jfastframework
Docs Español PyPI GitHub

Storage

Files live on a named disk. Your code writes to "public" or "invoices"; whether that disk is a directory, an S3 bucket or a MinIO container is configuration. The same handler works in development and in production without an edit.

This is Laravel's disk idea, and it is worth copying: the alternative — a handler that knows it is writing to /var/app/uploads — cannot be deployed anywhere else without a rewrite.

jfast new service billing --plugins storage

The two default disks

A new service gets two, and the difference between them is the whole point:

Disk Visibility URL Used for
public public permanent avatars, logos, anything already public
private private expires invoices, contracts, exports, uploads
[plugin.storage]
default = "public"
serve_local = true                    # development default; JFAST_STORAGE_SERVE_LOCAL wins

[plugin.storage.disks.public]
driver = "local"
root = "storage/public"
visibility = "public"

[plugin.storage.disks.private]
driver = "local"
root = "storage/private"
visibility = "private"

A private disk refuses to produce a permanent URL. That is not a convenience check — a permanent URL to a private disk is exactly how invoice PDFs end up in a search index.

storage = request.app.state.jfast.require("storage")

await storage.disk("public").put("logos/acme.png", data, content_type="image/png")
storage.disk("public").url("logos/acme.png")          # /storage/public/logos/acme.png

await storage.disk("private").put("invoices/1042.pdf", pdf)
storage.disk("private").url("invoices/1042.pdf")      # StorageError
await storage.disk("private").temporary_url("invoices/1042.pdf", expires_in=300)

That is from a route. A @task or @subscribe handler has no request: it annotates a parameter TaskContext and asks it instead -- ctx.require("storage"), the same object (Queues and events).

Absolute URLs

url() is root-relative by default — /storage/public/logos/acme.png — which is right when the page and the file come from the same origin. When they do not, that URL resolves against the client's host and 404s, and a broken <img> shows nothing: no console error, no failed request where you would look for one.

public_base_url fixes it, on either driver:

[plugin.storage.disks.public]
driver = "local"
root = "storage/public"
visibility = "public"
public_base_url = "https://api.example.com/storage/public"

[plugin.storage.disks.assets]
driver = "s3"
bucket = "acme-assets"
visibility = "public"
public_base_url = "https://cdn.example.com"    # CloudFront in front of the bucket

It applies to url() only. temporary_url() stays on this app's own path deliberately: public_base_url usually names a cache, and a cache in front of a signed URL hands the object to the next caller after the signature expired.

A disk key that the driver does not understand is now a startup error, not a silent no-op:

storage disk 'public' uses driver 'local', which has no setting 'bucket'.
It belongs to the s3 driver.

That check exists because public_base_url on a local disk was accepted and dropped for a release, and nothing anywhere said so.

Temporary URLs

For a local disk the link carries an expiry and an HMAC signature over both the key and that expiry:

/storage/private/invoices/1042.pdf?expires=1793491200&signature=Yk3f...

Signing the key alone would make one valid link a key to the entire disk; the holder could edit the path. Signing the expiry alone would let them edit the path instead. The signature covers both, and comparison is constant-time.

Set the key or temporary URLs do not work:

JFAST_STORAGE_SIGNING_KEY=$(openssl rand -hex 32)

In production the plugin warns at startup when a private local disk has no key, rather than letting the first user to click a download link discover it; a key shorter than 32 bytes refuses to boot there. Outside production it stays quiet -- most services never sign a URL, and a warning on every start of every one of them is a warning nobody reads -- and temporary_url() itself fails with the variable to set.

An expired link and a forged one return the same 403 with the same message. Different answers would tell an attacker whether the key exists.

For an S3 disk, temporary_url() is a presigned URL and the signing key is irrelevant — S3 does the signing with its own credentials.

S3 and MinIO

[plugin.storage.disks.uploads]
driver = "s3"
bucket = "acme-uploads"
region = "eu-west-1"
visibility = "private"

No credentials in the config: boto3's default chain finds the instance role, the task role, or ~/.aws/credentials. A task role is better than any key you could put here, because there is no long-lived secret to leak.

MinIO is S3 with two extra settings:

[plugin.storage.disks.uploads]
driver = "s3"
bucket = "uploads"
endpoint_url = "http://localhost:8006"
access_key = "jfast"
secret_key = "${MINIO_PASSWORD}"
force_path_style = true              # MinIO needs it, real S3 does not

To get the container in your compose file:

[plugin.storage]
minio_include_infra = true

jfast deploy compose then emits MinIO at your service's base port +6, like every other plugin's container.

Every S3 call has a deadline, a retry policy and a circuit breaker, per disk:

