aws / aws-sdk-net

The official AWS SDK for .NET. For more information on the AWS SDK for .NET, see our web site:
http://aws.amazon.com/sdkfornet/
Apache License 2.0
2.02k stars 849 forks source link

AmazonS3Client is missing `Create Presigned Post` #1901

Open marns opened 2 years ago

marns commented 2 years ago

AmazonS3Client / IAmazonS3 is missing Create Presigned Post functionality found in JS/Python libraries. GetPreSignedURL does not appear to support POST. Therefore there seems to be no way to constrain max upload size when providing signed links using aws-sdk-net.

ashishdhingra commented 2 years ago

Hi @marns,

Good morning.

Could you please elaborate or point to the JS/Python functionality which appear to use Create Presigned Post? Are you referring to https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_s3_presigned_post.html (for example)? Just curious if you considered using the PUT verb (look at the example Uploading objects using presigned URLs).

Just for reference, GetPreSignedUrlRequest has a property named Verb which is of type Amazon.S3.HttpVerb which only supports GET, PUT, DELETE and HEAD verbs. The example at Uploading objects using presigned URLs makes use of PUT HTTP Verb. Also the Visual Studio toolkit appears to support only GET and PUT HTTP verbs for generating presigned URLs.

This needs discussion with the team and doesn't appear to be bug, rather, the feature request.

Thanks, Ashish

ashishdhingra commented 2 years ago

Just for reference:

marns commented 2 years ago

Yes, see also: https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html?highlight=presigned#S3.Client.generate_presigned_post

I'm using PUT but (please correct me if I'm wrong) this does not provide for constraints like max file size or preventing update by link reuse.

hstefanov commented 2 years ago

Correct me if I am wrong I was also looking for POST option for .NET but could not find a solution. Moreover is it possible with the PUT option to use presigned URLs where the trusted signer is not self (AWS account) but rather managed key group of signers. I opened an issue for that as well. Thanks.

hognevevle commented 2 years ago

We're also in need of pre-signed POST support, as GetPreSignedURL doesn't meet our requirements (limiting the size of the uploaded files, and allowing only certain content-types).

zhuker commented 2 years ago

+1 for pre-signed post we also need it here

zhuker commented 2 years ago

@hognevevle @marns I created a quick-hack by rewrite of boto3 generate_presigned_post https://gist.github.com/zhuker/ec11b828860bf1cf72614403a5f9bf2a

Sample usage:

[Test]
public void TestPost()
{
    var s3 = new AmazonS3Client(new BasicAWSCredentials("MyAccessKey", "MySecretKey"), RegionEndpoint.USWest1);

    var post = s3.GeneratePreSignedPost("mybucket", "testkey",
        new List<List<string>> {new() {"content-length-range", "1", "42000"}},
        4200);

    Console.WriteLine(post.Url);
    foreach (var (key, value) in post.Fields)
    {
        Console.WriteLine(key + ": " + value);
    }
}
coultonluke commented 2 years ago

Also looking for this. Ending up using a lambda with the JavaScript SDK so that I can add the Conditions (specifically content-length-range to restrict file sizes) and calling s3.createPresignedPost(...)

AxelJunker commented 2 years ago

We ended up implementing our own S3 presigned POST in F#. Inspired by the AWS SDK for JS

Pronyuk commented 2 years ago

@ashishdhingra Hi, any updates about PresignedPost for .net?

vadim-kor commented 1 year ago

does it still missing content-length-range?

glen-84 commented 1 year ago

I implemented this in an extended AmazonS3 client.

Install AWSSDK.S3 and AWSSDK.Extensions.NETCore.Setup.

using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Amazon.Runtime;
using Amazon.Runtime.Internal.Auth;
using Amazon.S3;

namespace Api.Framework.AmazonS3;

