Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

File Fields & Uploads

A field tagged mfx:"file" accepts an uploaded file alongside the model’s JSON. The column stores an opaque storage key; the bytes live in a configured FileStorage backend. Standalone upload, download, and delete endpoints can also be mounted (see Standalone file endpoints).

Config changed. File settings now live under a single Config.FilesConfig struct (maniflex.FilesConfig). The old flat Config.FileStorage, Config.FileSignedURLTTL, and Config.FileMiddleware fields have been removed. The mapping is:

Old (Config.…)New (Config.FilesConfig.…)
FileStorageStorage
FileSignedURLTTLSignedURLTTL
FileMiddlewareBeforeMiddlewares
(implied by FileStorage != nil)MountEndpointsnow explicit, see the footgun

New: KeyGen (custom storage-key layout) and AfterMiddlewares (post-handler observation).

Declaring a file field

Add the file directive to a string field:

type Article struct {
    maniflex.BaseModel
    Title string `json:"title" mfx:"required"`
    Cover string `json:"cover" mfx:"file,max_size:2MB,accept:image/*"`
}

The column’s Go and DB types are string — what is stored is the storage key returned after the upload. The on-disk bytes are managed by the storage backend; the database row holds only the reference.

A file field’s Go type must be string (one key) or maniflex.FileKeys (many). Anything else is a registration error: every file rule is keyed on the column being a storage key, so on another type they would all be skipped.

Tag sub-options:

