date-website

Direct-to-Storage Uploads (Uppy)

Purpose

Large uploads (gallery photos, exam files, archive documents) previously traveled as multipart POST bodies through the web process before reaching S3-compatible storage. On the production cluster this strains the small web pods. With direct uploads, the browser PUTs files straight to the S3-compatible endpoint (Backblaze B2 in production) using short-lived presigned URLs; the web process only issues the signatures and later moves the object from a temp key to its final key with a server-side copy.

The feature uses the pinned Uppy 5.2.4 browser bundle from Transloadit’s release CDN. The site-specific integration remains in static/common/uploads/js/upload-init.js (one file serving both the direct Uppy mode and the classic fallback) with shared styling in static/common/uploads/css/upload-widget.css.

How it fits together

  1. Forms that upload files use DirectUploadField (see core/upload_widgets.py). When enabled it renders a Uppy Dashboard container plus a hidden input; when disabled it degrades to a classic file input with a removable list of the selected files, so local and self-hosted setups behave as before while keeping the same file management UX. If the Uppy bundle fails to load, the direct widget falls back to that classic input with a status message instead of leaving the page silently unusable.
  2. POST /_uploads/sign/ (core/uploads.py, mounted by core/urls/common.py for every association) validates the request and returns a presigned PUT URL for a server-generated key under tmp/. Scopes mirror the existing access gates:

    Scope Allowed Gate
    gallery images gallery.add_album or archive.add_collection permission
    gallery-admin images staff
    exambank images, documents same gate as the exam bank (ExamBankAccessSettings)
    admin images, documents staff
  3. The browser uploads directly to the S3 endpoint (CORS on the bucket allows the site origin). On form submit the hidden input carries the temp keys; the app’s save path calls core.uploads.finalize_upload, which copies the temp object to the model’s final key (<year>/<album>/... etc., computed by the same upload_to callables as before) and deletes the temp object. Full file bodies never pass through the web process. Finalization reads up to 512 bytes for type validation.

  4. The widget only manages temp uploads. Below the Dashboard it renders the list of files already uploaded to the temp prefix, rehydrated from the hidden input on every page load, so uploaded files stay visible and removable across reloads and validation errors; removing an entry drops it from the payload (the temp object is left for the bucket lifecycle rule). Files already attached to model rows are not listed: those are managed through the admin inlines and the normal model delete flows.

  5. Gallery photos are compressed client-side (@uppy/compressor, 1600px, quality 60) before upload. Compression preserves the input format so the signed key extension continues to match the uploaded bytes. Compression and upload concurrency are sized to the device from navigator.deviceMemory and navigator.hardwareConcurrency: strong machines compress several images at once and upload without a concurrency cap, weak devices run one compression and a few uploads at a time so the tab stays responsive and memory stays bounded. Direct-uploaded photos set Photo._skip_compress so the server does not re-resize them. Large images can still require substantial browser memory while decoded.

Enabling (per deployment)

Security model

Known limits / follow-ups