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

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions