diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt index c00ed1051..29d750d34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExt.kt @@ -104,13 +104,20 @@ private fun bridgeUrl( if (url.startsWith("blossom:", ignoreCase = true)) return url if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url - val sha = - explicitHash?.lowercase()?.takeIf { sha256HexRegex.matches(it) } - ?: extractSha256FromUrlPath(url) - ?: return url + // The local Blossom cache fetches `/.` on miss per BUD-01, + // which only works when the upstream URL is itself BUD-01 layout. For + // non-BUD-01 URLs (e.g. https://i.nostr.build/M5AwJ.gif) the imeta `x` + // hash identifies the blob but the upstream server doesn't host it at + // /., 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 serverBase = extractServerBase(url, sha) ?: return url + val serverBase = extractServerBase(url, urlSha) ?: return url val authors = authorPubKey diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt index 19301c0dc..84dcf80c8 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaUrlContentExtTest.kt @@ -80,14 +80,15 @@ class MediaUrlContentExtTest { @Test fun bridgeOnInfersExtensionFromMimeType() { - val image = MediaUrlImage(url = "https://nostr.build/i/abc", hash = sha, mimeType = "image/png") + // BUD-01 allows `` 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) assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result") } @Test 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) assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result") } @@ -100,7 +101,7 @@ class MediaUrlContentExtTest { @Test 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) assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result") } @@ -110,7 +111,7 @@ class MediaUrlContentExtTest { val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" val image = MediaUrlImage( - url = "https://cdn.example.com/foo.jpg", + url = "https://cdn.example.com/$sha.jpg", hash = sha, authorPubKey = authorPub, ) @@ -122,7 +123,7 @@ class MediaUrlContentExtTest { fun invalidAuthorPubKeyDropped() { val image = MediaUrlImage( - url = "https://cdn.example.com/foo.jpg", + url = "https://cdn.example.com/$sha.jpg", hash = sha, authorPubKey = "not-a-pubkey", ) @@ -130,6 +131,24 @@ class MediaUrlContentExtTest { 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 /., 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/.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 fun bridgeProfilePictureUrlNullReturnsNull() { assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))