Frontend contract
Once the module is required and configured, every file_attr field backed by an
underlying_attachment automatically gets an upload block in its options — for both places
a field's options are rendered: the resource field schema (list/show columns) and Create/Edit
form fields. The latter is what UploadInput actually reads
to decide chunked upload vs. base64 — make sure the field is included in your CreateView /
EditView boxes, not just fields, or the form silently falls back to base64 for it.
{
"name": "photo",
"format": "base64-image",
"options": {
"upload": {
"provider": "s3-multipart",
"threshold": 5242880,
"chunk_size": 5242880,
"min_chunk_size": 5242880,
"initiate_url": "/habarico/uploads/initiate?field=eyJtb2RlbCI6..."
},
"max_size": 5242880,
"accept": ["image/png", "image/jpeg"]
}
}
max_size and accept sit beside upload, not inside it — they describe the field, not the
transport, so they are emitted for the base64 path as well. See
What the frontend checks up front.
The tus provider emits endpoint instead of initiate_url / min_chunk_size:
{
"provider": "tus",
"threshold": 5242880,
"chunk_size": 5242880,
"endpoint": "https://tusd.example.com/files/"
}
The frontend uses chunked upload when the file exceeds threshold bytes. 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.
initiate_url carries an opaque signed ?field= token. It is the only thing that tells the
otherwise field-agnostic endpoints which field a request is for, so initiate can enforce that
field's upload validations. Treat the whole URL as opaque — the frontend
already preserves the query string when it derives the complete / abort endpoints from it, so
nothing on the frontend has to know about the token. It carries that field's rules and nothing else
— no storage (there is only ever the app's default ActiveStorage service) — and complete /
abort ignore it entirely.
options.upload is nil — and the field falls back to base64 — whenever any of the following is
true:
- the
habarico-enterprisegem is not installed, or the module was never required (require "habarico_enterprise/uploads"), - no enabled provider supports the app's default
ActiveStorageservice (e.g.enabled_providers = [:s3]but the default service isn'tService::S3Service), - the
file_attrfield has nounderlying_attachment— there is nothing for a provider to finalize a chunked upload into.
Upload validations
The content-type blocklist
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 one here would
turn a file upload into stored XSS once the object is served back from the bucket. For the S3 path
this is the only place it is enforceable: the presigned URL binds Content-Type into its
signature, and finalize's copy_object carries whatever was declared at initiate forward
unchanged into the final, served object.
A field's content_type: validation can name a blocked type back in - deliberately, not by
accident. Naming it exactly (content_type: 'image/svg+xml', or the :svg shorthand) opts
that field back in; initiate then accepts it. A wildcard (image/*) or Regexp match never
does — content_type: %r{\Aimage/.*\z} still refuses SVG, because a pattern is not a considered
decision about SVG specifically, and silently admitting script-capable SVG through every
pattern-matched 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:
Config.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 naming
a type exactly — so it carries the same serving-side responsibility, at a larger scale.
Per-field rules
Beyond that floor, initiate enforces the content_type: and size: validations declared on the
file_attr's underlying_attachment with
active_storage_validations:
class Product < ApplicationRecord
has_one_attached :photo_attachment
file_attr :photo, underlying_attachment: :photo_attachment, nature: :image
validates :photo_attachment,
content_type: %w[image/png image/jpeg],
size: { less_than: 5.megabytes }
end
content_type:— strings, the:png-style extension shorthand and regexes all work; the shorthand is resolved through Marcel exactly as the validator itself resolves it on save.size:— the upper bound ofless_than,less_than_or_equal_to,betweenorequal_to. Agreater_than-only validation has no upper bound, so nothing is enforced atinitiate. The stricter of this andConfig.max_upload_sizewins (see Upload size limits).
A field with neither accepts any content_type except the three blocked by default above, and any
size up to Config.max_upload_size.
The rules are derived once, when the field schema is built, and travel inside the signed 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.
They are the same values the field advertises as max_size / accept, delivered in the same
response — so the two cannot disagree, and both refresh together when the client refetches the
schema. A token issued before a model's validators were tightened keeps its old rules until then;
the blocklist and the validators themselves on save are unaffected either way.
A rejected content_type answers 422 Unprocessable Entity, the same as every other initiate
refusal.
What the frontend checks up front
The same declarations are also advertised in the field's options, so a file that would be refused
anyway never leaves the browser:
| Option | Filled from | Checked as |
|---|---|---|
max_size | the size: validation, and Config.max_upload_size when the s3 provider serves the field | file.size > max_size → refused |
accept | the content_type: validation | extension (.png), concrete type (image/png) or wildcard (image/*) |
This is a courtesy, not the enforcement — the server checks every upload regardless, and two things deliberately do not reach the frontend:
- The blocklist is not in
accept. It has no allowlist form, so a field allowingimage/*lets the browser offer an SVG thatinitiatethen refuses. - A
Regexpcontent_type:yields noacceptat all. Dropping only the regex entries would leave a list stricter than the server's, refusing filesinitiatewould accept — so such a field advertises nothing. An absentaccepttherefore never means "anything goes server-side".
Config.max_upload_size joins max_size only for a field the s3 provider serves, because
Providers::S3#initiate is the only place that enforces it — advertising it for a base64 or tus
field would refuse client-side what the server accepts.
Both are ordinary field options, so a value already set for the field is left alone rather than overwritten by the derived one.
An option given as a lambda (content_type: ->(record) { ... }) is resolved by the gem per record.
There is no record yet at initiate, so such a field falls back to the blocklist alone — the
validator itself still runs on save.
The tus provider has no initiate call to enforce anything in; the frontend talks to tusd
directly. Its uploads are checked by the validators themselves when the finalized blob is attached
on save, against the bytes that actually arrived.
S3 upload lifecycle
POST <initiate_url>— presigns either a singlePUT(files under 5 MB, S3's multipart minimum) or a full multipart upload (one presignedupload_partURL per part), and returns amodediscriminator ("put"or"multipart") selecting the flow. It answers422 Unprocessable Entityinstead when the claimedsizewould need more than 10 000 parts or exceedsConfig.max_upload_size(see Upload size limits).- The frontend (Uppy's AwsS3Multipart plugin) uploads parts directly to S3.
POST /habarico/uploads/complete— completes the multipart upload (mode: "multipart"only;mode: "put"sends the returnedkeystraight through asupload_key).POST /habarico/uploads/abort— cancels and cleans up on user abort.
On record save, Eluvia::File#upload_key is finalized (Providers::S3#finalize): the tmp object
is moved to its final key via a server-side copy_object (no bytes flow through the Rails
process), the tmp object is deleted, and an ActiveStorage::Blob is registered for it via
Blob.create_before_direct_upload! — the same low-level API Rails' own DirectUploadsController
uses for direct-to-service uploads — then attached with zero re-upload.
byte_size / content_type are read back from S3 (head_object, authoritative); checksum is
best-effort from the object's ETag — S3 multipart ETags are not a plain MD5, and nothing
re-verifies this checksum against the object once attached.
tus upload lifecycle
- The frontend uploads directly to the tus
endpointvia the TUS protocol. - It derives
upload_keyfrom the TUS upload URL (the ID shaped by the pre-create hook, see tus server setup) and sends it as{"upload_key": "..."}on POST/PUT. - habarico validates the key against
Config::Tus.tmp_prefix, then finalizes the result on save.
Unlike the Python library, Ruby habarico has no generic (non-model) upload targets — the S3
endpoints always resolve against the app's single default ActiveStorage service (see
Installation). The ?field= token is not Python's ?target=: it selects no
storage, only the field whose validations initiate enforces.
See also
- UploadInput — every prop and option
- useUploadQueue — building your own upload UI
- Frontend contract — the Python counterpart