A TimestampSigner value is an HMAC over 'payload:timestamp', so a plain Signer's HMAC check accepts it and the embedded timestamp then reached the JSON decoder, raising JSONDecodeError (not BadSignature) and surfacing as a 500. That broke every stored visual-match thumbnail URL minted before the stable scheme, so the J-ID match tiles never loaded on prod. Detect the legacy shape by its extra separator and verify it with TimestampSigner; malformed input returns None instead of raising.
65 lines
2.3 KiB
Python
65 lines
2.3 KiB
Python
"""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.
|
|
|
|
Legacy ``TimestampSigner`` values are still accepted for one release.
|
|
Detect them by their extra separator (``payload:timestamp:signature``):
|
|
a plain ``Signer`` accepts the HMAC a ``TimestampSigner`` computed over
|
|
``payload:timestamp`` and then chokes on the embedded timestamp while
|
|
decoding the JSON payload, which used to surface as a 500.
|
|
"""
|
|
if not signature:
|
|
return None
|
|
if signature.count(":") >= 2:
|
|
try:
|
|
return signing.TimestampSigner(salt=salt).unsign_object(
|
|
signature, max_age=legacy_max_age
|
|
)
|
|
except (signing.BadSignature, ValueError):
|
|
return None
|
|
try:
|
|
data = signing.Signer(salt=salt).unsign_object(signature)
|
|
except (signing.BadSignature, ValueError):
|
|
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
|