Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/3157-storage-disk-abstraction.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added a pluggable storage-disk abstraction under `wheels.storage` with `LocalDisk` and `S3Disk` drivers behind a uniform `put/get/exists/delete/url/signedUrl` interface, resolved by name through `StorageManager`. S3 access — including presigned, expiring URLs — uses a from-scratch SigV4 signer over plain `cfhttp` (no AWS SDK, no JARs) (#3157).
67 changes: 67 additions & 0 deletions vendor/wheels/interfaces/StorageDiskInterface.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Contract every storage disk driver must satisfy.
*
* A "disk" is a named, configured storage backend (local filesystem, S3, …).
* Drivers expose one small, uniform surface so application code can swap
* backends with a config change and no call-site edits — mirroring Laravel's
* Filesystem and Rails' ActiveStorage::Service abstractions.
*
* Implementations live under `wheels.storage.drivers.*`. Resolve a configured
* disk through `wheels.storage.StorageManager`.
*
* [section: Storage]
* [category: Interface]
*/
interface {

/**
* Store content at the given key, creating intermediate paths as needed.
*
* @key The opaque storage key (path-like, forward-slash separated).
* @content Binary or string content to write.
* @contentType MIME type hint (used by cloud backends; ignored by local).
* @visibility "public" or "private"; backends that support ACLs honour it.
* @return The stored key.
*/
public any function put(required string key, required any content, string contentType, string visibility);

/**
* Read the content stored at the given key as binary.
*
* @key The storage key.
* @return Binary content. Throws Wheels.Storage.NotFound when absent.
*/
public any function get(required string key);

/**
* Whether an object exists at the given key.
*
* @key The storage key.
*/
public boolean function exists(required string key);

/**
* Delete the object at the given key.
*
* @key The storage key.
* @return true when an object was deleted, false when nothing was there.
*/
public boolean function delete(required string key);

/**
* A non-expiring URL for the object (public objects / served route).
*
* @key The storage key.
*/
public string function url(required string key);

/**
* A signed, time-limited URL granting temporary access to a private object.
*
* @key The storage key.
* @expiresIn Seconds until the URL expires (default 300).
* @contentDisposition Optional Content-Disposition the download should carry.
*/
public string function signedUrl(required string key, numeric expiresIn, string contentDisposition);

}
285 changes: 285 additions & 0 deletions vendor/wheels/storage/S3Signer.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,285 @@
/**
* AWS Signature Version 4 signer for S3, implemented from scratch — no AWS SDK,
* no JARs. Generates presigned GET URLs (query-string auth) and Authorization
* headers (header auth) for arbitrary S3 requests issued via `cfhttp`.
*
* The crypto primitives are the same proven, cross-engine-green building blocks
* `wheels.auth.JwtService` relies on (SHA-256 hex hashing + a chained HMAC-SHA256
* key-derivation), driven through `javax.crypto.Mac` so binary signing keys work
* identically on Lucee 5/6/7, Adobe CF 2018-2025, and BoxLang.
*
* Reference: AWS "Authenticating Requests: Using Query Parameters (AWS Signature
* Version 4)" and "...Using the Authorization Header...".
*
* Usage:
* var signer = new wheels.storage.S3Signer(
* accessKeyId="AKIA…", secretAccessKey="…", region="us-east-1", bucket="my-bucket"
* );
* var url = signer.presignGetUrl(key="reports/q3.pdf", expiresIn=300);
*
* [section: Storage]
* [category: Core]
*/
component output="false" {

/**
* @accessKeyId AWS access key id.
* @secretAccessKey AWS secret access key.
* @region AWS region (e.g. "us-east-1").
* @bucket S3 bucket name.
* @endpoint Override the host (e.g. for S3-compatible stores). Empty => derive from bucket+region.
* @usePathStyle When true, addresses as host/bucket/key rather than bucket.host/key.
*/
public S3Signer function init(
required string accessKeyId,
required string secretAccessKey,
required string region,
required string bucket,
string endpoint = "",
boolean usePathStyle = false
) {
variables.accessKeyId = arguments.accessKeyId;
variables.secretAccessKey = arguments.secretAccessKey;
variables.region = arguments.region;
variables.bucket = arguments.bucket;
variables.usePathStyle = arguments.usePathStyle;
variables.service = "s3";

if (Len(arguments.endpoint)) {
variables.host = arguments.endpoint;
} else if (arguments.usePathStyle) {
variables.host = "s3." & arguments.region & ".amazonaws.com";
} else {
variables.host = arguments.bucket & ".s3." & arguments.region & ".amazonaws.com";
}

variables.javaSystem = CreateObject("java", "java.lang.System");
return this;
}

/**
* Build a presigned GET URL for an object key.
*
* @key Object key (path-like; slashes preserved).
* @expiresIn Seconds until the link expires (default 300, max 604800 per SigV4).
* @contentDisposition Optional response-content-disposition override S3 will echo.
* @amzDate Optional ISO8601 basic timestamp ("yyyymmddTHHnnssZ"). Defaults to now (UTC). Overridable for deterministic tests.
*/
public string function presignGetUrl(
required string key,
numeric expiresIn = 300,
string contentDisposition = "",
string amzDate = ""
) {
local.amzDate = Len(arguments.amzDate) ? arguments.amzDate : $amzNow();
local.dateStamp = Left(local.amzDate, 8);
local.credentialScope = local.dateStamp & "/" & variables.region & "/" & variables.service & "/aws4_request";

// Canonical URI: path-style prefixes the bucket; virtual-hosted does not.
local.canonicalUri = variables.usePathStyle
? "/" & $uriEncodePath(variables.bucket & "/" & arguments.key)
: "/" & $uriEncodePath(arguments.key);

// Canonical query string — keys must be sorted by their encoded name.
local.params = {
"X-Amz-Algorithm" = "AWS4-HMAC-SHA256",
"X-Amz-Credential" = variables.accessKeyId & "/" & local.credentialScope,
"X-Amz-Date" = local.amzDate,
"X-Amz-Expires" = arguments.expiresIn,
"X-Amz-SignedHeaders" = "host"
};
if (Len(arguments.contentDisposition)) {
local.params["response-content-disposition"] = arguments.contentDisposition;
}
local.canonicalQuery = $buildCanonicalQuery(local.params);

local.canonicalHeaders = "host:" & variables.host & Chr(10);
local.signedHeaders = "host";
local.payloadHash = "UNSIGNED-PAYLOAD";

local.canonicalRequest = "GET" & Chr(10)
& local.canonicalUri & Chr(10)
& local.canonicalQuery & Chr(10)
& local.canonicalHeaders & Chr(10)
& local.signedHeaders & Chr(10)
& local.payloadHash;

local.signature = $signString(local.canonicalRequest, local.amzDate, local.dateStamp, local.credentialScope);

local.scheme = "https://";
return local.scheme & variables.host & local.canonicalUri & "?" & local.canonicalQuery
& "&X-Amz-Signature=" & local.signature;
}

/**
* Sign an arbitrary S3 request, returning the headers (incl. Authorization)
* a caller adds to a `cfhttp` invocation. Used for put/get/delete/exists.
*
* @method HTTP verb.
* @key Object key.
* @payload Request body (binary or string); empty for GET/DELETE/HEAD.
* @amzDate Optional deterministic timestamp override.
* @return Struct of header name => value to attach to the request.
*/
public struct function signedHeaders(
required string method,
required string key,
any payload = "",
string amzDate = ""
) {
local.amzDate = Len(arguments.amzDate) ? arguments.amzDate : $amzNow();
local.dateStamp = Left(local.amzDate, 8);
local.credentialScope = local.dateStamp & "/" & variables.region & "/" & variables.service & "/aws4_request";

local.payloadHash = $sha256Hex(arguments.payload);

local.canonicalUri = variables.usePathStyle
? "/" & $uriEncodePath(variables.bucket & "/" & arguments.key)
: "/" & $uriEncodePath(arguments.key);

// Headers signed for header-auth: host, x-amz-content-sha256, x-amz-date (sorted).
local.canonicalHeaders = "host:" & variables.host & Chr(10)
& "x-amz-content-sha256:" & local.payloadHash & Chr(10)
& "x-amz-date:" & local.amzDate & Chr(10);
local.signedHeaderList = "host;x-amz-content-sha256;x-amz-date";

local.canonicalRequest = UCase(arguments.method) & Chr(10)
& local.canonicalUri & Chr(10)
& "" & Chr(10)
& local.canonicalHeaders & Chr(10)
& local.signedHeaderList & Chr(10)
& local.payloadHash;

local.signature = $signString(local.canonicalRequest, local.amzDate, local.dateStamp, local.credentialScope);

local.authorization = "AWS4-HMAC-SHA256 "
& "Credential=" & variables.accessKeyId & "/" & local.credentialScope & ", "
& "SignedHeaders=" & local.signedHeaderList & ", "
& "Signature=" & local.signature;

return {
"Authorization" = local.authorization,
"x-amz-content-sha256" = local.payloadHash,
"x-amz-date" = local.amzDate,
"Host" = variables.host
};
}

/**
* The resolved request host (virtual-hosted or path-style endpoint).
*/
public string function getHost() {
return variables.host;
}

/**
* RFC3986-encode an object key for use as a request path (forward slashes
* preserved). The wire URL must use the same encoding the canonical request
* signs, or S3 returns SignatureDoesNotMatch for keys with spaces / reserved
* characters. Lets the disk build request/url paths that stay byte-identical
* to what was signed.
*
* @key Object key.
*/
public string function encodeKey(required string key) {
return $uriEncodePath(arguments.key);
}

// ---- internals --------------------------------------------------------

/**
* Produce the lowercase-hex SigV4 signature for a canonical request.
*/
private string function $signString(
required string canonicalRequest,
required string amzDate,
required string dateStamp,
required string credentialScope
) {
local.stringToSign = "AWS4-HMAC-SHA256" & Chr(10)
& arguments.amzDate & Chr(10)
& arguments.credentialScope & Chr(10)
& $sha256Hex(arguments.canonicalRequest);

local.signingKey = $signingKey(arguments.dateStamp);
return LCase(BinaryEncode($hmac(local.signingKey, local.stringToSign), "hex"));
}

/**
* Derive the SigV4 signing key: HMAC chain seeded with "AWS4"+secret.
*/
private binary function $signingKey(required string dateStamp) {
local.kSecret = CharsetDecode("AWS4" & variables.secretAccessKey, "UTF-8");
local.kDate = $hmac(local.kSecret, arguments.dateStamp);
local.kRegion = $hmac(local.kDate, variables.region);
local.kService = $hmac(local.kRegion, variables.service);
return $hmac(local.kService, "aws4_request");
}

/**
* HMAC-SHA256 with a binary key, returning raw bytes. Uses javax.crypto.Mac
* directly so successive rounds can key off the previous round's binary
* output — the built-in HMac() takes only string keys.
*/
private binary function $hmac(required binary key, required string message) {
local.mac = CreateObject("java", "javax.crypto.Mac").getInstance("HmacSHA256");
local.keySpec = CreateObject("java", "javax.crypto.spec.SecretKeySpec").init(arguments.key, "HmacSHA256");
local.mac.init(local.keySpec);
return local.mac.doFinal(CharsetDecode(arguments.message, "UTF-8"));
}

/**
* Lowercase hex SHA-256 of a string or binary payload.
*/
private string function $sha256Hex(required any content) {
if (IsBinary(arguments.content)) {
return LCase(Hash(arguments.content, "SHA-256"));
}
return LCase(Hash(arguments.content, "SHA-256", "UTF-8"));
}

/**
* Build a sorted, RFC3986-encoded canonical query string from a struct.
*/
private string function $buildCanonicalQuery(required struct params) {
local.keys = StructKeyArray(arguments.params);
// SigV4 sorts by raw byte order of the encoded key name; for our fixed
// ASCII parameter names a case-sensitive text sort is byte-identical.
ArraySort(local.keys, "text");
local.pairs = [];
for (local.k in local.keys) {
ArrayAppend(local.pairs, $uriEncodeSegment(local.k) & "=" & $uriEncodeSegment(arguments.params[local.k]));
}
return ArrayToList(local.pairs, "&");
}

/**
* RFC3986 encode a single value (slashes ARE encoded). Built on
* java.net.URLEncoder with the AWS-required fix-ups so it is byte-identical
* across engines.
*/
private string function $uriEncodeSegment(required any value) {
local.encoder = CreateObject("java", "java.net.URLEncoder");
local.encoded = local.encoder.encode(ToString(arguments.value), "UTF-8");
local.encoded = Replace(local.encoded, "+", "%20", "all");
local.encoded = Replace(local.encoded, "*", "%2A", "all");
local.encoded = Replace(local.encoded, "%7E", "~", "all");
return local.encoded;
}

/**
* RFC3986 encode an object key path, preserving forward slashes.
*/
private string function $uriEncodePath(required string key) {
return Replace($uriEncodeSegment(arguments.key), "%2F", "/", "all");
}

/**
* Current UTC time as an ISO8601 basic timestamp ("yyyymmddTHHnnssZ").
*/
private string function $amzNow() {
local.utc = DateConvert("local2utc", Now());
return DateFormat(local.utc, "yyyymmdd") & "T" & TimeFormat(local.utc, "HHmmss") & "Z";
}

}
Loading
Loading