/// <summary>
/// An extended Amazon S3 client, with added support for creating presigned posts.
/// </summary>
/// <seealso href="https://github.com/aws/aws-sdk-net/issues/1901">
/// AmazonS3Client is missing Create Presigned Post
/// </seealso>
public sealed class AmazonS3ExtendedClient : AmazonS3Client, IAmazonS3Extended
{
    public AmazonS3ExtendedClient(AWSCredentials credentials, AmazonS3ExtendedConfig config)
        : base(credentials, config) { }

    public CreatePresignedPostResponse CreatePresignedPost(CreatePresignedPostRequest request)
    {
        var regionName = this.Config.RegionEndpoint.SystemName;

        var url = new Uri($"https://s3.{regionName}.amazonaws.com/{request.Bucket}");

        var signingDate = this.Config.CorrectedUtcNow
            .ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);

        var shortDate = signingDate[..8];

        var credentials = this.Credentials.GetCredentials();

        var credentialScope = $"{shortDate}/{regionName}/s3/aws4_request";

        var fields = request.Fields ?? new Dictionary<string, string>();

        fields.Add("key", request.Key);
        fields.Add("bucket", request.Bucket);
        fields.Add("X-Amz-Algorithm", "AWS4-HMAC-SHA256");
        fields.Add("X-Amz-Credential", $"{credentials.AccessKey}/{credentialScope}");
        fields.Add("X-Amz-Date", signingDate);
        fields.Add("X-Amz-Security-Token", credentials.Token);

        var conditions = request.Conditions ?? new List<Condition>();

        foreach (var (key, value) in fields)
        {
            conditions.Add(new ExactMatchCondition(key, value));
        }

        var postPolicy = new PostPolicy(
            this.Config.CorrectedUtcNow.Add(request.Expires ?? TimeSpan.FromSeconds(3600)),
            conditions);

        var postPolicyJson = JsonSerializer.Serialize(
            postPolicy,
            AmazonS3SerializerContext.Default.PostPolicy);

        var postPolicyEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(postPolicyJson));

        fields.Add("Policy", postPolicyEncoded);

        var signingKey = AWS4Signer.ComposeSigningKey(
            credentials.SecretKey,
            regionName,
            shortDate,
            "s3");

        var signature =
            AWS4Signer.ComputeKeyedHash(SigningAlgorithm.HmacSHA256, signingKey, postPolicyEncoded);

        fields.Add("X-Amz-Signature", Convert.ToHexString(signature).ToLowerInvariant());

        return new CreatePresignedPostResponse(url, fields);
    }
}

public interface IAmazonS3Extended : IAmazonS3
{
    public CreatePresignedPostResponse CreatePresignedPost(CreatePresignedPostRequest request);
}

public sealed class AmazonS3ExtendedConfig : AmazonS3Config { }

public sealed record CreatePresignedPostRequest(
    string Bucket,
    string Key,
    IList<Condition>? Conditions = null,
    IDictionary<string, string>? Fields = null,
    TimeSpan? Expires = null);

public sealed record CreatePresignedPostResponse(Uri Url, IDictionary<string, string> Fields);

public sealed record PostPolicy(DateTime Expiration, IList<Condition> Conditions);

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(PostPolicy))]
public sealed partial class AmazonS3SerializerContext : JsonSerializerContext { }

[JsonDerivedType(typeof(ExactMatchCondition))]
[JsonDerivedType(typeof(StartsWithMatchCondition))]
[JsonDerivedType(typeof(RangeMatchCondition))]
public abstract record Condition { }

[JsonConverter(typeof(ExactMatchConditionConverter))]
public sealed record ExactMatchCondition(string Key, string Value) : Condition;

[JsonConverter(typeof(StartsWithMatchConditionConverter))]
public sealed record StartsWithMatchCondition(string Key, string Value) : Condition;

[JsonConverter(typeof(RangeMatchConditionConverter))]
public sealed record RangeMatchCondition(string Key, int ValueStart, int ValueEnd) : Condition;

