Skip to content

feature: storage-disk abstraction (local + S3 adapters, signed URLs) — attachments phase 1 #3157

Closed

Description

@bpamiri

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.

Activity

  1. wheels-bot commented on Jun 12, 2026

    @wheels-bot
    Contributor

    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 -->

  2. wheels-bot commented on Jun 12, 2026

    @wheels-bot
    Contributor

    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 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
    Laravel (Filesystem) named disk + driver (local/s3); Flysystem put/get/exists/delete/copy/move/url/temporaryUrl/visibility local↔s3 = config-only; cloud caps runtime-probed temporaryUrl throws on local unless serve=true; visibility: public/private
    Django (Storage) Storage→FileSystemStorage/S3Storage; STORAGES named backends save/open/delete/exists/size/url; _save/_open template-method lowest-common-denominator; unsupported caps raise NotImplementedError url() public-vs-signed toggled by querystring_auth; querystring_expire TTL
    Spring (Resource/S3Template) Resource/ResourceLoader (file:/s3:); S3Template Resource has no delete/sign; S3Template: upload/download/deleteObject/objectExists/createSignedGetURL protocol-prefix resolution elegant; signing only in S3 layer presign expiry is Duration; exists() = real HEAD on S3
    Phoenix/Elixir (Waffle+ExAws) (no built-in) Waffle.Storage.Local/.S3 behaviour triad put/url/delete; ExAws.S3.presigned_url(:get,…,expires_in:) single-knob swap; no built-in disk — pure library choice local served via Plug.Static; presign offline; R2 needs PUT not POST
    AdonisJS Drive/FlyDrive (+1) Disk wraps Driver/DriverContract; services.fs/services.s3 put/get/exists/delete/copy/move/getUrl/getSignedUrl/streams/visibility 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.

    Sources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementroadmapRoadmap / tracking issue grouping scheduled work

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions