From 62d97c6d82141623e4005e8d75be61b7484f85b1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 15 May 2026 22:32:01 +0000 Subject: [PATCH] fix(blossom): require sha256 at start of last path segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URLs like https://nostr.build/i/nostr.build_.jpg embed a 64-char hex in the filename but aren't BUD-01 Blossom blobs — the last path segment must be exactly or .. The previous regex matched the embedded hash and rewrote the request to the local cache with xs=https://nostr.build/i, which 404s on miss because the real blob lives at /i/nostr.build_.jpg, not /i/. Switch both extraction sites (MediaUrlContentExt and the OkHttp interceptor) to a matchEntire regex that anchors the sha at the start of the segment with at most a . suffix. --- .../LocalBlossomCacheRedirectInterceptor.kt | 17 +++++++------ ...ocalBlossomCacheRedirectInterceptorTest.kt | 25 +++++++++++++++++++ .../commons/richtext/MediaUrlContentExt.kt | 15 +++++------ .../richtext/MediaUrlContentExtTest.kt | 23 +++++++++++++++++ 4 files changed, 65 insertions(+), 15 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 6da1cdff5a..c8f6be168e 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,16 +72,17 @@ class LocalBlossomCacheRedirectInterceptor( } private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { - // 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`. + // Per Blossom (BUD-01) the last path segment must be exactly + // `` or `.`. URLs whose filename merely embeds a + // 64-char hex (e.g. "nostr.build_.jpg") aren't Blossom blobs and + // the bridge must leave them alone — rewriting them would point the + // local cache at a fallback `xs=` server that doesn't host the blob. + // 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 match = BLOSSOM_LAST_SEGMENT_REGEX.matchEntire(segment) ?: return null + val sha = match.groupValues[1].lowercase() val ext = guessExtensionFrom(segment, sha) ?: "bin" return Triple(lastIndex, sha, ext) } @@ -119,6 +120,6 @@ class LocalBlossomCacheRedirectInterceptor( const val LOCAL_CACHE_HOST = "127.0.0.1" const val LOCAL_CACHE_PORT = 24242 const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT" - private val SHA256_SEGMENT_REGEX = Regex("(?_.. The hex inside the + // filename isn't a Blossom blob — rewriting to the local cache and + // sending xs=https://nostr.build/i would 404 because the real blob + // is at /i/nostr.build_.jpg, not /i/. + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + val response = interceptor.intercept(fakeChain(url, captured)) + assertEquals(url, captured.single()) + response.close() + } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() { + // A filename like _thumb.jpg isn't a BUD-01 blob URL either. + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val url = "https://example.com/${sha}_thumb.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 fd203d4262..c00ed10519 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 @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.richtext import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri private val sha256HexRegex = Regex("[0-9a-f]{64}") -private val sha256InPathRegex = Regex("(?` or `.`. URLs whose filename merely embeds a + // 64-char hex (e.g. "nostr.build_.jpg") aren't Blossom blobs and + // the bridge must leave them alone — rewriting them would point the + // local cache at a fallback `xs=` server that doesn't host the blob. val pathPart = url.substringBefore('?').substringBefore('#') val lastSegment = pathPart.substringAfterLast('/') - val match = sha256InPathRegex.find(lastSegment) ?: return null - return match.value.lowercase() + val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null + return match.groupValues[1].lowercase() } private fun guessExtension( 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 ce50e3e0c3..19301c0dc5 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 @@ -213,4 +213,27 @@ class MediaUrlContentExtTest { val url = "https://example.com/$sha/avatar.jpg" assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { + // nostr.build /i/ layout: _.. The hex inside the + // filename isn't a Blossom blob per BUD-01 — the last segment must + // be exactly or .. + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + val image = MediaUrlImage(url = url, hash = null) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } + + @Test + fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() { + val url = "https://nostr.build/i/nostr.build_$sha.jpg" + assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) + } + + @Test + fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() { + val url = "https://example.com/${sha}_thumb.jpg" + val image = MediaUrlImage(url = url, hash = null) + assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true)) + } }