public sealed class ExactMatchConditionConverter : JsonConverter<ExactMatchCondition>
{
    public override ExactMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        ExactMatchCondition value,
        JsonSerializerOptions options)
    {
        writer.WriteStartObject();
        writer.WriteString(value.Key, value.Value);
        writer.WriteEndObject();
    }
}

public sealed class StartsWithMatchConditionConverter : JsonConverter<StartsWithMatchCondition>
{
    public override StartsWithMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        StartsWithMatchCondition value,
        JsonSerializerOptions options)
    {
        writer.WriteStartArray();
        writer.WriteStringValue("starts-with");
        writer.WriteStringValue(value.Key);
        writer.WriteStringValue(value.Value);
        writer.WriteEndArray();
    }
}

public sealed class RangeMatchConditionConverter : JsonConverter<RangeMatchCondition>
{
    public override RangeMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        RangeMatchCondition value,
        JsonSerializerOptions options)
    {
        writer.WriteStartArray();
        writer.WriteStringValue(value.Key);
        writer.WriteNumberValue(value.ValueStart);
        writer.WriteNumberValue(value.ValueEnd);
        writer.WriteEndArray();
    }
}

In Program.cs:

builder.Services
    .AddAWSService<IAmazonS3Extended>(builder.Configuration.GetAWSOptions());

Usage:

// Inject an `IAmazonS3Extended` (s3Client).

this.s3Client.CreatePresignedPost(new(
    "YourBucketName",
    key,
    new List<Condition>()
    {
        // Limit file size.
        new RangeMatchCondition(
            "content-length-range",
            1,
            100_000)
    },
    Expires: TimeSpan.FromMinutes(10)));

Disclaimer: No support or liability.

Krzyrok commented 1 year ago

+1 for presigned POST url. It would be really nice to have this supported by aws-sdk-net. We rely on .net Core and we use this feature (presigned URLs) on FE. We want to use sendBeacon functionality for these requests ( https://developer.mozilla.org/en-US/docs/Web/API/Navigator/sendBeacon ) but it requires POST (fetch + keepalive has worse browser's support).

greffgreff commented 12 months ago

Here is my take on @glen-84 's work. It uses the new bucket url format but does exactly the same:

namespace Api.Framework.AmazonS3;

public sealed class AmazonS3ExtendedClient : AmazonS3Client, IAmazonS3Extended
{
    public AmazonS3ExtendedClient(string awsAccessKeyId, string awsSecretAccessKey, RegionEndpoint region) 
        : base(awsAccessKeyId, awsSecretAccessKey, region)
    {

    }

    public CreatePresignedPostResponse CreatePresignedPost(CreatePresignedPostUrlRequest request)
    {
        var regionName = this.Config.RegionEndpoint.SystemName;
        var credentials = this.Credentials.GetCredentials();
        var signingDate = this.Config.CorrectedUtcNow.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
        var signingAlgorithm = "AWS4-HMAC-SHA256";
        var shortDate = signingDate[..8];
        var credentialScope = $"{shortDate}/{regionName}/s3/aws4_request";
        var parsedCredentials = $"{credentials.AccessKey}/{credentialScope}";
        var url = new Uri($"https://{request.BucketName}.s3.{regionName}.amazonaws.com");

        request.Conditions.Add(new XAmzDate(signingDate));
        request.Conditions.Add(new XAmzAlgorithm(signingAlgorithm));
        request.Conditions.Add(new XAmzCredential(parsedCredentials));

        var policy = new PostPolicy
        {
            Expiration = this.Config.CorrectedUtcNow.Add(request.Expires ?? TimeSpan.FromSeconds(3600)),
            Conditions = request.Conditions,
        };

        var policyJson = JsonConvert.SerializeObject(policy);
        var policyEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(policyJson));

        var signingKey = AWS4Signer.ComposeSigningKey(credentials.SecretKey, regionName, shortDate, "s3");
        var signature = AWS4Signer.ComputeKeyedHash(SigningAlgorithm.HmacSHA256, signingKey, policyEncoded);

        var fields = new Dictionary<string, string>() 
        {
            { "Key", request.Key },
            { "Bucket", request.BucketName },
            { "Policy", policyEncoded },
            { "X-Amz-Signature", Convert.ToHexString(signature).ToLowerInvariant() },
            { "X-Amz-Algorithm", signingAlgorithm },
            { "X-Amz-Credential", parsedCredentials },
            { "X-Amz-Date", signingDate },
        };

        return new CreatePresignedPostResponse
        {
            Url = url,
            Fields = fields,
        };
    }
}

