From 054b3f0d981527fea66d109aae5124ba7c44b5f9 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 4 Aug 2026 10:07:48 +0200 Subject: [PATCH 1/2] fix: treat an empty TransformOptions as no transform, not a render request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetPublicUrl, Download and CreateSignedUrl selected the image-render endpoint whenever transform options were non-null. But TransformOptions ships non-null defaults (Resize=Cover, Quality=80, Format=origin), so a fresh `new TransformOptions()` is never empty and always emits resize/quality — routing "no transform" calls to `/render/image/...?format=origin&resize=cover&quality=80`. That URL is broken locally (imgproxy off) and silently transforms in production. Add TransformOptions.IsEmpty (every field still at its constructed default) and select the render endpoint only when a transform is actually requested, mirroring the storage-js `wantsTransformation` guard. Also skips the transform query on the download paths so an empty options object never dangles resize/quality onto an object URL. Fixes supabase-community/storage-csharp#37 --- Storage/StorageFileApi.cs | 10 +++++----- Storage/TransformOptions.cs | 14 ++++++++++++++ StorageTests/Files/PublicUrlTests.cs | 13 +++++++++++-- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index 73a440f..78aecde 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -68,7 +68,7 @@ public string GetPublicUrl( if (downloadOptions != null) queryParams.Add(downloadOptions.ToQueryCollection()); - if (transformOptions == null) + if (transformOptions is null or { IsEmpty: true }) { var queryParamsString = queryParams.ToString(); return $"{Url}/object/public/{GetFinalPath(path)}?{queryParamsString}"; @@ -101,7 +101,7 @@ public async Task CreateSignedUrl( var body = new Dictionary { { "expiresIn", expiresIn } }; var url = $"{Url}/object/sign/{GetFinalPath(path)}"; - if (transformOptions != null) + if (transformOptions is { IsEmpty: false }) { var transformOptionsJson = JsonConvert.SerializeObject( transformOptions, @@ -527,7 +527,7 @@ public Task Download( ) { var url = - transformOptions != null + transformOptions is { IsEmpty: false } ? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}" : $"{Url}/object/{GetFinalPath(supabasePath)}"; return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken, cacheNonce); @@ -878,7 +878,7 @@ private async Task DownloadFile( var builder = new UriBuilder(url); var progress = new Progress(); - if (transformOptions != null) + if (transformOptions is { IsEmpty: false }) query.Add(transformOptions.ToQueryCollection()); if (cacheNonce != null) @@ -919,7 +919,7 @@ private async Task DownloadBytes( var builder = new UriBuilder(url); var progress = new Progress(); - if (transformOptions != null) + if (transformOptions is { IsEmpty: false }) query.Add(transformOptions.ToQueryCollection()); if (cacheNonce != null) diff --git a/Storage/TransformOptions.cs b/Storage/TransformOptions.cs index 041f473..0287d89 100644 --- a/Storage/TransformOptions.cs +++ b/Storage/TransformOptions.cs @@ -57,5 +57,19 @@ public enum ResizeType /// [JsonProperty("format")] public string Format { get; set; } = "origin"; + + private static readonly TransformOptions Default = new(); + + /// + /// True when no transform has been requested — every field still holds its constructed default, + /// so the caller wants the original object, not a render. Callers use this to keep an + /// unconfigured instance off the image-render endpoint (issue #37). + /// + internal bool IsEmpty => + Width == Default.Width + && Height == Default.Height + && Resize == Default.Resize + && Quality == Default.Quality + && Format == Default.Format; } } diff --git a/StorageTests/Files/PublicUrlTests.cs b/StorageTests/Files/PublicUrlTests.cs index 49c7076..e51dc5a 100644 --- a/StorageTests/Files/PublicUrlTests.cs +++ b/StorageTests/Files/PublicUrlTests.cs @@ -8,8 +8,9 @@ namespace StorageTests.Files; /// /// Covers , the pure URL builder: it targets the public -/// object path by default, switches to the image-render path when transform options are supplied, -/// and appends the download attribute when download options ask for it. +/// object path by default, switches to the image-render path only when a transform is actually +/// requested (an empty options object is not), and appends the download attribute when download +/// options ask for it. /// [TestClass] [TestCategory("Unit")] @@ -34,6 +35,14 @@ public void GetPublicUrl_ShouldUseTheRenderPath_GivenTransformOptions() url.Should().Contain("/render/image/public/bucket/a.png").And.Contain("width=100"); } + [TestMethod] + public void GetPublicUrl_ShouldTargetThePublicObjectPath_GivenEmptyTransformOptions() + { + var url = Bucket().GetPublicUrl("a.png", new TransformOptions()); + url.Should().StartWith($"{BaseUrl}/object/public/bucket/a.png").And.NotContain("/render/image/", + "an empty TransformOptions requests no transform, so it must not route to the render endpoint (issue #37)"); + } + [TestMethod] public void GetPublicUrl_ShouldAppendDownloadName_GivenDownloadOptions() { From 584ad457ef6f4454e0870aeeccdb46e46827b7d2 Mon Sep 17 00:00:00 2001 From: tr00d Date: Tue, 4 Aug 2026 10:10:31 +0200 Subject: [PATCH 2/2] docs: improve xml documentation for StorageFileApi --- Storage/StorageFileApi.cs | 85 ++++++++++++++++++++++++++++++++------- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index 78aecde..31d8fe3 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -16,15 +16,36 @@ namespace Supabase.Storage { + /// public class StorageFileApi : IStorageFileApi { + /// public ClientOptions Options { get; protected set; } + /// + /// + /// protected string Url { get; set; } + /// + /// + /// protected Dictionary Headers { get; set; } + /// + /// + /// protected string? BucketId { get; set; } - protected Header StorageHeader = new(); + /// + /// + /// + protected readonly Header StorageHeader = new(); + /// + /// + /// + /// + /// + /// + /// public StorageFileApi( string url, string bucketId, @@ -36,6 +57,12 @@ public StorageFileApi( Options = options ?? new ClientOptions(); } + /// + /// + /// + /// + /// + /// public StorageFileApi( string url, Dictionary? headers = null, @@ -50,7 +77,7 @@ public StorageFileApi( } /// - /// A simple convenience function to get the URL for an asset in a public bucket.If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. + /// A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. /// This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. /// /// @@ -84,9 +111,9 @@ public string GetPublicUrl( } /// - /// Create signed url to download file without requiring permissions. This URL can be valid for a set number of seconds. + /// Create signed url to download a file without requiring permissions. This URL can be valid for a set number of seconds. /// - /// The file path to be downloaded, including the current file name. For example `folder/image.png`. + /// The file path to be downloaded, including the current file name. For example, `folder/image.png`. /// The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. /// /// @@ -133,7 +160,7 @@ public async Task CreateSignedUrl( /// /// Create signed URLs to download files without requiring permissions. These URLs can be valid for a set number of seconds. /// - /// paths The file paths to be downloaded, including the current file names. For example [`folder/image.png`, 'folder2/image2.png']. + /// The file paths to be downloaded, including the current file names. For example [`folder/image.png`, 'folder2/image2.png']. /// The number of seconds until the signed URLs expire. For example, `60` for URLs which are valid for one minute. /// /// @@ -224,6 +251,7 @@ public async Task CreateSignedUrl( /// /// /// + /// /// public async Task Upload( string localFilePath, @@ -243,6 +271,26 @@ public async Task Upload( return result; } + /// + /// Uploads a byte array to an existing bucket. + /// + /// + /// The relative file path. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. + /// + /// + /// + /// + public Task Upload( + byte[] data, + string supabasePath, + FileOptions? options = null, + EventHandler? onProgress = null, + CancellationToken cancellationToken = default + ) + { + return Upload(data, supabasePath, options, onProgress, true, cancellationToken); + } + /// /// Uploads a byte array to an existing bucket. /// @@ -251,6 +299,7 @@ public async Task Upload( /// /// /// + /// /// public async Task Upload( byte[] data, @@ -508,7 +557,9 @@ await Helpers.MakeRequest( } /// - /// Downloads a file from a private bucket. For public buckets, use + /// Downloads a file from a private bucket. For public buckets, use + /// DownloadPublicFile(string, string, TransformOptions?, EventHandler{float}?) + /// /// /// /// @@ -534,7 +585,9 @@ public Task Download( } /// - /// Downloads a file from a private bucket. For public buckets, use + /// Downloads a file from a private bucket. For public buckets, use + /// DownloadPublicFile(string, string, TransformOptions?, EventHandler{float}?) + /// /// /// /// @@ -551,7 +604,9 @@ public Task Download( ) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken, cacheNonce); /// - /// Downloads a byte array from a private bucket to be used programmatically. For public buckets + /// Downloads a byte array from a private bucket to be used programmatically. For public buckets + /// DownloadPublicFile(string, TransformOptions?, EventHandler{float}?) + /// /// /// /// @@ -572,7 +627,9 @@ public Task Download( } /// - /// Downloads a byte array from a private bucket to be used programmatically. For public buckets + /// Downloads a byte array from a private bucket to be used programmatically. For public buckets + /// DownloadPublicFile(string, TransformOptions?, EventHandler{float}?) + /// /// /// /// @@ -627,20 +684,20 @@ public Task DownloadPublicFile( } /// - /// Deletes file within the same bucket + /// Deletes a file within the same bucket /// - /// A path to delete, for example `folder/image.png`. + /// A path to delete, for example, `folder/image.png`. /// public async Task Remove(string path) { - var result = await Remove(new List { path }); + var result = await Remove([path]); return result?.FirstOrDefault(); } /// /// Deletes files within the same bucket /// - /// An array of files to be deletes, including the path and file name. For example [`folder/image.png`]. + /// An array of files to be deleted, including the path and file name. For example [`folder/image.png`]. /// public async Task?> Remove(List paths) { @@ -658,7 +715,7 @@ public Task DownloadPublicFile( /// /// Creates an upload signed URL. Use it to upload a file straight to the bucket without credentials /// - /// The file path, including the current file name. For example `folder/image.png`. + /// The file path, including the current file name. For example, `folder/image.png`. /// public async Task CreateUploadSignedUrl(string supabasePath) {