Skip to main content
Premium

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-enterprise gem is not installed, or the module was never required (require "habarico_enterprise/uploads"),
  • no enabled provider supports the app's default ActiveStorage service (e.g. enabled_providers = [:s3] but the default service isn't Service::S3Service),
  • the file_attr field has no underlying_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 of less_than, less_than_or_equal_to, between or equal_to. A greater_than-only validation has no upper bound, so nothing is enforced at initiate. The stricter of this and Config.max_upload_size wins (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:

OptionFilled fromChecked as
max_sizethe size: validation, and Config.max_upload_size when the s3 provider serves the fieldfile.size > max_size → refused
acceptthe content_type: validationextension (.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 allowing image/* lets the browser offer an SVG that initiate then refuses.
  • A Regexp content_type: yields no accept at all. Dropping only the regex entries would leave a list stricter than the server's, refusing files initiate would accept — so such a field advertises nothing. An absent accept therefore 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.

note

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​

  1. POST <initiate_url> — presigns either a single PUT (files under 5 MB, S3's multipart minimum) or a full multipart upload (one presigned upload_part URL per part), and returns a mode discriminator ("put" or "multipart") selecting the flow. It answers 422 Unprocessable Entity instead when the claimed size would need more than 10 000 parts or exceeds Config.max_upload_size (see Upload size limits).
  2. The frontend (Uppy's AwsS3Multipart plugin) uploads parts directly to S3.
  3. POST /habarico/uploads/complete — completes the multipart upload (mode: "multipart" only; mode: "put" sends the returned key straight through as upload_key).
  4. 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​

  1. The frontend uploads directly to the tus endpoint via the TUS protocol.
  2. It derives upload_key from 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.
  3. habarico validates the key against Config::Tus.tmp_prefix, then finalizes the result on save.
caution

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​