public sealed record CreatePresignedPostUrlRequest
{
    public string BucketName { get; set; }
    public string Key { get; set; }
    public IList<object> Conditions { get; set; } = new List<object>();
    public TimeSpan? Expires { get; set; }
}

public sealed record PostPolicy
{
    [JsonProperty("expiration")]
    public DateTime Expiration { get; set; }

    [JsonProperty("conditions")]
    public IList<object> Conditions { get; set; }
}

public sealed record CreatePresignedPostResponse
{
    public Uri Url { get; set; }
    public IDictionary<string, string>? Fields { get; set; }
}

To be used like so:

var bucketName = "<bucket name>";
var accessKey = "<access key>";
var secretKey = "<secret key>";
var region = Amazon.RegionEndpoint.EUWest2;
using var client = new AmazonS3ExtendedClient(accessKey, secretKey, region);

var request = new CreatePresignedPostUrlRequest
{
    BucketName = bucketName,
    Key = "folder/3d36552a-c4db-4f91-b1af-2c8395ca7e03",
    Expires = TimeSpan.FromMinutes(10),
    Conditions =
    {
        new { bucket = bucketName },
        new object[] { "content-length-range", 0, 1_000_000 },
        new object[] { "starts-with", "$key", "folder/3d36552a-c4db-4f91-b1af-2c8395ca7e03" },
    },
};

var url = client.CreatePresignedPost(request);
diegosasw commented 7 months ago

Is there any update on this or a workaround?

I've tried @glen-84 code but when I test it with a HTML + JS I am getting a 403 Forbidden with CORS

redirected
: 
false
status
: 
403
statusText
: 
"Forbidden"
type
: 
"cors"

And I don't know if it's related to something missing in the presigned URL POST retrieval.

My S3 bucket is already configured with the following CORS json

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "GET",
            "POST",
            "PUT",
            "DELETE",
            "HEAD"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": [],
        "MaxAgeSeconds": 0
    }
]

This is how I test it (fields truncated) with the fields retrieved plus the url retrieved https://s3.eu-west-1.amazonaws.com/my-foo-bucket

document.getElementById('upload-form').addEventListener('submit', function (e) {
    e.preventDefault();

    // Replace with the URL and fields you got from your server
    const presignedUrl = 'https://s3.eu-west-1.amazonaws.com/my-foo-bucket';
    const fields = {
        'key': 'foo.txt',
        'bucket': 'rubiko-saas-bucket',
        'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
        'X-Amz-Credential': 'AKIAXVUTS***********/20231110/eu-west-1/s3/aws4_request',
        'X-Amz-Date': '20231110T160442Z',
        'X-Amz-Security-Token': '',
        'Policy': 'eyJleHBpcma0aW9uIjoiMj**************',
        'X-Amz-Signature': '8f517b85d01b0a17726cb9484bf0197167e196961023******************'
    };

    const fileInput = document.getElementById('file-input');
    const file = fileInput.files[0];

    const formData = new FormData();
    for (const key in fields) {
        formData.append(key, fields[key]);
    }
    formData.append('file', file);

    fetch(presignedUrl, {
        method: 'POST',
        body: formData
    })
    .then(response => {
        if (response.ok) {
            console.log('File successfully uploaded to S3');
        } else {
            console.error('Upload failed:', response);
        }
    })
    .catch(error => console.error('Error uploading file:', error));
});

