A community based topic aggregation platform built on atproto

fix: normalize protocol-relative URLs in unfurl thumbnail URLs

Streamable and other providers return protocol-relative URLs (//cdn.example.com)
in their oEmbed thumbnail_url field. These fail when passed to the blob service
because http.NewRequest requires a full URL with scheme.

Fix:
- Add normalizeURL() helper to convert // → https://
- Apply to both oEmbed and OpenGraph thumbnail URLs
- Add comprehensive tests (6 test cases)

Example transformation:
//cdn-cf-east.streamable.com/image/abc.jpg
→ https://cdn-cf-east.streamable.com/image/abc.jpg

This ensures Streamable video thumbnails are properly downloaded and uploaded
as blobs, fixing missing thumbnails in video embeds.

Affects: All users posting Streamable/YouTube/Reddit links (not Kagi-specific)
Tested: Unit tests pass, build successful

+67 -2
+13 -2
internal/core/unfurl/providers.go
··· 106 return &oembed, nil 107 } 108 109 // mapOEmbedToResult converts oEmbed response to UnfurlResult 110 func mapOEmbedToResult(oembed *oEmbedResponse, originalURL string) *UnfurlResult { 111 result := &UnfurlResult{ 112 URI: originalURL, 113 Title: oembed.Title, 114 Description: oembed.Description, 115 - ThumbnailURL: oembed.ThumbnailURL, 116 Provider: strings.ToLower(oembed.ProviderName), 117 Domain: extractDomain(originalURL), 118 Width: oembed.Width, ··· 186 URI: urlStr, 187 Title: og.Title, 188 Description: og.Description, 189 - ThumbnailURL: og.Image, 190 Provider: "opengraph", 191 Domain: extractDomain(urlStr), 192 }
··· 106 return &oembed, nil 107 } 108 109 + // normalizeURL converts protocol-relative URLs to HTTPS 110 + // Examples: 111 + // "//example.com/image.jpg" -> "https://example.com/image.jpg" 112 + // "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged) 113 + func normalizeURL(urlStr string) string { 114 + if strings.HasPrefix(urlStr, "//") { 115 + return "https:" + urlStr 116 + } 117 + return urlStr 118 + } 119 + 120 // mapOEmbedToResult converts oEmbed response to UnfurlResult 121 func mapOEmbedToResult(oembed *oEmbedResponse, originalURL string) *UnfurlResult { 122 result := &UnfurlResult{ 123 URI: originalURL, 124 Title: oembed.Title, 125 Description: oembed.Description, 126 + ThumbnailURL: normalizeURL(oembed.ThumbnailURL), 127 Provider: strings.ToLower(oembed.ProviderName), 128 Domain: extractDomain(originalURL), 129 Width: oembed.Width, ··· 197 URI: urlStr, 198 Title: og.Title, 199 Description: og.Description, 200 + ThumbnailURL: normalizeURL(og.Image), 201 Provider: "opengraph", 202 Domain: extractDomain(urlStr), 203 }
+54
internal/core/unfurl/providers_test.go
···
··· 1 + package unfurl 2 + 3 + import ( 4 + "testing" 5 + 6 + "github.com/stretchr/testify/assert" 7 + ) 8 + 9 + func TestNormalizeURL(t *testing.T) { 10 + tests := []struct { 11 + name string 12 + input string 13 + expected string 14 + }{ 15 + { 16 + name: "protocol-relative URL", 17 + input: "//cdn.example.com/image.jpg", 18 + expected: "https://cdn.example.com/image.jpg", 19 + }, 20 + { 21 + name: "https URL unchanged", 22 + input: "https://example.com/image.jpg", 23 + expected: "https://example.com/image.jpg", 24 + }, 25 + { 26 + name: "http URL unchanged", 27 + input: "http://example.com/image.jpg", 28 + expected: "http://example.com/image.jpg", 29 + }, 30 + { 31 + name: "empty string", 32 + input: "", 33 + expected: "", 34 + }, 35 + { 36 + name: "protocol-relative with query params", 37 + input: "//cdn.example.com/image.jpg?width=500&height=300", 38 + expected: "https://cdn.example.com/image.jpg?width=500&height=300", 39 + }, 40 + { 41 + name: "real Streamable URL", 42 + input: "//cdn-cf-east.streamable.com/image/7kpdft.jpg?Expires=1762932720", 43 + expected: "https://cdn-cf-east.streamable.com/image/7kpdft.jpg?Expires=1762932720", 44 + }, 45 + } 46 + 47 + for _, tt := range tests { 48 + t.Run(tt.name, func(t *testing.T) { 49 + result := normalizeURL(tt.input) 50 + assert.Equal(t, tt.expected, result) 51 + }) 52 + } 53 + } 54 +