[plugin.storage.disks.uploads]
connect_timeout = 5.0      # seconds to connect
read_timeout = 30.0        # seconds of silence while reading
max_attempts = 3           # botocore "standard" retries, first try included
breaker_failures = 5       # failed calls in a row that stop calling S3...
breaker_cool_down = 15.0   # ...for this long; then one probe

While the breaker is open an upload fails at once with a 503 instead of holding a worker thread through three timeouts. A 404 is an answer and never counts. Presigning is local and is not behind the breaker. The reasons for each number are in Resilience.

Keys are not paths

Every backend runs the key through the same validator before it reaches a filesystem or a bucket:

Local disks additionally re-check after resolving, because a symlink inside the disk root can point outside it and only resolution reveals that.

Path traversal is the most common storage vulnerability there is. Validating in the backend rather than in the plugin means a backend used directly — from a worker, from a script — is as safe as one used through a route.

Validating uploads

put() on a bare disk accepts any number of bytes of anything. Size and type rules are not a per-handler concern — every handler that forgets one is the hole — so they belong to the disk, in a pipeline:

[plugin.storage.disks.uploads]
driver = "s3"
bucket = "acme-uploads"
pipeline = ["validate"]

[plugin.storage.disks.uploads.validate]
max_bytes = "10MB"
allow = ["image/jpeg", "image/png", "application/pdf"]

Both settings are optional. Without max_bytes there is no size limit; without allow, any type validate can recognise is accepted.

The pipeline runs inside the backend, not in the plugin, for the same reason key validation does: a disk used directly from a worker or a script obeys the same rules as one used through a route.

The type comes from the bytes

validate reads the content type out of the file's first bytes. Not from the extension, not from the Content-Type the browser sent — both are chosen by whoever is uploading, and a .png on a shell script is the oldest upload attack there is. The type it reads then replaces whatever the caller declared, because that value is what gets stored and served back on download.

guess_content_type() in jfastframework.storage is the extension-based one. It is only ever correct for objects this service wrote itself; do not reach for it on the way in.

Recognition is a small table of magic numbers, no dependency. It knows:

images image/png image/jpeg image/gif image/webp image/tiff image/avif image/heic
documents application/pdf application/zip application/gzip
media video/mp4 audio/mpeg audio/ogg
fonts font/woff font/woff2

Anything else is rejected, including something that is perfectly fine. Waving an unrecognised file through would make the check decorative.

Two consequences worth knowing before you configure a disk:

Listing a type validate cannot recognise is a startup error, not a rule that silently never matches.

XML, JSON, CSV and text

A disk for CFDI invoices holds XML, and validate can hold it to that:

[plugin.storage.disks.cfdi]
driver = "local"
root = "storage/cfdi"
pipeline = ["validate"]

[plugin.storage.disks.cfdi.validate]
max_bytes = "5MB"
allow = ["application/xml"]
In allow The file must
application/xml Be well-formed XML with no DOCTYPE and no ENTITY declaration -- that is where XXE and entity-expansion bombs live, and a data file needs neither -- and not be SVG or HTML, which a browser would render and run
application/json Parse to an object or an array
text/csv Be UTF-8 text that the csv module reads into at least one row
text/plain Be UTF-8 with no control characters beyond tab and newlines

They are tried only when the file matched no magic number, and in that order, so an XML file on a disk that allows both XML and text is XML. A disk that does not list them rejects them exactly as before.

Large files: put_stream

put() takes bytes, so the whole file is in memory before anything is checked. For a multi-gigabyte archive, stream it instead:

disk = storage.disk("archives")
stored = await disk.put_stream("2026/04/cfdi.zip", request.stream())

Any async iterable of bytes works: a request body, a file read in chunks, a download from somewhere else. On the way:

Optimising images

optimise-image re-encodes the image formats it recognises on the way in. It is optional, off unless a disk names it, and it carries Pillow:

pip install "jfastframework[images]"
[plugin.storage.disks.uploads]
driver = "s3"
bucket = "acme-uploads"
pipeline = ["validate", "optimise-image"]

[plugin.storage.disks.uploads.optimise-image]
format = "webp"          # webp | jpeg | png | gif | keep
quality = 82             # 1-100; means nothing to png and gif
max_dimension = 2400     # longest edge in pixels; 0 leaves the size alone
strip_metadata = true    # the default
max_pixels = 50000000    # refuse anything that decodes larger than this