and the html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Upload to S3</title>
</head>
<body>

<form id="upload-form">
    <input type="file" id="file-input" name="file" required />
    <input type="submit" value="Upload to S3" />
</form>

<script src="upload.js"></script>
</body>
</html>

Any idea on why CORS may be a problem here?

DanielLaberge commented 4 months ago

Please consider adding support for Conditions in the .NET SDK. Feature parity between languages is important!

ShaneK commented 3 months ago

I ran up against this and was able to combine solutions and do a lot of tinkering on my own to hammer out an extended client that works with security tokens and ${filename} keys. Thank you so much for your contributions @greffgreff, @glen-84, and @zhuker. This should work fully as a copy-paste solution:

using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Amazon.Runtime;
using Amazon.Runtime.Internal.Auth;
using Amazon.S3;
using JsonSerializer = System.Text.Json.JsonSerializer;

namespace Your.Namespace.Here;

/// <summary>
/// An extended Amazon S3 client, with added support for creating presigned posts.
/// </summary>
/// <seealso href="https://github.com/aws/aws-sdk-net/issues/1901">
/// AmazonS3Client is missing Create Presigned Post
/// </seealso>
public sealed class AmazonS3ExtendedClient : AmazonS3Client, IAmazonS3Extended {
    public AmazonS3ExtendedClient(AWSCredentials credentials, AmazonS3Config config)
        : base(credentials, config) {
    }

    public PresignedPost GeneratePreSignedPost(CreatePresignedPostUrlRequest request) {
        // This null check will occur if you're using local S3. This won't work with local S3 - and cannot as far as I can tell - but at least it won't break.
        var regionName = this.Config.RegionEndpoint?.SystemName ?? "us-east-1";
        var credentials = this.Credentials.GetCredentials();
        var signingDate = this.Config.CorrectedUtcNow.ToString("yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
        const string signingAlgorithm = "AWS4-HMAC-SHA256";
        var shortDate = signingDate[..8];
        var credentialScope = $"{shortDate}/{regionName}/s3/aws4_request";
        var parsedCredentials = $"{credentials.AccessKey}/{credentialScope}";
        var url = new Uri($"https://{request.BucketName}.s3.{regionName}.amazonaws.com");

        request.Conditions.Add(new ExactMatchCondition("bucket", request.BucketName));
        request.Conditions.Add(new ExactMatchCondition("X-Amz-Algorithm", "AWS4-HMAC-SHA256"));
        request.Conditions.Add(new ExactMatchCondition("X-Amz-Credential", $"{credentials.AccessKey}/{credentialScope}"));
        request.Conditions.Add(new ExactMatchCondition("X-Amz-Date", signingDate));
        if (!string.IsNullOrWhiteSpace(credentials.Token)) {
            request.Conditions.Add(new ExactMatchCondition("X-Amz-Security-Token", credentials.Token));
        }

        if (request.Key.EndsWith("${filename}")) {
            var keyWithoutFilename = request.Key[..^"${filename}".Length];
            request.Conditions.Add(new StartsWithMatchCondition("$key", keyWithoutFilename));
        } else {
            request.Conditions.Add(new ExactMatchCondition("$key", request.Key));
        }

        var policy = new PostPolicy {
            Expiration = this.Config.CorrectedUtcNow.Add(request.Expires ?? TimeSpan.FromSeconds(3600)),
            Conditions = request.Conditions,
        };

        var policyJson = JsonSerializer.Serialize(policy);
        var policyEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(policyJson));

        var signingKey = AWS4Signer.ComposeSigningKey(credentials.SecretKey, regionName, shortDate, "s3");
        var signature = AWS4Signer.ComputeKeyedHash(SigningAlgorithm.HmacSHA256, signingKey, policyEncoded);

        var fields = new Dictionary<string, string> {
            { "Key", request.Key },
            { "Bucket", request.BucketName },
            { "Policy", policyEncoded },
            { "X-Amz-Signature", Convert.ToHexString(signature).ToLowerInvariant() },
            { "X-Amz-Algorithm", signingAlgorithm },
            { "X-Amz-Credential", parsedCredentials },
            { "X-Amz-Date", signingDate },
        };

        if (!string.IsNullOrWhiteSpace(credentials.Token)) {
            fields.Add("X-Amz-Security-Token", credentials.Token);
        }

        return new PresignedPost { Url = url.ToString(), Fields = fields, };
    }
}

