Skip to content

GetPublicUrl with an empty TransformOptions routes to the render endpoint instead of the object URL #37

Description

@Tr00d

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:

  1. Have a public bucket with an object, e.g. lot-images/1.jpg.
  2. Build a URL with an empty TransformOptions:
    var url = storage.From("lot-images").GetPublicUrl("1.jpg", new TransformOptions());
  3. 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
    
  4. 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:

  1. 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#.
  2. 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).

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

Fields

No fields configured for Bug.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions