You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Child of #2962, sequenced LAST. Phase 1 only: vendor/wheels/storage/ disk abstraction — local + S3 adapters, signed URLs; S3 via plain cfhttp request signing (no JARs). Phase 2 (Rails-style hasOneAttached model macro + polymorphic blob tables) gets its own issue later ONLY if demand shows; storage adapters alone cover most demand at a fraction of the effort.
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
#3157 (child of #2962, Phase 1) wants a storage-disk abstraction under vendor/wheels/storage/: a pluggable disk interface with local + S3 adapters covering put/get/exists/delete and signed, expiring URLs, where S3 is accessed via plain cfhttp request signing (no SDK, no JARs). The hasOneAttached model macro + polymorphic blob tables are out of scope (Phase 2). Narrow question: what should the disk interface and its config look like?
How other frameworks handle this
Framework
Naming
Surface
Trade-offs
Edge cases
Rails (ActiveStorage)
Service→DiskService/S3Service; opaque key
upload/download/delete/exist?/url; one url() dispatches public-vs-private
~10-method abstract class; zero model knowledge
checksum integrity; Disk needs routed DiskController to serve local; S3Service depends on aws-sdk to sign
purpose-built; I/O uniform but URL/visibility leak the backend
fs has no native signed-URL — needs app route (serveFiles:true); signed-URL contentType/contentDisposition map to S3 response headers
+1 = AdonisJS Drive/FlyDrive: modern, actively-maintained lib that solved exactly this (one DriverContract over fs+S3+GCS with expiresIn signed URLs); surfaces the sharpest lesson — a local backend has no native signed URL, so the framework must serve files via its own route.
Dominant pattern
Strong convergence: the two purpose-built disk abstractions (Laravel, AdonisJS) and Rails all use named disks, each parameterized by a driver string, behind one small uniform interface (put/get/exists/delete/url + a separate signedUrl for expiry), selected by name with a default. Django/Spring agree on the abstraction but are weaker on portable signing. The single universal divergence is signed URLs for the local driver — every framework treats S3 presigning as native and local signing as a separate problem needing an app-served route (Rails DiskController, Laravel serve, AdonisJS serveFiles, Phoenix Plug.Static). Naming consensus for a new abstraction is Laravel/AdonisJS's disk + driver.
Recommended path for Wheels
Adopt named-disk + driver: a thin StorageManager lazily resolving disks from config, plus a storage() helper mixed onto controllers/models (mirroring service()/model()). Ship LocalDisk + S3Disk. S3 presigning is from-scratch SigV4 — de-risked: vendor/wheels/auth/JwtService.cfc:275 already runs the exact primitives (HMac(msg,key,"HMACSHA256") + BinaryDecode/BinaryEncode) green on Lucee/Adobe/BoxLang, which is all SigV4 needs (SHA-256 hex canonical hash + HMAC key-derivation chain). Local signedUrl follows the universal pattern: an HMAC token over key+expires to a built-in serving route.
// config/settings.cfm
set(storage = {
default = "local",
disks = {
local = { driver="local", root="/storage/uploads", urlPrefix="/uploads" },
s3 = { driver="s3", bucket="myapp-prod", region="us-east-1",
accessKeyId=get("AWS_ACCESS_KEY_ID"),
secretAccessKey=get("AWS_SECRET_ACCESS_KEY"), visibility="private" }
}
});
// vendor/wheels/interfaces/StorageDiskInterface.cfc
interface {
public any function put(required string key, required any content, string contentType, string visibility);
public any function get(required string key);
public boolean function exists(required string key);
public boolean function delete(required string key);
public string function url(required string key);
public string function signedUrl(required string key, numeric expiresIn=300, string contentDisposition);
}
// usage — same surface on any backend; all-named args when passing options
storage().put(key="avatars/123.png", content=fileData, contentType="image/png");
if (storage("s3").exists(key="avatars/1.png")) local.u = storage("s3").url(key="avatars/1.png");
local.tmp = storage("s3").signedUrl(key="reports/q3.pdf", expiresIn=300,
contentDisposition="attachment; filename=q3.pdf");
Lives in: vendor/wheels/storage/StorageManager.cfc, .../drivers/LocalDisk.cfc, .../drivers/S3Disk.cfc, .../S3Signer.cfc; interface in vendor/wheels/interfaces/StorageDiskInterface.cfc; storage() helper on the controller/model mixin surface; one built-in route + Public-handler for signed local files.
Composes with: the DI container (register StorageManager as a singleton in config/services.cfm; resolve via service("storage") / storage()), the set() config convention, and get(env) for creds. Drivers are standalone services (not $integrateComponents-mixed), so plain private helpers are fine.
Cross-engine notes
Riskiest piece is proven prior art: SigV4 = Hash(canonical,"SHA-256") hex + chained HMac(...,"HMACSHA256") with BinaryDecode/BinaryEncode — identical to JwtService.cfc, green on all engines. Guards: (1) local dirs via CreateObject("java","java.io.File").init(path).mkdirs(), not Lucee-only DirectoryCreate(path,true); (2) FileReadBinary()/multipart bytes are byte[] on Lucee7/Adobe but int-array on BoxLang/Lucee6 — pass to cfhttp/JDBC without an IsArray guard; (3) the S3 cfhttp wrapper must build a plain struct of headers (never attributeCollection=arguments — Adobe 2023/2025 reject it). No closures-as-constructor-args, no obj.map(), no reserved-scope params.
Open questions
Local serving route is the one genuinely new infra piece (built-in route + handler verifying an HMAC token before streaming). Always-on, opt-in per-disk (serve=true), or only when a local disk declares urlPrefix?
Credentials: get()/env only for Phase 1, or also instance-profile/~/.aws later?
visibility on local: map to file-mode bits or ignore and rely on the serving route (suggest the latter for Phase 1)?
Ship the 6-method core or also copy/move/size/putStream/getStream now?
Confidence: medium
API shape has strong cross-framework consensus and respects every Wheels convention + cross-engine invariant (SigV4 de-risked by JwtService prior art), but it introduces a built-in local-file-serving route + handler and a from-scratch SigV4 signer — genuinely new infrastructure (the auto-downgrade-to-low trigger). Held at medium not low because the issue author already pre-decided that infra direction in the body (vendor/wheels/storage/, local+S3, cfhttp signing, signed URLs), so the "should we build this" discussion the low-rule guards is settled; what remains is API ergonomics, where the frameworks converge.
Activity
wheels-bot commentedon Jun 12, 2026
Wheels Bot — Triage
Classified as framework-design (API/feature design question).
Cross-framework research will follow shortly — Rails, Laravel, Django,
and other modern frameworks will be consulted before any path forward
is proposed.
<!-- wheels-bot:triage:3157 -->
<!-- wheels-bot:triage-class:framework-design -->
wheels-bot commentedon Jun 12, 2026
Wheels Bot — Cross-Framework Research
Question
#3157 (child of #2962, Phase 1) wants a storage-disk abstraction under
vendor/wheels/storage/: a pluggable disk interface with local + S3 adapters covering put/get/exists/delete and signed, expiring URLs, where S3 is accessed via plaincfhttprequest signing (no SDK, no JARs). ThehasOneAttachedmodel macro + polymorphic blob tables are out of scope (Phase 2). Narrow question: what should the disk interface and its config look like?How other frameworks handle this
Service→DiskService/S3Service; opaquekeyupload/download/delete/exist?/url; oneurl()dispatches public-vs-privatechecksumintegrity; Disk needs routedDiskControllerto serve local; S3Service depends on aws-sdk to signlocal/s3); Flysystemput/get/exists/delete/copy/move/url/temporaryUrl/visibilitytemporaryUrlthrows on local unlessserve=true;visibility: public/privateStorage→FileSystemStorage/S3Storage;STORAGESnamed backendssave/open/delete/exists/size/url;_save/_opentemplate-methodNotImplementedErrorurl()public-vs-signed toggled byquerystring_auth;querystring_expireTTLResource/ResourceLoader(file:/s3:);S3TemplateResourcehas no delete/sign;S3Template:upload/download/deleteObject/objectExists/createSignedGetURLDuration;exists()= real HEAD on S3Waffle.Storage.Local/.S3behaviourput/url/delete;ExAws.S3.presigned_url(:get,…,expires_in:)Plug.Static; presign offline; R2 needs PUT not POSTDiskwrapsDriver/DriverContract;services.fs/services.s3put/get/exists/delete/copy/move/getUrl/getSignedUrl/streams/visibilityserveFiles:true); signed-URLcontentType/contentDispositionmap to S3 response headers+1 = AdonisJS Drive/FlyDrive: modern, actively-maintained lib that solved exactly this (one
DriverContractover fs+S3+GCS withexpiresInsigned URLs); surfaces the sharpest lesson — a local backend has no native signed URL, so the framework must serve files via its own route.Dominant pattern
Strong convergence: the two purpose-built disk abstractions (Laravel, AdonisJS) and Rails all use named disks, each parameterized by a
driverstring, behind one small uniform interface (put/get/exists/delete/url+ a separatesignedUrlfor expiry), selected by name with a default. Django/Spring agree on the abstraction but are weaker on portable signing. The single universal divergence is signed URLs for the local driver — every framework treats S3 presigning as native and local signing as a separate problem needing an app-served route (RailsDiskController, Laravelserve, AdonisJSserveFiles, PhoenixPlug.Static). Naming consensus for a new abstraction is Laravel/AdonisJS's disk + driver.Recommended path for Wheels
Adopt named-disk + driver: a thin
StorageManagerlazily resolving disks from config, plus astorage()helper mixed onto controllers/models (mirroringservice()/model()). ShipLocalDisk+S3Disk. S3 presigning is from-scratch SigV4 — de-risked:vendor/wheels/auth/JwtService.cfc:275already runs the exact primitives (HMac(msg,key,"HMACSHA256")+BinaryDecode/BinaryEncode) green on Lucee/Adobe/BoxLang, which is all SigV4 needs (SHA-256 hex canonical hash + HMAC key-derivation chain). LocalsignedUrlfollows the universal pattern: an HMAC token overkey+expiresto a built-in serving route.// config/settings.cfm set(storage = { default = "local", disks = { local = { driver="local", root="/storage/uploads", urlPrefix="/uploads" }, s3 = { driver="s3", bucket="myapp-prod", region="us-east-1", accessKeyId=get("AWS_ACCESS_KEY_ID"), secretAccessKey=get("AWS_SECRET_ACCESS_KEY"), visibility="private" } } }); // vendor/wheels/interfaces/StorageDiskInterface.cfc interface { public any function put(required string key, required any content, string contentType, string visibility); public any function get(required string key); public boolean function exists(required string key); public boolean function delete(required string key); public string function url(required string key); public string function signedUrl(required string key, numeric expiresIn=300, string contentDisposition); } // usage — same surface on any backend; all-named args when passing options storage().put(key="avatars/123.png", content=fileData, contentType="image/png"); if (storage("s3").exists(key="avatars/1.png")) local.u = storage("s3").url(key="avatars/1.png"); local.tmp = storage("s3").signedUrl(key="reports/q3.pdf", expiresIn=300, contentDisposition="attachment; filename=q3.pdf");Lives in:
vendor/wheels/storage/StorageManager.cfc,.../drivers/LocalDisk.cfc,.../drivers/S3Disk.cfc,.../S3Signer.cfc; interface invendor/wheels/interfaces/StorageDiskInterface.cfc;storage()helper on the controller/model mixin surface; one built-in route + Public-handler for signed local files.Composes with: the DI container (register
StorageManageras a singleton inconfig/services.cfm; resolve viaservice("storage")/storage()), theset()config convention, andget(env)for creds. Drivers are standalone services (not$integrateComponents-mixed), so plainprivatehelpers are fine.Cross-engine notes
Riskiest piece is proven prior art: SigV4 =
Hash(canonical,"SHA-256")hex + chainedHMac(...,"HMACSHA256")withBinaryDecode/BinaryEncode— identical toJwtService.cfc, green on all engines. Guards: (1) local dirs viaCreateObject("java","java.io.File").init(path).mkdirs(), not Lucee-onlyDirectoryCreate(path,true); (2)FileReadBinary()/multipart bytes arebyte[]on Lucee7/Adobe but int-array on BoxLang/Lucee6 — pass tocfhttp/JDBC without anIsArrayguard; (3) the S3cfhttpwrapper must build a plain struct of headers (neverattributeCollection=arguments— Adobe 2023/2025 reject it). No closures-as-constructor-args, noobj.map(), no reserved-scope params.Open questions
serve=true), or only when a local disk declaresurlPrefix?get()/env only for Phase 1, or also instance-profile/~/.awslater?visibilityon local: map to file-mode bits or ignore and rely on the serving route (suggest the latter for Phase 1)?copy/move/size/putStream/getStreamnow?Confidence:
mediumAPI shape has strong cross-framework consensus and respects every Wheels convention + cross-engine invariant (SigV4 de-risked by
JwtServiceprior art), but it introduces a built-in local-file-serving route + handler and a from-scratch SigV4 signer — genuinely new infrastructure (the auto-downgrade-to-low trigger). Held atmediumnotlowbecause the issue author already pre-decided that infra direction in the body (vendor/wheels/storage/, local+S3,cfhttpsigning, signed URLs), so the "should we build this" discussion the low-rule guards is settled; what remains is API ergonomics, where the frameworks converge.Sources
feat(storage): add storage-disk abstraction with local and S3 drivers (…