Sub-optionEffect
filemark the field as a file upload
max_size:Nper-field size limit; suffixes KB, MB, GB or plain bytes. On a FileKeys field it bounds each file, not their total
max_count:NFileKeys only — maximum number of keys (default 100). See Many files per field
accept:p1|p2allowed MIME-type patterns, e.g. image/*|application/pdf
auto_delete:falsekeep the stored file when the row is hard-deleted or the field is replaced (default: delete)
upload:presignedmount POST /{model}/{field}/upload-url so the client uploads straight to storage instead of through the app — see Direct-to-storage uploads. Requires a backend that can presign (storage/s3; not LocalStorage)
upload:streammultipart upload, but piped straight to storage as it arrives instead of buffered to the app server’s disk first — see Streaming uploads. Mutually exclusive with upload:presigned
file_acl:private(default) response carries the raw storage key; downloads go via /files/<key> or the per-model attachment route
file_acl:signedresponse replaces the key with a pre-signed URL valid for Config.FilesConfig.SignedURLTTL (default 1h). Requires FileStorage.URL()
file_acl:publicresponse replaces the key with a permanent / long-lived URL (e.g. S3 7-day max). Pair with public-read ACL on the bucket for true permanence

Those three are the whole set — anything else is a registration error naming the field. A typo used to be read as private, which looks like the safe direction and is not the safe outcome: the field was asked to serve signed or public URLs and served raw storage keys instead, with nothing to say why.

Who may write a file field

file_acl decides how the value is presented. The access tags decide who may set it, and they mean the same thing for a file field as anywhere else — a multipart upload is a client write like any other:

TagClient may uploadKey in responsesAttachment route
(none)yesyesyes
writeonlyyesnoyes
readonlynoyesyes
hiddennonoyes
immutableon create onlyyesyes

mfx:"file,writeonly" is the combination to reach for when the storage key should not appear in responses but the file must stay uploadable and downloadable. The client posts the file, every response omits the key, and GET /{model}/{id}/{field} still streams the bytes. The OpenAPI read schema marks the field writeOnly: true, so a generated client models it correctly rather than expecting a key that never arrives.

type Document struct {
    maniflex.BaseModel
    // Uploadable, never echoed, still downloadable through the attachment route.
    Scan string `json:"scan" db:"scan" mfx:"file,writeonly,max_size:10MB"`
    // Server-managed: only the application sets this.
    Report string `json:"report" db:"report" mfx:"file,readonly"`
}

A client upload to a readonly, hidden, or already-set immutable field is refused with 422 VALIDATION_ERROR, and those fields are left out of the multipart/form-data schema so a generated client never offers the part.

Before this was enforced, readonly and hidden were applied by stripping the field from the parsed JSON body — which a multipart part never passes through. A mfx:"file,readonly" field therefore refused a key reference but accepted an upload, so a client could set a server-managed document on create and replace its bytes on update. If you were relying on that to get “uploadable but not echoed”, writeonly is the tag that means it.

accept matches the content type the client declared on the multipart part; when the part declares nothing (or the generic application/octet-stream), the type is sniffed from the first 512 bytes. A declared type is a client-supplied claim, so treat accept as an input filter, not a security boundary — downloads are what enforce safety, via X-Content-Type-Options: nosniff and forced attachment for anything outside the inline allowlist (see Standalone file endpoints).

Tag detail is in Field Tags Reference.

Many files per field (FileKeys)

A gallery, or any attachment set, is a maniflex.FileKeys column:

type Post struct {
    maniflex.BaseModel
    Title  string            `json:"title"  mfx:"required"`
    Images maniflex.FileKeys `json:"images" mfx:"file,accept:image/*,max_size:5MB,max_count:10,file_acl:signed"`
}

It stores as a JSON array (JSONB on Postgres, TEXT on SQLite), so ordering is preserved exactly as written — a gallery keeps its sequence. Every rule a single-key field enforces applies per key: existence, max_size (per file, not per array), accept, file_acl signing on read, auto_delete, and cleanup on hard delete.

Write by key reference, not multipart. Upload each file first — via POST /files or a presigned upload — then send the keys:

PATCH /posts/1
{"images": ["uploads/a1/one.jpg", "uploads/b2/two.jpg"]}

A multipart upload to a FileKeys field is refused (422). Multipart carries one file per field, so it could only ever store one key and would silently drop the rest; and routing many large files through the app process is what presigned uploads exist to avoid.

A PATCH replaces the whole array, as it does any column. With auto_delete (the default), keys present before the write and absent after it are deleted from storage once the write commits. The diff is a set difference, so reordering deletes nothing, and a key you keep is never touched:

PATCH /posts/1  {"images": ["uploads/a1/one.jpg", "uploads/c3/three.jpg"]}
// one.jpg   kept    — still referenced
// two.jpg   deleted — dropped by this write
// three.jpg kept    — newly referenced

max_count bounds the array (default DefaultMaxFileCount, 100). Every key is Stat’d against storage to enforce the field’s rules, so an uncapped array would be one request buying N storage round-trips. Over the cap is 422 TOO_MANY_FILES.

No attachment route is mounted for a FileKeys field — that route streams one object’s bytes and a list names no single one. Use file_acl:signed to have each key rewritten to a URL in the response.

file_acl modes

type Attachment struct {
    maniflex.BaseModel
    Logo   string `mfx:"file,file_acl:public,max_size:1MB,accept:image/*"`
    Resume string `mfx:"file,file_acl:signed,max_size:5MB,accept:application/pdf"`
    Notes  string `mfx:"file"`  // implicit private — raw key in the response
}

The rewrite happens in the Response step on create, read, list, and update. A null/empty value passes through unchanged — no fabricated URLs to nothing.

Configure the signed-URL lifetime once on Config:

maniflex.New(maniflex.Config{
    FilesConfig: maniflex.FilesConfig{
        SignedURLTTL: 15 * time.Minute, // default: 1h
    },
})

S3Storage.URL uses awss3.NewPresignClient; ttl=0 (public mode) maps to the AWS 7-day maximum.

LocalStorage cannot sign. URL returns the server-relative /files/<key> for both signed and public modes, ignoring the TTL entirely — so file_acl:signed against it yields a permanent path where a time-limited one was asked for, and how exposed that path is depends on whatever guards GET /files/*. That is a weaker guarantee than the tag requests, so the framework no longer lets it pass unremarked: it warns at boot, naming the model and fields, and Config.Strict makes it a startup error.

Use a signing backend, or mark the field file_acl:private — its downloads go through the per-model attachment route, which enforces the same auth as reading the parent record. Bring an HMAC layer only if you need signed URLs and local disk.

A backend declares this by implementing maniflex.SignedURLCapable. Not implementing it means “I can sign”, so S3Storage and any third-party signer need do nothing.

Configuring storage

Uploads require a FileStorage implementation, set on Config.FilesConfig.Storage. The framework ships one backend; bring your own for cloud storage.

import "github.com/xaleel/maniflex/storage"

fs, err := storage.NewLocalStorage("./uploads")
if err != nil {
    log.Fatal(err)
}
defer fs.Close()

server := maniflex.New(maniflex.Config{
    Port: 8080,
    FilesConfig: maniflex.FilesConfig{
        Storage:        fs,
        MountEndpoints: true, // mount POST/GET/DELETE /files — see the footgun below
    },
})

LocalStorage pins the directory with Go’s directory-scoped os.Root; file and metadata operations may follow symlinks only while they remain inside that root. Links which escape it are rejected. Close the backend when the process no longer uses it to release the root directory handle.

FileStorage is a small interface — Store, Retrieve, Delete, Exists, URL — making S3, R2, GCS, or any other key-value store straightforward to adapt.

When Storage is nil, model endpoints still accept JSON, but multipart uploads and the standalone /files routes respond with 501 Not Implemented.

MountEndpoints is explicit

MountEndpoints gates only the standalone /files routes and defaults to false. Setting Storage alone is not enough to mount them:

You setMultipart on mfx:"file" fieldsPer-model attachment routesStandalone /files
Storage only✅ enabled✅ mountednot mounted (404)
Storage + MountEndpoints: true✅ enabled✅ mounted✅ mounted
MountEndpoints: true, Storage nil❌ 501❌ 501✅ mounted, returns 501

Migration footgun. Before this release a non-nil FileStorage auto-mounted /files. That is no longer implied — if you relied on the standalone endpoints, add MountEndpoints: true. Model file fields and per-model attachment routes remain gated on Storage alone and are unaffected.

Upload size limits

A multipart request is capped at 32 MB in total by default, across every part. Anything larger is rejected with 413 BODY_TOO_LARGE while it streams — before a byte is written to a temp file, let alone to storage. Raise or lower the ceiling per app:

FilesConfig: maniflex.FilesConfig{
    Storage:         fs,
    MaxUploadBytes:  100 << 20, // total request size (default 32 MB)
    MaxUploadMemory:   8 << 20, // buffered in RAM before spooling (default 32 MB)
}

MaxUploadBytes bounds the request; the per-field max_size tag bounds an individual attachment within it. Both apply, and the request ceiling is checked first — so max_size:2MB on a field does not stop a client from sending a 50 GB body, but MaxUploadBytes does.

For a tighter limit on one model, register body.MaxBodySize on the Deserialize step; it overrides MaxUploadBytes for the models it is scoped to.

Custom storage keys with KeyGen

By default a POST /files upload is stored under uploads/<uuid>/<sanitised-filename>. Override KeyGen to control the layout — for example, to shard by tenant read from the request context:

FilesConfig: maniflex.FilesConfig{
    Storage:        fs,
    MountEndpoints: true,
    KeyGen: func(ctx *maniflex.ServerContext, h *multipart.FileHeader) string {
        tenant := ctx.Auth.Claims["tenant"] // populated by a BeforeMiddleware
        return fmt.Sprintf("%s/%s", tenant, h.Filename)
    },
},

The returned string is used verbatim as the storage key — sanitise any user-supplied component yourself (the default uses sanitizeFilename). KeyGen applies only to the standalone POST /files route; per-model attachment keys are framework-generated.

KeyGen controls the layout of a key; it does not bind the key to a caller. The framework adds a scope prefix on top of whatever KeyGen returns — see Key ownership and FilesConfig.KeyScope.

S3, R2, MinIO, DigitalOcean Spaces

The satellite module maniflex/storage/s3 ships a FileStorage implementation backed by the AWS SDK v2. It works against any S3-compatible service.

import "github.com/xaleel/maniflex/storage/s3"

store, err := s3.New(ctx, s3.Config{
    Bucket: "my-app-uploads",
    Region: "us-east-1",
    // Endpoint, UsePathStyle, KeyPrefix, ACL are all optional.
})
if err != nil { log.Fatal(err) }
server.SetStorage(store)

Credentials follow the standard AWS resolution chain (env vars, shared config, IAM instance role, IRSA, ECS task role). Override with Config.AWSConfig when you need a custom credential provider, HTTP client, or retry policy.

Per-service tips:

ServiceEndpointUsePathStyle
AWS S3leave emptyfalse
MinIOhttp://localhost:9000true
Cloudflare R2https://<account>.r2.cloudflarestorage.comfalse
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.comfalse

Use KeyPrefix to share one bucket across environments (KeyPrefix: "staging/") — callers pass logical keys and never see the prefix. File metadata (filename, size, content type) is stored as native S3 object metadata so objects remain browsable via the AWS console and aws s3 cp without the maniflex layer.

How uploads work

A model containing one or more file fields accepts multipart/form-data on create and update, in addition to JSON:

  • Form fields named the same as JSON fields populate the row’s scalar values.
  • Form file parts named after a file field are streamed to storage; the resulting key is written to the column.

A form carries only strings, so a value bound for a column that is not one is converted before it is written, and a string that will not convert is 422 rather than stored:

ColumnAcceptedNote
number7, 1.5out of range for the column’s width is refused
booleantrue / false, 1 / 0, on / offon is what a checked checkbox posts; an unchecked one posts nothing, which is absence
timestampRFC 3339 (2020-01-02T03:04:05Z)a bare date is refused
stringanythingpassed through, so 01234 stays "01234"

An empty value for a non-string column is read as no value at all, because a browser posts every input in the form including the ones nobody filled in. A nullable column (*int) stores NULL; one whose type has no null is refused by name, the same answer the same body gets as JSON.

Conceptually:

POST /api/articles
Content-Type: multipart/form-data; boundary=...

--...
Content-Disposition: form-data; name="title"

The First Post
--...
Content-Disposition: form-data; name="cover"; filename="hero.png"
Content-Type: image/png

<bytes>
--...

The response is the usual JSON envelope; the cover field carries the storage key the client uses to fetch the file later.

The framework rejects an upload before it reaches storage if it violates the field’s max_size or accept constraints.

Sending a pre-uploaded key

A file field also accepts a plain string in JSON — the storage key of a file already uploaded via the standalone endpoint. This is useful when the upload is decoupled from the record creation (large files uploaded ahead of time, re-using an existing file, and so on).

The key is checked against storage before the write: setting a file field to a string key that does not exist in the configured FileStorage is rejected with 422 FILE_NOT_FOUND, so a record can never reference a dangling key. (Pass JSON null to clear the field.) In production the key exists because the client uploaded it first — via the multipart part, a prior POST /files, or a presigned upload. In tests, seed the key into the shared storage before referencing it.

The field’s max_size and accept rules apply here too, checked against the object actually in storage — the same bytes get the same answer whichever way they arrived. This is new in v0.2.3: before it, this path checked only that the key existed, so uploading out of band and referencing the key was a way past both rules. See the changelog if you relied on that.

Key ownership

A storage key is bound to the principal that mints it, so a key one caller learns cannot be pinned onto another caller’s record. Keys are not secrets — they travel in signed URLs and file_acl:private responses — and without this a caller who obtained another record’s (or another tenant’s) key could reference it on their own record, and an auto_delete field could then delete a blob it never owned.

Every minting path — POST /files, a presigned upload, and a multipart upload through a model — prefixes the key with a hash of the caller’s scope, and a reference by a different scope is refused with 403 FILE_FORBIDDEN. The scope defaults to ctx.Auth.TenantID (so a tenant’s members share one), else ctx.Auth.UserID. Override it when your principal lives elsewhere:

FilesConfig: maniflex.FilesConfig{
    Storage: store,
    // Bind each key to the user rather than the tenant. Guard the nil case:
    // an anonymous request has no principal, and returns an unscoped key.
    KeyScope: func(ctx *maniflex.ServerContext) string {
        if ctx.Auth == nil {
            return ""
        }
        return ctx.Auth.UserID
    },
}

The scope must resolve consistently across the mint request and the later reference — if your POST /files auth and your model auth populate ctx.Auth differently, read whatever both share.

A key minted while no principal was present is unscoped and referenceable by anyone; returning "" from KeyScope opts a request out. Keys minted before v0.2.3 carry no scope marker and are likewise left to the existence check, so upgrading breaks no reference to an already-stored key — the guarantee holds for every key minted under a principal from v0.2.3 on.

Direct-to-storage uploads (upload:presigned)

By default an upload travels client → app → storage, and the app holds the whole body while it does: the multipart form is drained before the handler runs, and the in-memory buffer defaults to the same 32 MB as the body cap, so nothing spools to disk either. A 60 MB video therefore costs 60 MB of server memory and two hops of bandwidth to store one object.

Add upload:presigned and the bytes go straight to the bucket:

type Post struct {
    maniflex.BaseModel
    Title string `json:"title"`
    Video string `json:"video" mfx:"file,upload:presigned,accept:video/mp4,max_size:60MB"`
}

That mounts one extra route:

POST /posts/video/upload-url

There is no record id in that path, deliberately: a create-time file field has no record yet, so a record-scoped route could not serve one. The same route works for create and update.

The flow is two phases, and the second one is just an ordinary write:

① POST /posts/video/upload-url
   {"filename": "clip.mp4", "content_type": "video/mp4", "size": 41231234}

   → 200 {
       "url":        "https://bucket.s3.amazonaws.com/",
       "method":     "POST",
       "fields":     { "key": "...", "policy": "...", "x-amz-signature": "..." },
       "key":        "uploads/<uuid>/clip.mp4",
       "max_size":   62914560,
       "expires_at": "2026-07-17T13:05:00Z"
     }

② the client POSTs the file straight to `url` as multipart/form-data,
   sending every entry of `fields` first and the file last

③ POST /posts   {"title": "...", "video": "uploads/<uuid>/clip.mp4"}
   → the ordinary create, which verifies the object and stores the key

Phase ③ is the completion step, and there is nothing else to call: the record either names the key or it does not. That is why no pending-upload state exists to reconcile — if a client uploads and never completes, no record references the object and nothing is corrupt (the object itself is an orphan; see auto_delete).

The field’s rules bind at both ends. At ① the declared content_type and size are checked against accept and max_size, so a URL is never minted for a file the field would refuse. The limits are then pinned into the signature — S3’s POST policy carries a content-length-range, so S3 itself rejects an oversize body — and at ③ the stored object’s real size and type are checked again. That last check is the one that matters: a signature can only bound what the backend enforces, and the record is what makes an object real.

The client never chooses the key. It is minted server-side through FilesConfig.KeyGen (so a per-tenant prefix scheme covers presigned uploads too) and returned in the response. A client that could name the key could aim its upload at another record’s object.

The authorisation covers one key, not one write. It is short-lived, not one-shot. Neither an S3 POST policy nor a presigned PUT can be spent — the storage backend verifies a signature, and a signature that verifies once verifies every time until it expires. So whoever holds the response from ① may repeat ② as often as they like inside the window — including after ③ has stored the key, overwriting the bytes a record already points at. No backend can close this; it is a property of signed URLs rather than of this framework.

Three things bound it. The key is minted server-side, so a replay can only rewrite that object and cannot reach another. The mint route is authenticated, so the window opens only for callers you already trust with the write. And expires_at in the response is the hard edge — set by FilesConfig.SignedURLTTL, default 1 hour.

An hour is generous for a capability that stays live after the upload finishes. Shorten it if your clients upload promptly — but note the same knob bounds signed downloads, so the two cannot be tuned apart; pick the shorter requirement. If the bytes must be immutable once written, verify them at ③ and copy the object to a key the client was never authorised for.

Auth applies. The mint route runs Auth → handler → Response, so whatever gates the model gates the minting — granting the right to write an object is not something to leave unauthenticated. Note the operation is maniflex.OpPresignUpload, not OpCreate: the mint is not the create and can precede one by minutes, so scope middleware with ForOperation(OpPresignUpload) if you need it there.

Backend support. Presigning requires a backend that can mint one. storage/s3 can (AWS S3, R2, MinIO, Spaces, …). LocalStorage cannot, and says so: the route answers 501 PRESIGN_UNSUPPORTED rather than handing back an unsigned URL, which would be an open write endpoint rather than a degraded presigned one. Use the ordinary multipart upload with LocalStorage.

Streaming uploads (upload:stream)

upload:presigned takes the bytes off the app entirely, which is ideal when the client can do the two-phase dance and the backend can presign. When it cannot — LocalStorage, or a client that only knows how to POST one multipart form — the bytes still have to pass through the app, and by default the whole request is buffered first (in memory up to MaxUploadMemory, the rest to temp files) before a single byte reaches storage. upload:stream removes that landing: the field’s part is piped straight into FileStorage.Store as it arrives off the socket.

type Post struct {
    maniflex.BaseModel
    Title string `json:"title"`
    Video string `json:"video" mfx:"file,upload:stream,accept:video/mp4,max_size:60MB"`
}

Nothing about the request shape changes — it is the same multipart POST /posts as an unflagged file field. Only where the bytes go changes: to the backend directly, not to the app’s disk first.

Two rules bind differently when the length is not known until the stream ends:

  • accept is checked first, from the part’s declared type (or a sniff of the first 512 bytes when it declares none), so a disallowed type is refused with 415 before anything is stored.
  • max_size is enforced mid-stream: the upload is cut off and answered 413 the moment it runs past the limit. By then a partial object exists in storage — the request ends non-2xx, so the same orphan cleanup that protects every failed upload deletes it (see Automatic cleanup). A backend that can reject early (S3 rejects an oversize multipart part) still saves the bandwidth; the framework catches the rest.

Because the length is unknown while streaming, FileStorage.Store receives a FileMeta.Size of 0 and must store an unsized reader — read it to EOF rather than trusting Size. storage/s3 does this transparently (the AWS SDK uploader switches to a multipart upload); LocalStorage copies the reader as-is. A custom backend that assumed a known length needs to handle the unsized case before enabling upload:stream against it.

Which one? upload:presigned when the bytes should never touch the app and the backend can presign — the best choice for the very largest files. upload:stream when they must pass through (to scan, transform, or because the backend cannot presign) but should not land on disk on the way. Plain file (buffered) for everything else — it is the default for a reason, and streaming trades a pre-storage validation gate for a stored-then-cleaned one. The two upload: strategies are mutually exclusive on one field; setting both is a registration error.

Standalone file endpoints

When MountEndpoints is true, three routes are mounted under PathPrefix:

MethodPathAction
POST/filesupload a single file (multipart, field name file)
GET/files/{key...}stream the file with its original content type
DELETE/files/{key...}remove the file from storage

POST /files returns 201 with {"data": {"key": "...", "content_type": "...", "size": ..., "filename": "..."}}. The returned key is the value to store in a file-tagged column. It streams the file part straight to storage as it arrives (see Streaming uploads), so a large standalone upload never lands on the app server’s disk; size in the response is the length measured while streaming. A custom FilesConfig.KeyGen here sees a *multipart.FileHeader whose Size is 0, since the length is not yet known — key off Filename/Header instead.

GET /files/{key...} streams the body with Content-Type and Content-Length from the stored metadata. For safety it always sends X-Content-Type-Options: nosniff and serves only an allowlist of content types (common images, PDF, plain text) inline; everything else — including text/html and image/svg+xml — is sent as a Content-Disposition: attachment download so a stored file cannot execute script on the API origin. The original filename is serialized as a standards-compliant media-type parameter rather than copied verbatim from storage metadata; quotes and controls cannot create extra header parameters, and names requiring UTF-8 use filename*. Missing keys return 404.

These endpoints are storage-key-addressed and have no built-in auth when BeforeMiddlewares is empty. Set it to wrap the routes with the same pipeline middleware (e.g. JWT, role checks) that protects your model endpoints:

maniflex.New(maniflex.Config{
    FilesConfig: maniflex.FilesConfig{
        Storage:        fs,
        MountEndpoints: true,
        BeforeMiddlewares: []maniflex.MiddlewareFunc{
            auth.JWTAuth(secret, auth.JWTOptions{}),
            auth.RequireRole("admin"),
        },
    },
})

Each middleware sees a synthesised ServerContext (Request, Writer, Ctx, RequestID, logger — no Model/Operation, since these routes are outside the model pipeline). Aborting the context short-circuits the request before the file handler runs. Leaving BeforeMiddlewares empty keeps the pre-fix behaviour for backward compatibility, but production deployments should populate it — anyone who guesses a key could otherwise delete arbitrary files. The server logs a warning at startup when /files is mounted without BeforeMiddlewares. For a deliberately public file service, set FilesConfig.AllowPublic; this suppresses strict/production validation errors but installs no middleware and changes no runtime behavior.

Before vs. after middleware

BeforeMiddlewares run before the handler and own request control: they can authenticate, populate ctx.Auth, or short-circuit (abort / set ctx.Response) to replace the response entirely.

AfterMiddlewares run after the handler has served the request. Because the handler streams its response (status, headers, body) straight to the client, the response is already committed by the time an after-middleware runs — so they are for observation and side effects only (audit logging, metrics, cleanup), never for altering the response. Read the outcome with:

AfterMiddlewares: []maniflex.MiddlewareFunc{
    func(ctx *maniflex.ServerContext, next func() error) error {
        status := ctx.Writer.(interface{ Status() int }).Status()
        log.Printf("file request completed: %d", status)
        return next()
    },
},

Setting ctx.Response from an after-middleware is ignored (and logged) rather than corrupting the already-sent body. To alter, replace, or block the response, use BeforeMiddlewares.

Per-model attachment routes

For each mfx:"file" field on each model, the framework mounts a record- scoped download path:

GET /:model/:id/:file_field

E.g. GET /api/patients/123/discharge_summary streams the file referenced by Patient.DischargeSummary for record 123.

Unlike GET /files/{key...}, this route runs through the read pipeline for the parent record — the same Auth, soft-delete, and tenancy middleware that protect GET /api/patients/123 also protect the download. Use this for any attachment whose access depends on the parent row.

Response codes:

StatusMeaning
200file streamed with Content-Type, Content-Disposition, Content-Length
206a Range request was satisfied — see Resumable downloads
404 NOT_FOUNDthe record does not exist (or is soft-deleted)
404 FILE_NOT_SETthe record exists but the field is null/empty
404 FILE_NOT_FOUNDthe field references a key that is missing from storage
401 / 403whatever the Auth middleware decided

The route is only mounted when Config.FilesConfig.Storage is configured; with no storage backend, the route is absent and requests return 404 from the router. (Unlike the standalone /files endpoints, per-model attachment routes do not require MountEndpoints.)

Internally this is dispatched as its own operation, maniflex.OpReadAttachment. Middleware filtered by ForOperation(OpRead) does apply to attachment requests: an attachment is a read of one record, so whatever decides who may read that record decides who may download its file. The implication runs one way only — ForOperation(OpReadAttachment) means attachment requests alone.

Before v0.2.5 an OpRead-scoped middleware did not run for attachments, so tenancy written that way left the attachment route unscoped.

Resumable downloads (HTTP Range)

Both download routes — GET /files/{key...} and the per-model attachment route — honour the Range request header, so a client can resume an interrupted download or seek into a large media file instead of refetching it from the start.

# the last 500 bytes
curl -H 'Range: bytes=-500' localhost:8080/api/files/uploads/<uuid>/clip.mp4

# resume from where a failed download stopped
curl -H 'Range: bytes=1048576-' localhost:8080/api/patients/123/scan

A satisfiable range answers 206 Partial Content:

HeaderValue
Content-Rangebytes 1048576-2097151/8388608 — the window, and the object’s full length
Content-Lengththe window’s length, not the object’s
Accept-Rangesbytes (also sent on full 200 responses, so clients know they may resume)

The security headers a full download sends — X-Content-Type-Options: nosniff and the Content-Disposition inline/attachment decision — are identical on a 206.

Response codes:

StatusWhen
206the range resolved to a window of the object
200no Range header, or one the server declined (see below)
416 RANGE_NOT_SATISFIABLEthe range is well-formed but starts past the end of the object; Content-Range: bytes */<size> tells the client the real length

