From 1b287baaf22c71b6da95295f61f3108325fca07b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 10:11:32 +0000 Subject: [PATCH 1/3] fix(blossom-bridge): pick rightmost sha256 segment in URL path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CDNs like share.yabu.me serve blobs under `/.` where both path segments are 64-char hex. The bridge previously locked onto the first match (the cache prefix), dropping the blob segment from `xs` and asking the local cache for a non-existent blob. Walking the path right-to-left makes the rightmost sha — the one that carries the file extension — win, while the prefix segments stay in `xs` so the cache can fetch upstream on miss. Behaviour is unchanged when the path has a single sha; tests cover the new two-hash layout in both the Coil-model path and the OkHttp interceptor path. --- .../LocalBlossomCacheRedirectInterceptor.kt | 9 ++++++-- ...ocalBlossomCacheRedirectInterceptorTest.kt | 15 ++++++++++++ .../commons/richtext/MediaUrlContentExt.kt | 7 ++++-- .../richtext/MediaUrlContentExtTest.kt | 23 +++++++++++++++++++ 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt index 1f6afef7e..3dc9b7229 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -72,8 +72,13 @@ class LocalBlossomCacheRedirectInterceptor( } private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { - url.pathSegments.forEachIndexed { index, segment -> - val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed + // Walk segments right-to-left so CDNs that put a cache prefix (itself a + // 64-char hex segment) ahead of the blob hash — e.g. + // `https://share.yabu.me//.webp` — still resolve to the + // blob and leave the prefix in `xs`. + for (index in url.pathSegments.indices.reversed()) { + val segment = url.pathSegments[index] + val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue val sha = match.value.lowercase() val ext = guessExtensionFrom(segment, sha) ?: "bin" return Triple(index, sha, ext) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index afaff2dcf..489b1b241 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -132,6 +132,21 @@ class LocalBlossomCacheRedirectInterceptorTest { response.close() } + @Test + fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { + // share.yabu.me layout: /. + val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" + val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://share.yabu.me/$prefix/$blob.webp", captured)) + assertEquals( + "http://127.0.0.1:24242/$blob.webp?xs=https%3A%2F%2Fshare.yabu.me%2F$prefix", + captured.single(), + ) + response.close() + } + private fun fakeChain( url: String, captured: MutableList, 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 78d121b6f..6f8d6ce74 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 @@ -145,7 +145,10 @@ private fun percentEncode(input: String): String { private fun extractSha256FromUrlPath(url: String): String? { val pathPart = url.substringBefore('?').substringBefore('#') - val match = sha256InPathRegex.find(pathPart) ?: return null + // Prefer the rightmost 64-char hex segment so CDNs that put a cache prefix + // (itself a 64-char hex) ahead of the blob hash — e.g. + // `https://share.yabu.me//.webp` — still resolve to the blob. + val match = sha256InPathRegex.findAll(pathPart).lastOrNull() ?: return null return match.value.lowercase() } @@ -189,7 +192,7 @@ private fun extractServerBase( val hostStart = schemeEnd + 3 if (hostStart >= pathPart.length) return null - val shaIndex = pathPart.indexOf(sha, ignoreCase = true) + val shaIndex = pathPart.lastIndexOf(sha, ignoreCase = true) if (shaIndex >= 0) { // Anchor on the slash immediately preceding the sha so the cache // can append "/" verbatim per the local-blossom-cache spec. 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 db8240220..853dad9b5 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 @@ -173,4 +173,27 @@ class MediaUrlContentExtTest { val uri = "blossom:$sha.jpg?xs=https://nostr.build" assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true)) } + + @Test + fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { + // share.yabu.me layout: /. + val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" + val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" + val image = MediaUrlImage(url = "https://share.yabu.me/$prefix/$blob.webp", hash = null) + assertEquals( + "blossom:$blob.webp?xs=https://share.yabu.me/$prefix", + image.toCoilModel(useLocalBlossomBridge = true), + ) + } + + @Test + fun bridgeProfilePictureUrlPicksRightmostShaWhenPathHasTwoHashes() { + val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" + val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" + val url = "https://share.yabu.me/$prefix/$blob.webp" + assertEquals( + "http://127.0.0.1:24242/$blob.webp?xs=https%3A%2F%2Fshare.yabu.me%2F$prefix", + bridgeProfilePictureUrl(url, useBridge = true), + ) + } } From 74c2bd2fa86bf59d338453d4af6d87ceecf9ee29 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 10:21:59 +0000 Subject: [PATCH 2/3] test(blossom-bridge): inline the share.yabu.me URL literally Building the URL from extracted prefix/blob constants could mask a parser bug that splits the path the same way the test constructs it. Use the full URL from the bug report as a single literal so the test only agrees with the parser if the parser actually parses the URL correctly. --- ...ocalBlossomCacheRedirectInterceptorTest.kt | 12 +++++++---- .../richtext/MediaUrlContentExtTest.kt | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index 489b1b241..f17c0bea4 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -135,13 +135,17 @@ class LocalBlossomCacheRedirectInterceptorTest { @Test fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { // share.yabu.me layout: /. - val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" - val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" val interceptor = LocalBlossomCacheRedirectInterceptor { true } val captured = mutableListOf() - val response = interceptor.intercept(fakeChain("https://share.yabu.me/$prefix/$blob.webp", captured)) + val response = + interceptor.intercept( + fakeChain( + "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp", + captured, + ), + ) assertEquals( - "http://127.0.0.1:24242/$blob.webp?xs=https%3A%2F%2Fshare.yabu.me%2F$prefix", + "http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", captured.single(), ) response.close() 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 853dad9b5..ee7d28e7b 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 @@ -177,23 +177,25 @@ class MediaUrlContentExtTest { @Test fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { // share.yabu.me layout: /. - val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" - val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" - val image = MediaUrlImage(url = "https://share.yabu.me/$prefix/$blob.webp", hash = null) + val image = + MediaUrlImage( + url = "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp", + hash = null, + ) assertEquals( - "blossom:$blob.webp?xs=https://share.yabu.me/$prefix", + "blossom:28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", image.toCoilModel(useLocalBlossomBridge = true), ) } @Test fun bridgeProfilePictureUrlPicksRightmostShaWhenPathHasTwoHashes() { - val prefix = "84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5" - val blob = "28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5" - val url = "https://share.yabu.me/$prefix/$blob.webp" assertEquals( - "http://127.0.0.1:24242/$blob.webp?xs=https%3A%2F%2Fshare.yabu.me%2F$prefix", - bridgeProfilePictureUrl(url, useBridge = true), + "http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", + bridgeProfilePictureUrl( + "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp", + useBridge = true, + ), ) } } From 3cc9ac8b7c637d8672474443dc80eda60285b236 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 13 May 2026 10:27:26 +0000 Subject: [PATCH 3/3] fix(blossom-bridge): only the last path segment is the blob hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUD-01 defines a Blossom URL as `/[.]` — the blob hash is always the last path segment. Walking the path right-to-left for any hex match was too permissive: a non-Blossom URL like `https://example.com//avatar.jpg` (sha appears in an intermediate segment) would get incorrectly bridged. Both parsers now look at the last path segment only and skip the URL entirely when it isn't a sha256. The earlier hex-prefix case (share.yabu.me's `/.ext`) still works because the blob is still the last segment; the prefix flows into `xs` via the existing `buildServerBase` / `extractServerBase` logic. Adds negative tests covering the sha-in-non-last-segment case in both modules. --- .../LocalBlossomCacheRedirectInterceptor.kt | 24 +++++++++---------- ...ocalBlossomCacheRedirectInterceptorTest.kt | 14 ++++++++++- .../commons/richtext/MediaUrlContentExt.kt | 10 ++++---- .../richtext/MediaUrlContentExtTest.kt | 19 +++++++++++++-- 4 files changed, 48 insertions(+), 19 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt index 3dc9b7229..6da1cdff5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -72,18 +72,18 @@ class LocalBlossomCacheRedirectInterceptor( } private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { - // Walk segments right-to-left so CDNs that put a cache prefix (itself a - // 64-char hex segment) ahead of the blob hash — e.g. - // `https://share.yabu.me//.webp` — still resolve to the - // blob and leave the prefix in `xs`. - for (index in url.pathSegments.indices.reversed()) { - val segment = url.pathSegments[index] - val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue - val sha = match.value.lowercase() - val ext = guessExtensionFrom(segment, sha) ?: "bin" - return Triple(index, sha, ext) - } - return null + // Per Blossom (BUD-01) the blob is always the last path segment. If the + // last segment isn't a sha256, this isn't a Blossom URL and the bridge + // must leave it alone — even if an earlier path segment happens to be + // a 64-char hex (e.g. a per-user cache prefix). The prefix segments + // are preserved verbatim via `buildServerBase`. + val lastIndex = url.pathSegments.lastIndex + if (lastIndex < 0) return null + val segment = url.pathSegments[lastIndex] + val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null + val sha = match.value.lowercase() + val ext = guessExtensionFrom(segment, sha) ?: "bin" + return Triple(lastIndex, sha, ext) } /** diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt index f17c0bea4..2edbe3c55 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -133,7 +133,7 @@ class LocalBlossomCacheRedirectInterceptorTest { } @Test - fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { + fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() { // share.yabu.me layout: /. val interceptor = LocalBlossomCacheRedirectInterceptor { true } val captured = mutableListOf() @@ -151,6 +151,18 @@ class LocalBlossomCacheRedirectInterceptorTest { response.close() } + @Test + fun bridgeOnSkipsWhenLastSegmentIsNotSha() { + // Per BUD-01 the last segment is the blob; if it isn't a sha256, the + // URL isn't a Blossom blob even if an earlier segment is hex. + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val url = "https://example.com/$sha/avatar.jpg" + val response = interceptor.intercept(fakeChain(url, captured)) + assertEquals(url, captured.single()) + response.close() + } + private fun fakeChain( url: String, captured: MutableList, 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 6f8d6ce74..fd203d426 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 @@ -144,11 +144,13 @@ private fun percentEncode(input: String): String { } private fun extractSha256FromUrlPath(url: String): String? { + // Per Blossom (BUD-01) the blob is always the last path segment. If the + // last segment isn't a sha256, this isn't a Blossom URL and the bridge + // must leave it alone — even if an earlier path segment happens to be + // a 64-char hex (e.g. a per-user cache prefix). val pathPart = url.substringBefore('?').substringBefore('#') - // Prefer the rightmost 64-char hex segment so CDNs that put a cache prefix - // (itself a 64-char hex) ahead of the blob hash — e.g. - // `https://share.yabu.me//.webp` — still resolve to the blob. - val match = sha256InPathRegex.findAll(pathPart).lastOrNull() ?: return null + val lastSegment = pathPart.substringAfterLast('/') + val match = sha256InPathRegex.find(lastSegment) ?: return null return match.value.lowercase() } 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 ee7d28e7b..ce50e3e0c 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 @@ -175,7 +175,7 @@ class MediaUrlContentExtTest { } @Test - fun bridgeOnPicksRightmostShaWhenPathHasTwoHashes() { + fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() { // share.yabu.me layout: /. val image = MediaUrlImage( @@ -189,7 +189,7 @@ class MediaUrlContentExtTest { } @Test - fun bridgeProfilePictureUrlPicksRightmostShaWhenPathHasTwoHashes() { + fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() { assertEquals( "http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5", bridgeProfilePictureUrl( @@ -198,4 +198,19 @@ class MediaUrlContentExtTest { ), ) } + + @Test + fun bridgeOnSkipsWhenLastSegmentIsNotSha() { + // Per BUD-01 the last segment is the blob; if it isn't a sha256, the + // URL isn't a Blossom blob even if an earlier segment is hex. + val url = "https://example.com/$sha/avatar.jpg" + val image = MediaUrlImage(url = url, hash = null) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } + + @Test + fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() { + val url = "https://example.com/$sha/avatar.jpg" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } }