Digging Deeper
Storage
This page shows you how to read and write files through the wheels.storage abstraction instead of reaching for FileWrite / cfhttp directly. You configure one or more named disks — a local directory, an S3 bucket, or both — and resolve them through StorageManager for a single put / get / exists / delete / url / signedUrl surface that swaps backends with a config change.
You’ll learn:
- How to declare disks in
config/settings.cfmand resolve them by name - How
LocalDiskstores objects on the filesystem and serves them through a URL prefix - How
S3Disktalks to S3 over plaincfhttpwith from-scratch SigV4 signing — no AWS SDK, no JARs - How signed (presigned) URLs work on both drivers
- What the failure contract is (
Wheels.Storage.*errors)
The model
Section titled “The model”Storage mirrors the disk abstraction you already know from Laravel’s Filesystem or Rails’ ActiveStorage. Configuration names each disk and assigns it a driver — "local" or "s3". Application code asks StorageManager for a disk by name and receives a wheels.interfaces.StorageDiskInterface back, regardless of which driver is behind it.
<cfscript>set(storage = { default = "local", disks = { local = { driver = "local", root = ExpandPath("../storage/uploads"), urlPrefix = "/uploads" }, s3 = { driver = "s3", bucket = "my-bucket", region = "us-east-1", accessKeyId = env("S3_ACCESS_KEY_ID"), secretAccessKey = env("S3_SECRET_ACCESS_KEY"), visibility = "private" } }});</cfscript>Register the manager as a singleton in config/services.cfm. Because its init() takes the storage config struct — which isn’t a registered service — wrap construction in toFactory(), and end with .asSingleton() (a factory mapping is transient otherwise, so every service("storage") call would build a new manager):
<cfscript>local.di = injector();local.di.map("storage").toFactory(function() { return new wheels.storage.StorageManager(config = get("storage"));}).asSingleton();</cfscript>StorageManager.disk() returns the default disk when you pass no name, and throws Wheels.Storage.UnknownDisk for a name you didn’t configure:
local.uploads = service("storage").disk(); // "local" (the default)local.assets = service("storage").disk("s3"); // "s3"If you aren’t using the container, instantiate the manager directly — new wheels.storage.StorageManager(config = get("storage")) — and keep it in a scope you control.
Storing and reading objects
Section titled “Storing and reading objects”Every disk exposes the same six methods. Keys are forward-slash-delimited path strings; put() creates intermediate directories for you.
local.disk = service("storage").disk();
local.disk.put("avatars/42.png", local.binary, contentType = "image/png", visibility = "public");// -> "avatars/42.png"
local.content = local.disk.get("avatars/42.png"); // binarylocal.has = local.disk.exists("avatars/42.png"); // truelocal.disk.delete("avatars/42.png"); // true| Method | Returns | Notes |
|---|---|---|
put(key, content, contentType, visibility) | the key | Binary or string content; visibility is "public" or "private". |
get(key) | binary | Throws Wheels.Storage.NotFound when absent. |
exists(key) | boolean | LocalDisk checks the filesystem; S3Disk issues a HEAD. |
delete(key) | boolean | false when the object was already gone. |
url(key) | string | Public URL (local prefix, or the S3 object URL). |
signedUrl(key, expiresIn, contentDisposition) | string | Expiring URL; see below. |
get() and put() round-trip bytes exactly — LocalDisk.put() writes binary, never a string, so a PNG comes back identical to what went in.
LocalDisk
Section titled “LocalDisk”The local driver stores objects under a configured root and exposes them through urlPrefix, which is whatever path your app already serves statically. Its config:
| Key | Required | Default | Meaning |
|---|---|---|---|
root | yes | — | Directory objects are written under. |
urlPrefix | no | "" | Public URL path the app serves the files from. |
signingKey | no | "" | HMAC secret for signedUrl(); required only if you call it. |
resolveSymlinks | no | false | Opt into a stricter containment check that resolves symlinks too (see Symlinks under root). |
<cfscript>set(storage = { default = "local", disks = { local = { driver = "local", root = ExpandPath("../storage/uploads"), urlPrefix = "/uploads", signingKey = env("STORAGE_SIGNING_KEY") } }});</cfscript>url("avatars/42.png") produces /uploads/avatars/42.png. Because the filesystem has no native presigning, signedUrl() appends an HMAC expires / signature query string over the key and expiry — your download route is expected to verify it with verifySignature() before streaming. This is the same tradeoff Rails’ DiskController and Laravel’s serve make.
Key validation and path safety
Section titled “Key validation and path safety”LocalDisk treats every key as relative to root and rejects anything that could escape it with Wheels.Storage.InvalidKey: a path-traversal segment (../, ..\), a segment made only of dots and/or whitespace (., .., ..., . ., and — because Windows strips trailing dots and spaces — .. ), and a drive-letter prefix (C:/…, C:foo). Because every key is relative to root, slash runs carry no meaning: a leading /, a trailing /, doubled // and a UNC/network prefix (//server/share, \\server\share) are normalised to a relative key under root, and keys are never URL-decoded, so %2e%2e%2f stays a literal segment. A name that merely contains two dots inside a segment, such as reports/q3..final.pdf or a..b.txt, is a normal name and is allowed. The containment check is lexical (it resolves ./, ../ and // as text), so it behaves identically on every engine, including the JVM-free RustCFML.
Opt in to symlink-resolving containment (resolveSymlinks)
Section titled “Opt in to symlink-resolving containment (resolveSymlinks)”For a hardened deployment — for example, root on a shared volume where a symlink escape is part of your threat model — set resolveSymlinks = true on the disk. On top of the lexical check, $resolve() then canonicalises each path through the filesystem and compares the resolved paths exactly, so a symlink under root that targets outside it is rejected with Wheels.Storage.InvalidKey instead of followed. The lexical check from the default mode stays on underneath; this only adds a stronger layer.
<cfscript>set(storage = { default = "local", disks = { local = { driver = "local", root = ExpandPath("../storage/uploads"), urlPrefix = "/uploads", resolveSymlinks = true } }});</cfscript>S3Disk
Section titled “S3Disk”The S3 driver speaks to S3 (and S3-compatible endpoints) over plain cfhttp, signing each request with a from-scratch SigV4 implementation in wheels.storage.S3Signer — no AWS SDK, no bundled JARs. Its config:
| Key | Required | Default | Meaning |
|---|---|---|---|
bucket | yes | — | Bucket name. |
region | yes | — | AWS region, e.g. us-east-1. |
accessKeyId | yes | — | AWS access key. |
secretAccessKey | yes | — | AWS secret key. |
visibility | no | "private" | Default ACL for put() (public → public-read). |
endpoint | no | "" | S3-compatible endpoint (MinIO, R2, etc.). |
usePathStyle | no | false | Force path-style addressing for non-AWS endpoints. |
timeout | no | 60 | HTTP timeout in seconds. |
service("storage").disk("s3").put( key = "reports/q3.pdf", content = local.pdfBytes, contentType = "application/pdf", visibility = "private");url() returns the object’s public URL; signedUrl() returns a presigned, expiring GET URL computed locally by S3Signer — no round-trip to AWS required:
local.url = service("storage").disk("s3").signedUrl( key = "reports/q3.pdf", expiresIn = 900, contentDisposition = "attachment; filename=q3.pdf");Signed URLs on both drivers
Section titled “Signed URLs on both drivers”Both signedUrl() signatures match: signedUrl(key, expiresIn = 300, contentDisposition = ""). expiresIn must be within 1..604800 (one second to seven days) or the call throws Wheels.Storage.InvalidExpiresIn. On S3 the URL is a real AWS presigned GET; on local it’s a URL with an HMAC token your app verifies. Object keys are RFC3986-encoded on both drivers, so spaces and reserved characters behave identically.
Failure contract
Section titled “Failure contract”Storage errors are typed so you can rescue them precisely:
| Error | Raised when |
|---|---|
Wheels.Storage.UnknownDisk | disk("nope") names a disk that isn’t configured. |
Wheels.Storage.UnknownDriver | A disk’s driver is neither local nor s3. |
Wheels.Storage.InvalidConfiguration | A disk omits a required config key (e.g. root, bucket). |
Wheels.Storage.NotFound | get() on a missing key. |
Wheels.Storage.InvalidKey | A LocalDisk key that is empty/slash-only, uses path traversal (../), has a dot/space-only segment, or is absolute or drive-letter-prefixed (see Key validation and path safety). |
Wheels.Storage.InvalidExpiresIn | expiresIn outside 1..604800. |
Wheels.Storage.MissingSigningKey | signedUrl() on a local disk with no signingKey. |
Wheels.Storage.RequestFailed | An S3 request fails in a way that isn’t “the object is absent”. |
exists() and delete() deliberately distinguish “object absent” (false) from “the backend didn’t answer” (a thrown error) — a connection failure must not read as “no file here.”