Ranges the server declines. A malformed header, an unrecognised unit, and a multi-range request (bytes=0-99,200-299) are all answered with the whole object and 200 rather than an error — the spec permits ignoring any Range, and serving several windows would require a multipart/byteranges body. A client that asks for several windows gets the object and slices it itself.

Backend support

How much a range actually saves depends on the storage backend:

BackendBehaviour
LocalStorageseeks the file; only the window is read
storage/s3 (S3, R2, MinIO, Spaces)pushes the range into GetObject, so only the window leaves the bucket — you are not billed egress for bytes the client did not ask for
A custom backend implementing RangeRetrieversame as above
A custom backend that does notstill works: the Range header is ignored and the whole object is served with 200, and Accept-Ranges is not advertised

To support ranges in your own backend, implement the optional maniflex.RangeRetriever alongside FileStorage:

func (s *MyStorage) RetrieveRange(
    ctx context.Context, key string, offset, length int64,
) (io.ReadCloser, maniflex.FileMeta, error) {
    // Return exactly the bytes [offset, offset+length).
}

The framework resolves the Range header against the size your Stat reports before calling, so offset and length are always absolute and in bounds — you never have to parse a header or clamp a window. Adding the method is all that is needed; there is nothing to register.

Automatic cleanup

By default, a file field’s stored bytes are removed when:

  • the record is hard-deleted, or
  • the field is overwritten by an update.

