diff --git a/Storage/DownloadOptions.cs b/Storage/DownloadOptions.cs index 44f7c00..883e95a 100644 --- a/Storage/DownloadOptions.cs +++ b/Storage/DownloadOptions.cs @@ -15,5 +15,10 @@ public class DownloadOptions ///

When field is empty, the original file name will be used. Use for quick initialized with original file names.

/// public string? FileName { get; set; } + + /// + /// Append a cache nonce parameter to the URL to invalidate the cache + /// + public string? CacheNonce { get; set; } } } \ No newline at end of file diff --git a/Storage/Extensions/DownloadOptionsExtension.cs b/Storage/Extensions/DownloadOptionsExtension.cs index 6911924..9d6135c 100644 --- a/Storage/Extensions/DownloadOptionsExtension.cs +++ b/Storage/Extensions/DownloadOptionsExtension.cs @@ -14,12 +14,16 @@ public static NameValueCollection ToQueryCollection(this DownloadOptions downloa { var query = HttpUtility.ParseQueryString(string.Empty); - if (download.FileName == null) + if (download.FileName != null) { - return query; + query.Add("download", string.IsNullOrEmpty(download.FileName) ? "true" : download.FileName); } - query.Add("download", string.IsNullOrEmpty(download.FileName) ? "true" : download.FileName); + + if (download.CacheNonce != null) + { + query.Add("cacheNonce", download.CacheNonce); + } return query; } diff --git a/Storage/Interfaces/IStorageFileApi.cs b/Storage/Interfaces/IStorageFileApi.cs index 8c7df70..f78e78e 100644 --- a/Storage/Interfaces/IStorageFileApi.cs +++ b/Storage/Interfaces/IStorageFileApi.cs @@ -20,38 +20,43 @@ Task CreateSignedUrl( int expiresIn, DownloadOptions? options = null ); - Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default); + Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default, string? cacheNonce = null); Task Download( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task Download( string supabasePath, string localPath, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task Download( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task DownloadPublicFile( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); Task DownloadPublicFile( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ); string GetPublicUrl( string path, diff --git a/Storage/StorageFileApi.cs b/Storage/StorageFileApi.cs index a0c29c0..73a440f 100644 --- a/Storage/StorageFileApi.cs +++ b/Storage/StorageFileApi.cs @@ -515,20 +515,22 @@ await Helpers.MakeRequest( /// /// /// + /// /// public Task Download( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = transformOptions != null ? $"{Url}/render/image/authenticated/{GetFinalPath(supabasePath)}" : $"{Url}/object/{GetFinalPath(supabasePath)}"; - return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken); + return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -538,13 +540,15 @@ public Task Download( /// /// /// + /// /// public Task Download( string supabasePath, string localPath, EventHandler? onProgress = null, - CancellationToken cancellationToken = default - ) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken); + CancellationToken cancellationToken = default, + string? cacheNonce = null + ) => Download(supabasePath, localPath, null, onProgress: onProgress, cancellationToken, cacheNonce); /// /// Downloads a byte array from a private bucket to be used programmatically. For public buckets @@ -553,16 +557,18 @@ public Task Download( /// /// /// + /// /// public Task Download( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = $"{Url}/object/{GetFinalPath(supabasePath)}"; - return DownloadBytes(url, transformOptions, onProgress, cancellationToken); + return DownloadBytes(url, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -571,9 +577,10 @@ public Task Download( /// /// /// + /// /// - public Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default) => - Download(supabasePath, transformOptions: null, onProgress: onProgress, cancellationToken); + public Task Download(string supabasePath, EventHandler? onProgress = null, CancellationToken cancellationToken = default, string? cacheNonce = null) => + Download(supabasePath, transformOptions: null, onProgress: onProgress, cancellationToken, cacheNonce); /// /// Downloads a public file to the filesystem. This method DOES NOT VERIFY that the file is actually public. @@ -583,17 +590,19 @@ public Task Download(string supabasePath, EventHandler? onProgres /// /// /// + /// /// public Task DownloadPublicFile( string supabasePath, string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = GetPublicUrl(supabasePath, transformOptions); - return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken); + return DownloadFile(url, localPath, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -603,16 +612,18 @@ public Task DownloadPublicFile( /// /// /// + /// /// public Task DownloadPublicFile( string supabasePath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { var url = GetPublicUrl(supabasePath, transformOptions); - return DownloadBytes(url, transformOptions, onProgress, cancellationToken); + return DownloadBytes(url, transformOptions, onProgress, cancellationToken, cacheNonce); } /// @@ -859,14 +870,21 @@ private async Task DownloadFile( string localPath, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { + var query = HttpUtility.ParseQueryString(string.Empty); var builder = new UriBuilder(url); var progress = new Progress(); if (transformOptions != null) - builder.Query = transformOptions.ToQueryCollection().ToString(); + query.Add(transformOptions.ToQueryCollection()); + + if (cacheNonce != null) + query.Add("cacheNonce", cacheNonce); + + builder.Query = query.ToString(); if (onProgress != null) progress.ProgressChanged += onProgress; @@ -893,14 +911,21 @@ private async Task DownloadBytes( string url, TransformOptions? transformOptions = null, EventHandler? onProgress = null, - CancellationToken cancellationToken = default + CancellationToken cancellationToken = default, + string? cacheNonce = null ) { + var query = HttpUtility.ParseQueryString(string.Empty); var builder = new UriBuilder(url); var progress = new Progress(); if (transformOptions != null) - builder.Query = transformOptions.ToQueryCollection().ToString(); + query.Add(transformOptions.ToQueryCollection()); + + if (cacheNonce != null) + query.Add("cacheNonce", cacheNonce); + + builder.Query = query.ToString(); if (onProgress != null) progress.ProgressChanged += onProgress; diff --git a/StorageTests/Files/StorageFileApiContractTests.cs b/StorageTests/Files/StorageFileApiContractTests.cs index 7c41628..7d7f9cb 100644 --- a/StorageTests/Files/StorageFileApiContractTests.cs +++ b/StorageTests/Files/StorageFileApiContractTests.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Linq; using System.Collections.Generic; using System.Text; @@ -13,6 +14,7 @@ using WireMock.RequestBuilders; using WireMock.ResponseBuilders; using WireMock.Server; +using FileOptions = Supabase.Storage.FileOptions; namespace StorageTests.Files; @@ -290,6 +292,35 @@ public async Task Download_ShouldGetBytesFromTheObjectPath() } } + [TestMethod] + public async Task Download_ShouldSendTheCacheNonceInTheQuery_GivenACacheNonce() + { + this.server.Given(Request.Create().WithPath($"/storage/v1/object/{Bucket}/a.bin").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Encoding.UTF8.GetBytes("file-bytes"))); + await this.client.From(Bucket).Download("a.bin", (EventHandler?) null, cacheNonce: "nonce-123"); + this.SingleRequest().Query!.Should().ContainKey("cacheNonce") + .WhoseValue.Should().Contain("nonce-123", "the nonce must ride the request so the CDN cache is bypassed"); + } + + [TestMethod] + public async Task Download_ShouldSendTheCacheNonceInTheQuery_GivenACacheNonceAndLocalPath() + { + this.server.Given(Request.Create().WithPath($"/storage/v1/object/{Bucket}/a.bin").UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(Encoding.UTF8.GetBytes("file-bytes"))); + var localPath = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.bin"); + try + { + await this.client.From(Bucket).Download("a.bin", localPath, (EventHandler?) null, cacheNonce: "nonce-123"); + this.SingleRequest().Query!.Should().ContainKey("cacheNonce") + .WhoseValue.Should().Contain("nonce-123", "the to-disk path must carry the nonce just as the byte path does"); + } + finally + { + if (File.Exists(localPath)) + File.Delete(localPath); + } + } + [TestMethod] public async Task PurgeCache_ShouldDeleteTheCdnObjectPathWithNoBodyAndReturnTheMessage() { diff --git a/StorageTests/Options/DownloadOptionsExtensionTests.cs b/StorageTests/Options/DownloadOptionsExtensionTests.cs index c6e7217..5e4c350 100644 --- a/StorageTests/Options/DownloadOptionsExtensionTests.cs +++ b/StorageTests/Options/DownloadOptionsExtensionTests.cs @@ -34,4 +34,11 @@ public void ToQueryCollection_ShouldEmitTheFileName_GivenAName() var query = new DownloadOptions { FileName = "custom-file.png" }.ToQueryCollection(); query["download"].Should().Be("custom-file.png"); } + + [TestMethod] + public void ToQueryCollection_ShouldEmitTheCacheNonce_GivenACacheNonce() + { + var query = new DownloadOptions { CacheNonce = "nonce-123" }.ToQueryCollection(); + query["cacheNonce"].Should().Be("nonce-123"); + } }