From 657980ac1f43f368cbdbc5052637ce49054e9157 Mon Sep 17 00:00:00 2001 From: TalLevAmi Date: Sun, 27 Sep 2026 06:56:15 +0000 Subject: [PATCH] Fix URL encoding of spaces, `+`, `%` and `//` in public IDs - `smartEscape` used `url.QueryEscape`, which writes a space as `+`. In a URL path, `+` is a literal plus, so a public ID with a space gave a 404. Write the space as `%20`. - `source()` changed `%20` to `+` and called `url.QueryUnescape`, which read a literal `+` as a space and panicked on a lone `%`. Use `url.PathUnescape`, and keep the ID as is when it is not valid escaping. - `//` in a transformation or a public ID was not collapsed, so the URL and its signature differed from the other SDKs and signed URLs got a 401. Collapse it in `String()`, but keep `://`. Co-Authored-By: Claude Opus 5.5 --- asset/asset.go | 20 +++++++++++++++----- asset/asset_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/asset/asset.go b/asset/asset.go index f11871e..a56e247 100644 --- a/asset/asset.go +++ b/asset/asset.go @@ -96,6 +96,9 @@ func (a Asset) String() (result string, err error) { } }() + a.Transformation = transformation.RawTransformation(collapseSlashes(string(a.Transformation))) + a.PublicID = collapseSlashes(a.PublicID) + assetURL := a.assetURL() query := a.query() @@ -268,10 +271,9 @@ func (a Asset) source() string { source := fileNameWithoutExt(a.PublicID) if !isURL(source) { - var err error - source, err = url.QueryUnescape(strings.Replace(source, "%20", "+", -1)) - if err != nil { - panic(err) + // Decode a pre-escaped public ID. Keep it as is if it is not valid escaping (for example, a literal "%"). + if unescaped, err := url.PathUnescape(source); err == nil { + source = unescaped } } @@ -339,8 +341,16 @@ func isURL(candidate string) bool { return urlRegexp.MatchString(candidate) } +var multipleSlashesRegexp = regexp.MustCompile(`([^:])/+`) + +// collapseSlashes replaces "//" with "/", but keeps "://". +func collapseSlashes(str string) string { + return multipleSlashesRegexp.ReplaceAllString(str, "$1/") +} + func smartEscape(str string) string { - revert := strings.NewReplacer("%3A", ":", "%2F", "/") + // QueryEscape encodes a space as "+" (a literal "+" becomes "%2B"). In a URL path, a space must be "%20". + revert := strings.NewReplacer("%3A", ":", "%2F", "/", "+", "%20") return revert.Replace(url.QueryEscape(str)) } diff --git a/asset/asset_test.go b/asset/asset_test.go index 8ef61d9..f94b669 100644 --- a/asset/asset_test.go +++ b/asset/asset_test.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/cloudinary/cloudinary-go/v2/api" "github.com/cloudinary/cloudinary-go/v2/asset" + "github.com/cloudinary/cloudinary-go/v2/config" "github.com/cloudinary/cloudinary-go/v2/internal/cldtest" "github.com/stretchr/testify/assert" "testing" @@ -108,3 +109,37 @@ func TestAsset_Media(t *testing.T) { } assert.Contains(t, getAssetUrl(t, m), fmt.Sprintf("image/upload/%s", cldtest.PublicID)) } + +// Expected URLs match the Python SDK (cloudinary_url with raw_transformation). +func TestAsset_PublicIDEncoding(t *testing.T) { + conf, _ := config.NewFromParams(cldtest.CloudName, cldtest.APIKey, cldtest.APISecret) + + cases := []struct{ publicID, expected string }{ + {"my image", "image/upload/my%20image"}, + {"a+b", "image/upload/a%2Bb"}, + {"100%", "image/upload/100%25"}, + {"a%20b", "image/upload/a%20b"}, + {"a%2Bb", "image/upload/a%2Bb"}, + } + + for _, c := range cases { + i, _ := asset.Image(c.publicID, conf) + assert.Contains(t, getAssetUrl(t, i), c.expected, c.publicID) + } +} + +func TestAsset_CollapseSlashes(t *testing.T) { + conf, _ := config.NewFromParams(cldtest.CloudName, cldtest.APIKey, cldtest.APISecret) + + i, _ := asset.Image("folder//img", conf) + i.Transformation = "w_100//e_sepia" + + assert.Contains(t, getAssetUrl(t, i), "image/upload/w_100/e_sepia/v1/folder/img") + + s, _ := asset.Image("sample", conf) + s.DeliveryType = api.Authenticated + s.Transformation = "w_100//e_sepia" + s.Config.URL.SignURL = true + + assert.Contains(t, getAssetUrl(t, s), "image/authenticated/s--15JMHXkE--/w_100/e_sepia/sample") +}