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
defaultstorage is used. - signed token - issued by habarico (e.g. in a field's
initiate_url); resolves to aSTORAGESalias 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.
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.
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"]
}
}
| Option | Derived from | Checked as |
|---|---|---|
max_size | the field's MaxFileSizeValidator, and MAX_UPLOAD_SIZE when a chunked provider serves the field | file.size > max_size → refused |
accept | the content-type allowlist above | extension (.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":
POST /admin/uploads/initiate– initiate chunked upload, receive presigned URLs- Upload parts directly to the presigned URLs
POST /admin/uploads/complete– complete and finalize the uploadPOST /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
- Frontend uploads directly to the TUS endpoint via the TUS protocol
- Frontend derives
upload_keyfrom the TUS upload URL (the ID shaped by the pre-create hook, see 12.5) and sends it as{"upload_key": "..."}on POST/PUT - Habarico validates the key, then finalizes the result on save