It only touches four types: image/jpeg, image/png, image/gif and image/webp. Everything else is handed on byte for byte, including things it would clearly be able to open. That is the point: a signed PDF through an image codec is an invalid PDF, and re-encoding a raw photograph destroys the negative. image/tiff is excluded for exactly that reason — Canon CR2 and Nikon NEF raws are TIFF containers, with the same magic bytes and the same sniffed type, and nothing at this layer can tell one from the other. avif and heic are excluded too, because Pillow decodes neither without a plugin that may not be in your image.

It reads the type out of the bytes itself rather than trusting upload.content_type, so it behaves the same whether or not validate runs in front of it.

What it does about the awkward cases, each of which is a silent data loss if you get it wrong:

case what happens
animated GIF or WebP left untouched — a still frame is not the animation
alpha channel, format = "jpeg" encoded as WebP instead — JPEG has no alpha, and compositing onto a background colour nobody chose is a silent edit
EXIF, XMP, PNG text dropped, strip_metadata = true being the default: EXIF carries GPS coordinates
EXIF orientation applied to the pixels before the tag is dropped, always — stripping the flag without rotating turns a portrait sideways
ICC profile kept even when stripping. A colour profile is colour, not privacy
output larger than the input the original is kept, unless max_dimension resized it
more pixels than max_pixels UploadRejected. A 40 GB expansion out of a small file is the decompression-bomb CVE, and a hostile file is refused rather than stored
Pillow cannot decode it passed through. This step optimises; deciding what is admissible is validate's job

The key changes with the bytes. A local disk reads an object's content type back out of its key, so holiday.jpg holding WebP would be served as image/jpeg. put() returns the key it actually wrote — keep that one, not the one you passed in:

stored = await disk.put("holiday.jpg", data)
stored.key  # 'holiday.webp'

What it saves, measured in tests/test_storage_images.py at format = "webp", quality = 82, Pillow 12.3:

input before after saving
JPEG q90 photograph, 1600×1200 588,527 B 311,426 B 47%
PNG photograph, 1600×1200 3,175,929 B 339,070 B 89%
PNG photograph with alpha, 800×600 480,285 B 39,654 B 92%
PNG flat-colour screenshot, 1600×1200 9,384 B 9,054 B 4%

That last row is the honest one. The saving is a property of the input, not of the step: a PNG holding a photograph is being stored losslessly and has everything to gain, a JPEG already lost most of it, and flat colour was already the case PNG is good at. Nothing here is free — every one of those rows cost a full decode and re-encode of the image, which is why it happens in a thread.

It runs in asyncio.to_thread. Image codecs are CPU, not I/O, which is exactly why they read as harmless: nothing about Image.open(...) looks like a network call, and a 24-megapixel JPEG is still hundreds of milliseconds of decode and seconds of re-encode with every other request on the worker waiting. jfast contracts check knows about Pillow — Image.open, Image.new, ImageOps, ImageFilter and the methods of anything they return — so a synchronous re-encode written into an async def is a finding rather than a mystery in production latency.

Writing a step

validate and optimise-image are the two that ship, not the only shape of one. Virus scanning plugs in the same way:

from dataclasses import replace
from jfastframework.storage import Upload, register_step

class Thumbnail:
    name = "thumbnail"

    def __init__(self, disk: str, config: dict) -> None:
        self.width = int(config.get("width", 512))

    async def process(self, upload: Upload) -> Upload:
        smaller = await asyncio.to_thread(shrink, upload.data, self.width)
        return replace(upload, data=smaller)

register_step("thumbnail", Thumbnail)

A step takes an Upload and returns one. Returning rather than mutating means a step that rewrites the bytes and one that only inspects them read the same way, and a step that raises half way through cannot leave a half-rewritten upload for the next step.

Steps are async def because the pipeline runs inside a request, and anything CPU-bound in one has to go to asyncio.to_thread — the same discipline LocalStorage.put follows, and the one jfast contracts check enforces. Rejecting an upload means raising UploadRejected, which names the limit and the actual value:

disk 'uploads': 'holiday.jpg' is 24.3MB (25480051 bytes),
over this disk's max_bytes of 10MB (10485760 bytes)

Serving files

serve_local = true mounts /storage/{disk}/{key} so downloads work with nothing else running. It is for development.

In production, put Caddy or a CDN in front of the public disk and set serve_local = false. A Python worker holding a connection open to stream a 40 MB PDF is a worker not serving requests. The plugin logs a warning if it finds itself serving files in production.

JFAST_STORAGE_SERVE_LOCAL=false does that from the deployment even when jfast.toml says serve_local = true: the environment wins over the file for this key and for signing_key (deploy). A disk's own settings -- bucket, endpoint_url, region -- are not in that table: they live in the disk's table, and the file decides them.

