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,57 @@
|
||||
"""Stable, expiring signatures for media URLs.
|
||||
|
||||
The SPA loads media with ``<img>``/``<video>`` tags, which cannot send the
|
||||
API's ``Authorization`` header, so those URLs carry a signature instead. The
|
||||
signature has to be *stable*: a URL that changes on every response makes the
|
||||
browser treat every refetch as a new resource and re-download the file.
|
||||
|
||||
URLs are signed with a plain ``Signer`` (no per-second timestamp) plus an
|
||||
explicit ``exp`` claim quantized to a bucket, so every request inside a bucket
|
||||
mints the exact same URL. The URL rotates once per bucket and is valid for at
|
||||
least ``URL_TTL_SECONDS`` and at most ``URL_TTL_SECONDS + URL_BUCKET_SECONDS``.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from django.core import signing
|
||||
|
||||
URL_TTL_SECONDS = 7 * 86400
|
||||
URL_BUCKET_SECONDS = 24 * 3600
|
||||
_BUCKETS = URL_TTL_SECONDS // URL_BUCKET_SECONDS
|
||||
|
||||
|
||||
def _expiry(now=None):
|
||||
current = time.time() if now is None else now
|
||||
bucket = int(current // URL_BUCKET_SECONDS)
|
||||
return (bucket + _BUCKETS + 1) * URL_BUCKET_SECONDS
|
||||
|
||||
|
||||
def sign_payload(payload, salt, now=None):
|
||||
"""Sign a payload with a stable, bucket-quantized expiry."""
|
||||
return signing.Signer(salt=salt).sign_object(
|
||||
{**payload, "exp": _expiry(now)}
|
||||
)
|
||||
|
||||
|
||||
def load_payload(signature, salt, legacy_max_age=86400):
|
||||
"""Verify a signed payload; ``None`` when missing, tampered with or expired.
|
||||
|
||||
Signatures minted before the stable scheme (``TimestampSigner``) are still
|
||||
accepted for one release so pages open across the deploy keep working.
|
||||
"""
|
||||
try:
|
||||
data = signing.Signer(salt=salt).unsign_object(signature)
|
||||
except signing.BadSignature:
|
||||
try:
|
||||
return signing.TimestampSigner(salt=salt).unsign_object(
|
||||
signature, max_age=legacy_max_age
|
||||
)
|
||||
except signing.BadSignature:
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
expired = int(data.get("exp", 0)) < time.time()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if expired else data
|
||||
Reference in New Issue
Block a user