Setting auto_delete:false opts out, leaving the file in storage for out-of-band lifecycle management. Soft-deleted rows never trigger cleanup — the file is preserved until the row is hard-deleted.

Bring-your-own storage

Implement maniflex.FileStorage:

type FileStorage interface {
    Store(ctx context.Context, key string, r io.Reader, meta FileMeta) error
    Retrieve(ctx context.Context, key string) (io.ReadCloser, FileMeta, error)
    Delete(ctx context.Context, key string) error
    Exists(ctx context.Context, key string) (bool, error)
    Stat(ctx context.Context, key string) (FileMeta, error)
    PresignUpload(ctx context.Context, key string, opts PresignUploadOptions) (*PresignedUpload, error)
    URL(ctx context.Context, key string, opts PresignURLOptions) (string, error)
}

Stat and PresignUpload are new in v0.2.3 and break every third-party backend until it implements them.

Stat returns an object’s metadata without fetching its body. Return maniflex.ErrFileNotFound for a missing key. Its Size and ContentType are what a file field’s max_size and accept are checked against when a record references a key, so they must describe what is really stored — a client that uploaded 5 GB to a 60 MB field is caught here or nowhere. If your backend has no cheap metadata call, Retrieve and measure; it is correct, merely slower.

PresignUpload mints a direct-to-storage authorisation. If your backend cannot, return maniflex.ErrPresignUnsupported — the framework turns that into a clean 501. Do not return an unsigned URL instead: that is not a degraded presigned upload, it is an unauthenticated write endpoint. Pin opts.MaxSize into the signature if you can (S3’s POST policy does, via content-length-range; a presigned PUT cannot), since the framework’s own check can only run once the bytes are already stored and paid for.