public sealed record CreatePresignedPostUrlRequest {
    public required string BucketName { get; set; }
    public required string Key { get; set; }
    public IList<object> Conditions { get; set; } = new List<object>();
    public TimeSpan? Expires { get; set; }
}

public sealed record PostPolicy {
    [JsonPropertyName("expiration")] public DateTime Expiration { get; set; }

    [JsonPropertyName("conditions")] public IList<object> Conditions { get; set; } = new List<object>();
}

public sealed record PresignedPost {
    public required string Url { get; set; }
    public IDictionary<string, string>? Fields { get; set; }
}

public interface IAmazonS3Extended : IAmazonS3 {
    public PresignedPost GeneratePreSignedPost(CreatePresignedPostUrlRequest request);
}

[JsonDerivedType(typeof(ExactMatchCondition))]
[JsonDerivedType(typeof(StartsWithMatchCondition))]
[JsonDerivedType(typeof(RangeMatchCondition))]
public abstract record Condition {
}

[JsonConverter(typeof(ExactMatchConditionConverter))]
public sealed record ExactMatchCondition(string Key, string Value) : Condition;

[JsonConverter(typeof(StartsWithMatchConditionConverter))]
public sealed record StartsWithMatchCondition(string Key, string Value) : Condition;

[JsonConverter(typeof(RangeMatchConditionConverter))]
public sealed record RangeMatchCondition(string Key, int ValueStart, int ValueEnd) : Condition;

public sealed class ExactMatchConditionConverter : System.Text.Json.Serialization.JsonConverter<ExactMatchCondition> {
    public override ExactMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        ExactMatchCondition value,
        JsonSerializerOptions options) {
        writer.WriteStartObject();
        writer.WriteString(value.Key, value.Value);
        writer.WriteEndObject();
    }
}

public sealed class
    StartsWithMatchConditionConverter : System.Text.Json.Serialization.JsonConverter<StartsWithMatchCondition> {
    public override StartsWithMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        StartsWithMatchCondition value,
        JsonSerializerOptions options) {
        writer.WriteStartArray();
        writer.WriteStringValue("starts-with");
        writer.WriteStringValue(value.Key);
        writer.WriteStringValue(value.Value);
        writer.WriteEndArray();
    }
}

public sealed class RangeMatchConditionConverter : System.Text.Json.Serialization.JsonConverter<RangeMatchCondition> {
    public override RangeMatchCondition? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options) {
        throw new NotImplementedException();
    }

    public override void Write(
        Utf8JsonWriter writer,
        RangeMatchCondition value,
        JsonSerializerOptions options) {
        writer.WriteStartArray();
        writer.WriteStringValue(value.Key);
        writer.WriteNumberValue(value.ValueStart);
        writer.WriteNumberValue(value.ValueEnd);
        writer.WriteEndArray();
    }
}

Usage should work identical to @glen-84's example. I consider this code temporary, this issue was raised to p1 and I hope that means AWS will add official support soon and all of this can be replaced with just using the real thing.

glen-84 commented 3 months ago
// ReSharper disable once CheckNamespace - We have to hijack the namespace to make this work
namespace Api.Framework.AmazonS3;

This shouldn't be necessary. This is actually my own namespace.

ShaneK commented 3 months ago

This shouldn't be necessary. This is actually my own namespace.

Haha yeah, you're right, this was from my early attempts at making this all work. Updating it to its actual namespace seemed to be breaking something else, but I think it was a coincidence. Good call out, I've cleaned that bit up.