Merge pull request #2959 from greenart7c3/claude/fix-blossom-cache-url-8zolt

fix(blossom): only bridge to local cache when URL is BUD-01 layout
This commit is contained in:
Vitor Pamplona
2026-05-18 08:49:22 -04:00
committed by GitHub
2 changed files with 36 additions and 10 deletions
@@ -104,13 +104,20 @@ private fun bridgeUrl(
if (url.startsWith("blossom:", ignoreCase = true)) return url if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha = // The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } // which only works when the upstream URL is itself BUD-01 layout. For
?: extractSha256FromUrlPath(url) // non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x`
?: return url // hash identifies the blob but the upstream server doesn't host it at
// /<sha>.<ext>, so trusting only `explicitHash` would point the cache
// at a 404. Require the sha to be in the URL path before bridging.
val urlSha = extractSha256FromUrlPath(url) ?: return url
// Prefer the imeta hash when it's a valid sha256 (authoritative casing),
// otherwise fall back to what was parsed from the URL.
val sha = explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } ?: urlSha
val ext = guessExtension(url, mimeType) val ext = guessExtension(url, mimeType)
val serverBase = extractServerBase(url, sha) ?: return url val serverBase = extractServerBase(url, urlSha) ?: return url
val authors = val authors =
authorPubKey authorPubKey
@@ -80,14 +80,15 @@ class MediaUrlContentExtTest {
@Test @Test
fun bridgeOnInfersExtensionFromMimeType() { fun bridgeOnInfersExtensionFromMimeType() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png") // BUD-01 allows `<sha>` without an extension; mimeType supplies one.
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
val result = image.toCoilModel(useLocalBlossomBridge = true) val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result") assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
} }
@Test @Test
fun bridgeOnFallsBackToBinExtension() { fun bridgeOnFallsBackToBinExtension() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha) val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true) val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result") assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
} }
@@ -100,7 +101,7 @@ class MediaUrlContentExtTest {
@Test @Test
fun uppercaseHashNormalisedToLowercase() { fun uppercaseHashNormalisedToLowercase() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = sha.uppercase()) val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
val result = image.toCoilModel(useLocalBlossomBridge = true) val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result") assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
} }
@@ -110,7 +111,7 @@ class MediaUrlContentExtTest {
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
val image = val image =
MediaUrlImage( MediaUrlImage(
url = "https://cdn.example.com/foo.jpg", url = "https://cdn.example.com/$sha.jpg",
hash = sha, hash = sha,
authorPubKey = authorPub, authorPubKey = authorPub,
) )
@@ -122,7 +123,7 @@ class MediaUrlContentExtTest {
fun invalidAuthorPubKeyDropped() { fun invalidAuthorPubKeyDropped() {
val image = val image =
MediaUrlImage( MediaUrlImage(
url = "https://cdn.example.com/foo.jpg", url = "https://cdn.example.com/$sha.jpg",
hash = sha, hash = sha,
authorPubKey = "not-a-pubkey", authorPubKey = "not-a-pubkey",
) )
@@ -130,6 +131,24 @@ class MediaUrlContentExtTest {
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result) assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
} }
@Test
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
// The imeta `x` hash refers to a blob whose canonical Blossom location
// is /<sha>.<ext>, but the upstream URL serves it under a different
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
// local cache would set xs=https://i.nostr.build, and the cache would
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
val url = "https://i.nostr.build/M5AwJ.gif"
val image = MediaUrlImage(url = url, hash = sha)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
val url = "https://i.nostr.build/M5AwJ.gif"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test @Test
fun bridgeProfilePictureUrlNullReturnsNull() { fun bridgeProfilePictureUrlNullReturnsNull() {
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true)) assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))