Bug report
Describe the bug
GetPublicUrl(path, new TransformOptions()) looks like "give me the URL, no transforms", but passing a non-null but empty TransformOptions routes to the image-render endpoint instead of the plain object URL, and silently applies default transforms.
The method branches purely on transformOptions == null:
// Storage/StorageFileApi.cs — GetPublicUrl
if (transformOptions == null)
{
// ...
return $"{Url}/object/public/{GetFinalPath(path)}?{queryParamsString}";
}
queryParams.Add(transformOptions.ToQueryCollection());
var builder = new UriBuilder($"{Url}/render/image/public/{GetFinalPath(path)}")
// ...
A freshly constructed new TransformOptions() is non-null, so it takes the render branch. And its defaults are not "no-ops":
// Storage/TransformOptions.cs
public ResizeType Resize { get; set; } = ResizeType.Cover; // -> resize=cover
public int Quality { get; set; } = 80; // -> quality=80
public string Format { get; set; } = "origin"; // -> format=origin
ToQueryCollection() always emits resize and quality (and format when non-null), so an empty options object yields …/render/image/public/<path>?format=origin&resize=cover&quality=80.
Why it bites: the buggy form is the one that looks correct. Locally the render service (imgproxy) is stopped by default, so that URL simply fails (broken image); in production it silently applies transforms the caller never asked for.
To Reproduce
Steps to reproduce the behavior, please provide code snippets or a repository:
- Have a public bucket with an object, e.g.
lot-images/1.jpg.
- Build a URL with an empty
TransformOptions:
var url = storage.From("lot-images").GetPublicUrl("1.jpg", new TransformOptions());
- Inspect
url — it points at the render endpoint with default transforms:
.../storage/v1/render/image/public/lot-images/1.jpg?format=origin&resize=cover&quality=80
- On a default local stack (imgproxy not running) the URL fails to load; in production it returns a transformed image rather than the original.
Expected behavior
An empty TransformOptions (no width/height/quality/format explicitly set) should be treated as "no transform" and return the plain object URL:
.../storage/v1/object/public/lot-images/1.jpg
i.e. the render endpoint should only be selected when the caller actually requests a transform. Equivalently, the gate should be "any transform field set" rather than "options object is non-null".
Screenshots
N/A — reproducible from the URL string above.
System information
- OS: macOS (Darwin); reproducible cross-platform
- Browser (if applies): N/A
- Version of the SDK:
Supabase.Storage 2.0.2 (via umbrella Supabase 1.1.1); current master has the same branch
- Runtime: .NET 10
Additional context
Workaround: use the no-options overload GetPublicUrl(path) (passes transformOptions = null), which takes the object/public branch.
JS SDK parity reference. @supabase/storage-js's getPublicUrl (src/packages/StorageFileApi.ts) does not branch on null — it only picks the render path when the transform object actually has keys:
// @supabase/storage-js — StorageFileApi.getPublicUrl()
const wantsTransformation =
typeof options?.transform === 'object' &&
options.transform !== null &&
Object.keys(options.transform).length > 0 // empty object ⇒ NOT a transform
const renderPath = wantsTransformation ? 'render/image' : 'object'
So in JS, getPublicUrl(path, { transform: {} }) returns the plain object URL; only a populated transform routes to render/image. The same Object.keys(...).length > 0 guard is used in download and createSignedUrl.
Two differences compound to produce the C# bug, where either alone is sufficient:
- Branch condition. C# selects render on
transformOptions != null; JS selects it on "transform object with ≥1 key". An empty options object is a no-op in JS, a render request in C#.
- Defaults. JS
TransformOptions is a bare interface — all fields optional, no runtime defaults (the "defaults to cover/80" live in the server, not on the object), so {} truly has zero keys. C# TransformOptions is a class with Resize = ResizeType.Cover and Quality = 80 initializers that always serialize, so a "fresh" instance is never empty and always emits resize=cover&quality=80.
Suggested fix: mirror the JS guard — select the render endpoint only when at least one transform field is actually set (and/or drop the non-null Resize/Quality defaults on TransformOptions so an unconfigured instance is genuinely empty).
References:
- C# (this SDK):
Storage/StorageFileApi.cs (GetPublicUrl), Storage/TransformOptions.cs (Resize/Quality defaults), Storage/Extensions/TransformOptionsExtension.cs. Related to supabase#249.
- JS (parity target):
@supabase/storage-js src/packages/StorageFileApi.ts (getPublicUrl, download, createSignedUrl — the wantsTransformation guard) and src/lib/types.ts (TransformOptions interface, no runtime defaults).
Bug report
Describe the bug
GetPublicUrl(path, new TransformOptions())looks like "give me the URL, no transforms", but passing a non-null but emptyTransformOptionsroutes to the image-render endpoint instead of the plain object URL, and silently applies default transforms.The method branches purely on
transformOptions == null:A freshly constructed
new TransformOptions()is non-null, so it takes the render branch. And its defaults are not "no-ops":ToQueryCollection()always emitsresizeandquality(andformatwhen non-null), so an empty options object yields…/render/image/public/<path>?format=origin&resize=cover&quality=80.Why it bites: the buggy form is the one that looks correct. Locally the render service (imgproxy) is stopped by default, so that URL simply fails (broken image); in production it silently applies transforms the caller never asked for.
To Reproduce
Steps to reproduce the behavior, please provide code snippets or a repository:
lot-images/1.jpg.TransformOptions:url— it points at the render endpoint with default transforms:Expected behavior
An empty
TransformOptions(no width/height/quality/format explicitly set) should be treated as "no transform" and return the plain object URL:i.e. the render endpoint should only be selected when the caller actually requests a transform. Equivalently, the gate should be "any transform field set" rather than "options object is non-null".
Screenshots
N/A — reproducible from the URL string above.
System information
Supabase.Storage2.0.2 (via umbrellaSupabase1.1.1); currentmasterhas the same branchAdditional context
Workaround: use the no-options overload
GetPublicUrl(path)(passestransformOptions = null), which takes theobject/publicbranch.JS SDK parity reference.
@supabase/storage-js'sgetPublicUrl(src/packages/StorageFileApi.ts) does not branch on null — it only picks the render path when the transform object actually has keys:So in JS,
getPublicUrl(path, { transform: {} })returns the plainobjectURL; only a populated transform routes torender/image. The sameObject.keys(...).length > 0guard is used indownloadandcreateSignedUrl.Two differences compound to produce the C# bug, where either alone is sufficient:
transformOptions != null; JS selects it on "transform object with ≥1 key". An empty options object is a no-op in JS, a render request in C#.TransformOptionsis a bare interface — all fields optional, no runtime defaults (the "defaults to cover/80" live in the server, not on the object), so{}truly has zero keys. C#TransformOptionsis a class withResize = ResizeType.CoverandQuality = 80initializers that always serialize, so a "fresh" instance is never empty and always emitsresize=cover&quality=80.Suggested fix: mirror the JS guard — select the render endpoint only when at least one transform field is actually set (and/or drop the non-null
Resize/Qualitydefaults onTransformOptionsso an unconfigured instance is genuinely empty).References:
Storage/StorageFileApi.cs(GetPublicUrl),Storage/TransformOptions.cs(Resize/Qualitydefaults),Storage/Extensions/TransformOptionsExtension.cs. Related to supabase#249.@supabase/storage-jssrc/packages/StorageFileApi.ts(getPublicUrl,download,createSignedUrl— thewantsTransformationguard) andsrc/lib/types.ts(TransformOptionsinterface, no runtime defaults).