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:
@@ -36,6 +36,7 @@ from apps.library.models import (
|
||||
TempUpload,
|
||||
)
|
||||
from apps.library.services import MEDIA_FILE_SALT
|
||||
from apps.library.signing_urls import sign_payload
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -122,21 +123,15 @@ class SecurityTestCase(TestCase):
|
||||
return item
|
||||
|
||||
def old_signature(self, item, action="raw", age=3 * 86400):
|
||||
"""A valid signature minted `age` seconds ago."""
|
||||
real_time = signing.time
|
||||
|
||||
class Backdated:
|
||||
def time(self):
|
||||
return real_time.time() - age
|
||||
|
||||
try:
|
||||
signing.time = Backdated()
|
||||
return signing.dumps(
|
||||
{"item": item.id, "user": self.users["sec-uploader"].id, "action": action},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
)
|
||||
finally:
|
||||
signing.time = real_time
|
||||
"""A signed media URL whose expiry is `age` seconds in the past."""
|
||||
return signing.Signer(salt=MEDIA_FILE_SALT).sign_object(
|
||||
{
|
||||
"item": item.id,
|
||||
"user": self.users["sec-uploader"].id,
|
||||
"action": action,
|
||||
"exp": int(time.time()) - age,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GuestVisibilityTests(SecurityTestCase):
|
||||
@@ -183,9 +178,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
def test_authenticated_users_and_signed_urls_see_protected_items(self):
|
||||
uploader = self.client_for("sec-uploader")
|
||||
self.assertEqual(uploader.get(f"/api/files/J-{self.hidden.id}/").status_code, 200)
|
||||
signed = signing.dumps(
|
||||
signed = sign_payload(
|
||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
MEDIA_FILE_SALT,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.guest.get(f"/api/files/J-{self.hidden.id}/raw/?sig={signed}").status_code,
|
||||
@@ -193,9 +188,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
)
|
||||
|
||||
def test_signature_integrity(self):
|
||||
signed = signing.dumps(
|
||||
signed = sign_payload(
|
||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
MEDIA_FILE_SALT,
|
||||
)
|
||||
raw = f"/api/files/J-{self.hidden.id}/raw/"
|
||||
thumbnail = f"/api/files/J-{self.hidden.id}/thumbnail/"
|
||||
@@ -203,10 +198,29 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
self.assertEqual(self.guest.get(f"{raw}?sig={signed[:-4]}AAAA").status_code, 404)
|
||||
# Valid signature, wrong action.
|
||||
self.assertEqual(self.guest.get(f"{thumbnail}?sig={signed}").status_code, 404)
|
||||
# Expired signature (minted three days ago).
|
||||
# Expired signature (expiry three days ago).
|
||||
expired = self.old_signature(self.hidden)
|
||||
self.assertEqual(self.guest.get(f"{raw}?sig={expired}").status_code, 404)
|
||||
|
||||
def test_signed_media_urls_are_stable_and_versioned(self):
|
||||
"""The same item must keep the same URL across responses.
|
||||
|
||||
A per-second signature made browsers re-download every image on every
|
||||
poll; the MD5 version parameter busts caches only when the file itself
|
||||
changes (the optimize flow rewrites files under the same J-ID).
|
||||
"""
|
||||
item = self.visible
|
||||
first = services.signed_media_url(item, self.users["sec-uploader"])
|
||||
time.sleep(1.1)
|
||||
second = services.signed_media_url(item, self.users["sec-uploader"])
|
||||
self.assertEqual(first, second)
|
||||
self.assertIn(f"v={item.md5}", first)
|
||||
MediaItem.objects.filter(pk=item.pk).update(md5="b" * 32)
|
||||
item.refresh_from_db()
|
||||
self.assertNotEqual(
|
||||
services.signed_media_url(item, self.users["sec-uploader"]), first
|
||||
)
|
||||
|
||||
|
||||
class RoleBoundaryTests(SecurityTestCase):
|
||||
def test_non_uploader_is_read_only(self):
|
||||
@@ -451,9 +465,9 @@ class ThrottleTests(SecurityTestCase):
|
||||
md5=hashlib.md5(b"throttle-temp").hexdigest(),
|
||||
size=6,
|
||||
)
|
||||
signature = signing.dumps(
|
||||
signature = sign_payload(
|
||||
{"temp": str(temp.id), "user": self.users["sec-uploader"].id},
|
||||
salt=services.UPLOAD_FILE_SALT,
|
||||
services.UPLOAD_FILE_SALT,
|
||||
)
|
||||
url = f"/api/uploads/{temp.id}/file/?sig={signature}"
|
||||
codes = {self.guest.get(url).status_code for _ in range(150)}
|
||||
|
||||
Reference in New Issue
Block a user