Downloads are sent as Content-Disposition: attachment with X-Content-Type-Options: nosniff. An uploaded .html or .svg rendered inline runs the uploader's script on your origin, against your users' cookies — so uploads are saved, not rendered. Pass inline=True to sanitised_download_headers() only for files your own code produced.

Moving a file between disks

/storage/{disk}/{key} names the disk, so moving an object from local to s3 404s every URL ever handed out. That is usually the thing blocking a migration to S3, and it is why /storage/{key} exists — it names the object and lets the app work out where it lives:

[plugin.storage]
resolve_by_key = true
resolve_strategy = "recorded"     # or "probe"

Off by default. /storage/{disk}/{key} keeps working either way; a first path segment that matches a disk name is always read as a disk name, which is the price of not breaking the older shape.

The two strategies, and what each costs

recorded — the default. Whoever wrote the object also wrote down which disk took it, and resolution is one lookup:

storage = request.app.state.jfast.require("storage")
await storage.disk("uploads").put(key, data)
await storage.record(key, "uploads")

Exact and cheap. It only knows about objects that were recorded, so it does nothing for the files already sitting on the old disk the day you turn it on, and every write now has a second thing that can be forgotten.

The default ledger is in memory: fine for tests and a single-process dev server, wrong everywhere else, because two replicas do not share it and a restart loses every mapping. Point it at the row you already have:

class Files:
    async def record(self, key: str, disk: str) -> None: ...
    async def locate(self, key: str) -> str | None: ...
    async def forget(self, key: str) -> None: ...

storage.use_ledger(Files())

probe — for a migration window. Try disks in order and take the first that has the object:

[plugin.storage]
resolve_by_key = true
resolve_strategy = "probe"
read_order = ["s3", "local"]      # newest first
copy_on_read = true

No bookkeeping and it works on day one, including for files written years ago. It costs a round trip per disk that does not hold the object — a miss is a HEAD against every disk in the list before the 404, and against S3 that is real latency on your 404 path. It is a window, not a steady state.

copy_on_read closes the window as traffic flows: when probing finds an object on an older disk it is copied to the first disk in read_order and recorded, so the next request is a hit. It turns a GET into a read plus a write, so the first request for every object pays for a full copy — fine for a few thousand avatars, wrong for a bucket of video. It is opt-in for that reason.

Moving one, explicitly

await storage.copy(key, "local", "s3")     # both disks hold it
await storage.move(key, "local", "s3")     # copy, delete, re-point the ledger

move copies before it deletes: a crash in between leaves two copies, which is recoverable. Neither runs the target disk's pipeline — the object is already stored, and re-validating it would let a rule tightened today reject a file that was legal when it was written, in the middle of a migration.

storage.stable_url(key)                              # /storage/invoices/1042.pdf
await storage.stable_temporary_url(key, expires_in=300)

These are signed with the service's key rather than the disk's, so the link still verifies after the object has moved to a disk that does no signing of its own. The visibility rule is unchanged: whichever disk the key resolves to decides whether a signature is required.

Local disks in the production image

The generated image runs as appuser (uid 10001), and the generated compose file mounts a named volume on every local disk's root (<service>_<disk>_data). Docker creates a volume's mount point as root when the image does not already have that directory, so each local root has to exist in the image, owned by appuser. jfast deploy dockerfile writes one mkdir/chown line for every local disk in [plugin.storage.disks], and jfast add storage rewrites that line alone in a Dockerfile you have edited. After declaring a disk:

jfast add storage          # the Dockerfile creates the new root for appuser
jfast workspace compose    # its volume (`jfast deploy compose` for a lone service)
docker compose up --build

Skip the first and /ready answers 503 (/app/storage/adjuntos is not writable) while /health stays 200, and the first upload is a 500 PermissionError. jfast upgrade --check names every root the Dockerfile does not create (image-cannot-write-local-storage).

A volume created as root

Docker copies the image's directory, owner included, into a named volume that is empty when it is mounted. So a volume created as root before the image had the directory is fixed by the rebuilt image only while it is still empty; once it holds a file it stays root's, /ready stays 503, and rebuilding changes nothing. Hand it over once, as root, with the data in place:

docker compose run --rm --no-deps --user root <service> \
  chown -R appuser:appuser /app/storage/adjuntos

<service> is the API's compose service -- api from jfast deploy compose, the service's own name in a workspace. The worker mounts the same volume, so once is enough. An empty volume can be removed instead (docker volume rm <project>_<service>_adjuntos_data); the next up creates it from the image.

Health

/ready reports each disk: a local disk that is missing or read-only, an S3 bucket that cannot be reached. The two are not the same failure:

Each disk answers inside the readiness budget. The S3 probe makes one short try, and while the disk's breaker is open it is not probed at all.

What this does not do

See also