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

Changed files
+67 -2
internal
+13 -2
internal/core/unfurl/providers.go
···
return &oembed, nil
}
+
// normalizeURL converts protocol-relative URLs to HTTPS
+
// Examples:
+
// "//example.com/image.jpg" -> "https://example.com/image.jpg"
+
// "https://example.com/image.jpg" -> "https://example.com/image.jpg" (unchanged)
+
func normalizeURL(urlStr string) string {
+
if strings.HasPrefix(urlStr, "//") {
+
return "https:" + urlStr
+
}
+
return urlStr
+
}
+
// mapOEmbedToResult converts oEmbed response to UnfurlResult
func mapOEmbedToResult(oembed *oEmbedResponse, originalURL string) *UnfurlResult {
result := &UnfurlResult{
URI: originalURL,
Title: oembed.Title,
Description: oembed.Description,
-
ThumbnailURL: oembed.ThumbnailURL,
+
ThumbnailURL: normalizeURL(oembed.ThumbnailURL),
Provider: strings.ToLower(oembed.ProviderName),
Domain: extractDomain(originalURL),
Width: oembed.Width,
···
URI: urlStr,
Title: og.Title,
Description: og.Description,
-
ThumbnailURL: og.Image,
+
ThumbnailURL: normalizeURL(og.Image),
Provider: "opengraph",
Domain: extractDomain(urlStr),
}
+54
internal/core/unfurl/providers_test.go
···
+
package unfurl
+
+
import (
+
"testing"
+
+
"github.com/stretchr/testify/assert"
+
)
+
+
func TestNormalizeURL(t *testing.T) {
+
tests := []struct {
+
name string
+
input string
+
expected string
+
}{
+
{
+
name: "protocol-relative URL",
+
input: "//cdn.example.com/image.jpg",
+
expected: "https://cdn.example.com/image.jpg",
+
},
+
{
+
name: "https URL unchanged",
+
input: "https://example.com/image.jpg",
+
expected: "https://example.com/image.jpg",
+
},
+
{
+
name: "http URL unchanged",
+
input: "http://example.com/image.jpg",
+
expected: "http://example.com/image.jpg",
+
},
+
{
+
name: "empty string",
+
input: "",
+
expected: "",
+
},
+
{
+
name: "protocol-relative with query params",
+
input: "//cdn.example.com/image.jpg?width=500&height=300",
+
expected: "https://cdn.example.com/image.jpg?width=500&height=300",
+
},
+
{
+
name: "real Streamable URL",
+
input: "//cdn-cf-east.streamable.com/image/7kpdft.jpg?Expires=1762932720",
+
expected: "https://cdn-cf-east.streamable.com/image/7kpdft.jpg?Expires=1762932720",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
result := normalizeURL(tt.input)
+
assert.Equal(t, tt.expected, result)
+
})
+
}
+
}
+