URL’s signature changed in v0.4.2 and breaks every third-party backend until updated. The ttl time.Duration parameter became a PresignURLOptions struct so callers can pin the response headers a URL serves with, not only how long it lives.

The mechanical migration is ttlmaniflex.PresignURLOptions{TTL: ttl}; opts.TTL keeps every meaning ttl had, zero still being the permanent / public mode. A backend that cannot honour the response overrides needs nothing further — ignoring them is supported, and LocalStorage does exactly that.

Retrieve returns maniflex.ErrFileNotFound when the key does not exist. Delete should also return maniflex.ErrFileNotFound for missing keys so the standalone DELETE /files/* handler can surface a 404 without an extra Exists round-trip; backends that cannot detect the case atomically (e.g. S3 DeleteObject succeeds for missing keys) may return nil instead — both are treated as “delete succeeded”. Store is given a framework-generated key of the form uploads/<uuid>/<sanitised-filename>; create any intermediate directories or object prefixes as needed.

Signed URL options

URL takes a PresignURLOptions. Beyond TTL, every field asks the backend to pin a response header into the URL, so one signed link can serve an object differently from how it is stored:

url, err := store.URL(ctx, key, maniflex.PresignURLOptions{
    TTL:      15 * time.Minute,
    Download: true,
    Filename: "invoice-2026.pdf",
})

Download and Filename are the common case, and are encoded for you — non-ASCII names come out in the RFC 5987 filename* form rather than as a raw UTF-8 filename= that standards-compliant browsers discard. Backends must call opts.ContentDisposition() rather than reading the fields directly, so every backend encodes a name the same way. Set ResponseContentDisposition yourself only for a form those two cannot express; it always wins over them.

The remaining fields — ResponseCacheControl, ResponseContentEncoding, ResponseContentLanguage, ResponseContentType, ResponseExpires — map to S3’s response-* query parameters, and VersionID selects one version of an object in a versioned bucket.

They are best-effort: a backend honours what it can and ignores the rest. LocalStorage ignores all of them and returns /files/<key> as before. That is deliberate rather than unfinished — it serves from your application’s own origin, where the parameters would be unsigned, so honouring a caller-chosen ResponseContentType over stored bytes would be a stored-XSS vector. S3’s are safe because they sit inside the signature.

Options set on a signed URL are not reachable from mfx:"file_acl:signed", which still mints a URL with the configured TTL and nothing else. Call FileStorage.URL yourself where you need the overrides.

Optional: RangeRetriever

Beyond FileStorage, a backend may implement maniflex.RangeRetriever to serve byte windows for resumable downloads:

type RangeRetriever interface {
    RetrieveRange(ctx context.Context, key string, offset, length int64) (io.ReadCloser, FileMeta, error)
}

It is optional and additive — a backend that omits it keeps working, serving the whole object with 200 for a Range request. Implement it if your backend can fetch a window natively (S3’s GetObject Range, a file Seek), since that is where the saving is; the bounds are pre-resolved against your Stat size, so they are always absolute and in range.

Storage backends are also expected to:

  • honour ctx cancellation in Store — long uploads must abort when the request deadline elapses or the server is shutting down,

  • refuse keys that name the backend’s own bookkeeping, if it keeps that bookkeeping in the same key namespace as the objects — sibling .meta.json files, as LocalStorage does. Two rules make the difference between a guard and the appearance of one:

    Put the check in whatever function turns a key into a path, not in each method. LocalStorage had it in Store and Retrieve and was missing it in Stat, Exists and Delete, so a client could delete the sidecar of any file it could name; the file kept serving, permanently stripped of its content type and download filename.

    Compare the way the storage layer compares. Windows and macOS match filenames case-insensitively, and Windows ignores trailing dots and spaces, so x.META.JSON, x.meta.json. and x.meta.json all open x.meta.json there. An exact suffix match reads as airtight and holds only on Linux.

Filenames flowing through the framework-generated key are sanitised to the charset [A-Za-z0-9._-] (other runes become _), leading dots are stripped, and the result is truncated to 120 characters. CR / LF / NUL bytes never survive into the storage key.

File fields vs. static files

File fields handle user-supplied content. They are unrelated to Static Files, which serves a fixed directory of assets you ship with the app.

File fieldsStatic files
Sourceuploaded at runtimecommitted to the repo
StorageFileStorage backendlocal disk
URL/files/<key>/static/<path>
Configured byConfig.FilesConfig.Storagea static/ directory