Skip to main content
Premium

Frontend Contract

File and image field schemas gain an upload block when a provider is active. The frontend (e.g. Uppy @uppy/aws-s3 or @uppy/tus) uses chunked upload when the file exceeds the threshold bytes (from HABARICO_UPLOADS["CHUNK_THRESHOLD"]). The provider ("s3-multipart" / "tus") selects the flow and chunk_size is the part size to slice into. For the S3 provider, min_chunk_size is the S3 hard minimum part size (5 MiB); the frontend uses it to decide multipart vs. a single upload, so this protocol floor need not be hardcoded client-side.

For the S3 provider, each field's initiate_url carries a single opaque ?target=<value> query param; the frontend POSTs filename/size/content_type to it and the backend presigns directly into the resolved storage. complete/abort take the same target query param. (The tus provider needs no initiate call - the frontend talks to the tusd endpoint directly.)

The target value is resolved server-side to a storage as follows:

  • omitted / empty - the default storage is used.
  • signed token - issued by habarico (e.g. in a field's initiate_url); resolves to a STORAGES alias or a registered target's storage.
  • registered target name - a name registered via register_upload_target (see below).

Anything else resolves to nothing and the endpoint answers 404. The registry is the allowlist of storages a client may name; every other storage - including a STORAGES alias - is reachable only through a token, and only server-side code can sign one.

caution

The token is signed, not encrypted. It is base64-encoded JSON plus an HMAC, so anyone holding the URL can decode it and read the storage alias it names and the upload rules it carries - they just cannot change either. Treat it as tamper-proof, never as secret, and do not put anything in a target token that the client may not see.

That is why a token never names a model field. It used to carry app_label.model.field, which leaked more than the schema does — a serializer source= makes the schema's field name and the model's differ. A storage is now always named by its STORAGES alias, a name the project picked.

caution

A STORAGES alias used to be accepted as plaintext too. It no longer is, because any alias the project configures could then be named from the browser and presigned into (?target=staticfiles). Register the storage as a target, or hand the frontend sign_upload_target(storage="...").

A field's own generated initiate_url always carries a signed token now, even for the default storage - previously it omitted ?target= in that case. The "omitted / empty" resolution rule above still exists (a hand-built URL with no target still resolves to default), but nothing habarico generates produces that form any more, since the token is also what carries the upload rules below.

The frontend sends {"upload_key": "<key>"} for that field on POST/PUT.

Content-type allowlisting​

initiate rejects text/html, application/xhtml+xml and image/svg+xml by default - these are formats a browser can be made to render inline instead of download, so accepting them here would turn a file upload into stored XSS once served back from the bucket.

Beyond that floor, a field with a derivable type gets an additional allowlist: a field carrying Django's validate_image_file_extension (as Base64ImageFileSerializer does) allows image/*; a field carrying a FileExtensionValidator allows the concrete types guessed from its allowed extensions. The model field is never consulted for this - only for storage - since a serializer field's own validators are independent of it and are what actually runs on save. A field with neither accepts any content_type except the three blocked by default above.

A field's allowlist can name a blocked type back in - deliberately, not by accident. Naming it exactly (FileExtensionValidator(allowed_extensions=["svg"]) -> image/svg+xml, or ["html"] -> text/html) overrides the default for that field only; initiate then accepts it. A wildcard match never does - image/* (from validate_image_file_extension) still refuses SVG, because "accepts images" is not the same statement as "accepts SVG specifically", and silently admitting script-capable SVG through every generic image field would defeat the point of the blocklist. Opting a field in this way is on the host: habarico does not sanitise the file or change how it is served afterwards, so serve it safely yourself (forced Content-Disposition: attachment, a sandboxed origin/CDN, a strict CSP, or server-side sanitisation - pick what fits).

The default itself is a project-wide setting, not a constant: HABARICO_UPLOADS["DANGEROUS_CONTENT_TYPES"] (see Configuration) overrides or empties it. Emptying it is a broader, project-wide version of the same trade-off as the per-field opt-in above - it removes the floor for every field with no restriction of its own, not just one that names a type exactly - so it carries the same serving-side responsibility, at a larger scale.

The allowlist is derived once, when the field schema is built, and travels inside the signed target token. The signature is what makes that safe: a client can read the token but cannot rewrite it into a more permissive one, or present one habarico never issued. It is the same value the field advertises as accept, delivered in the same response, so the two cannot disagree and both refresh together when the client refetches the schema.

A rejected content_type answers 422 Unprocessable Entity, same as every other initiate error.

Per-field size limits​

Django and DRF ship no file-size validator, so MaxFileSizeValidator is the declarative hook for a field that needs a ceiling below the project-wide MAX_UPLOAD_SIZE:

from habarico_enterprise.uploads.validators import MaxFileSizeValidator

class ProductSerializer(AdminModelSerializer):
datasheet = AdminFileSerializer(validators=[MaxFileSizeValidator(5 * 1024 * 1024)])

It is read from the same side, and on the same schedule, as the content-type allowlist above: off the serializer field's own validators, once per schema build. initiate applies whichever of it and MAX_UPLOAD_SIZE is stricter (see Upload size limits) and answers 422 Unprocessable Entity when the claimed size is over it — so an oversized file is refused before anything is presigned, rather than uploaded in full and rejected on save.

The same validator still runs on save, where every other field validator does, so the base64 path and the tus path (which has no initiate call to enforce anything in) are covered by the declaration too — there against the bytes that actually arrived, not the claimed size.

What the frontend checks up front​

Both declarations are also advertised in the field's options, beside (not inside) the upload block — they describe the field, not the transport, so they are emitted for the base64 path as well:

{
"name": "datasheet",
"options": {
"upload": {"provider": "s3-multipart", "threshold": 5242880, "initiate_url": "..."},
"max_size": 5242880,
"accept": ["application/pdf"]
}
}
OptionDerived fromChecked as
max_sizethe field's MaxFileSizeValidator, and MAX_UPLOAD_SIZE when a chunked provider serves the fieldfile.size > max_size → refused
acceptthe content-type allowlist aboveextension (.png), concrete type (image/png) or wildcard (image/*)

This is a courtesy, not the enforcement — the server checks every upload regardless, and the hard blocklist deliberately does not reach the frontend: it has no allowlist form, so a field whose validator yields image/* lets the browser offer an SVG that initiate then refuses. An absent accept never means "anything goes server-side".

MAX_UPLOAD_SIZE joins max_size only for a field a chunked provider actually serves, because initiate is the only place that enforces it — advertising it for a base64-only field would refuse client-side what the server accepts.

Both are ordinary field options, so a value passed explicitly (AdminFileSerializer(options={...})) wins over the derived one.

Every chunked-upload field needs a registered storage​

A chunked-upload field's storage must be reachable under a STORAGES alias, because that alias is the name its target token carries. A field whose storage matches no alias raises ImproperlyConfigured when the schema is built:

STORAGES = {
"default": {"BACKEND": "django.core.files.storage.FileSystemStorage"},
"uploads-bucket": {"BACKEND": "storages.backends.s3.S3Storage", "OPTIONS": {...}},
}

class Product(models.Model):
datasheet = models.FileField(storage=storages["uploads-bucket"])

Matching is by instance first, then by the storage's deconstructed configuration — so building the same storage yourself (S3Storage(**the_same_options)) still resolves to the alias that configures it, and an unset FileField() resolves to default through Django's lazy default_storage proxy. Only a storage no alias configures at all is refused.

Generic (non-model) uploads​

Uploads are not tied to a model field. To presign into an arbitrary storage, register a named target and hand the frontend a signed token for it:

from habarico_enterprise.uploads.targets import register_upload_target, sign_upload_target

register_upload_target("imports", storage=my_s3_storage, tmp_prefix="tmp/uploads/")
# the app's own endpoint hands the frontend: sign_upload_target(target="imports")
# after complete, the app calls provider.finalize(upload_key, storage, "imports/<name>") itself.

register_upload_target(name, *, storage, tmp_prefix=None) registers a storage under a name; sign_upload_target(*, field=/storage=/target=) (exactly one keyword) issues the opaque token the frontend passes as ?target=.

provider.finalize(upload_key, storage, dest_path) is the storage+path primitive that moves the temporary upload to its final location. finalize_model_field(upload_key, field_file) is the model-field convenience used internally by the serializer. Generic uploads have no automatic finalize step - the caller invokes provider.finalize itself after complete.

S3 Upload Lifecycle​

POST /admin/uploads/initiate returns a mode discriminator selecting the flow. Small files (below the S3 multipart minimum, 5 MB) use a single presigned PUT (mode: "put", one url, no complete); larger files use multipart (mode: "multipart").

For mode: "multipart":

  1. POST /admin/uploads/initiate – initiate chunked upload, receive presigned URLs
  2. Upload parts directly to the presigned URLs
  3. POST /admin/uploads/complete – complete and finalize the upload
  4. POST /admin/uploads/abort – cancel and cleanup on user abort

For mode: "put": upload the whole file to the single presigned url, then send the returned key as upload_key on POST/PUT (no complete call).

initiate answers 422 Unprocessable Entity instead of either mode when the claimed size would need more than 10 000 parts or exceeds MAX_UPLOAD_SIZE (see Upload size limits).

TUS Upload Lifecycle​

  1. Frontend uploads directly to the TUS endpoint via the TUS protocol
  2. Frontend derives upload_key from the TUS upload URL (the ID shaped by the pre-create hook, see 12.5) and sends it as {"upload_key": "..."} on POST/PUT
  3. Habarico validates the key, then finalizes the result on save