From bb871f6c71ddd2d5fbec58425fba95b43fc0e0ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 11:35:53 +0000 Subject: [PATCH] fix(blossom): preserve upstream path prefix in xs= hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bridge was emitting `xs=https://cdn.nostr.build` for URLs like `https://cdn.nostr.build/i/.jpg`, dropping the `/i/` prefix. The local cache appends `/` to the xs server hint per spec, so it would try to fetch `https://cdn.nostr.build/` — a 404 on nostr.build's non-Blossom CDN scheme. Anchor the server-base extraction on the slash immediately preceding the sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`. The cache then appends `/` and reaches the original blob. Affects all three bridge entry points: - MediaUrlContent.toCoilModel (note media) - bridgeProfilePictureUrl (profile pictures) - LocalBlossomCacheRedirectInterceptor (everything else via OkHttp) Flat Blossom paths (`/`) keep emitting `xs=` unchanged. --- .../LocalBlossomCacheRedirectInterceptor.kt | 30 +++++++++++---- ...ocalBlossomCacheRedirectInterceptorTest.kt | 14 ++++++- .../commons/richtext/MediaUrlContentExt.kt | 37 +++++++++++++++++-- .../richtext/MediaUrlContentExtTest.kt | 27 +++++++++++++- 4 files changed, 94 insertions(+), 14 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 0ef5f5ffe..1f6afef7e 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 @@ -61,26 +61,42 @@ class LocalBlossomCacheRedirectInterceptor( val host = url.host if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null - val (sha, ext) = findSha256AndExtensionInPath(url) ?: return null - val originalHostBase = "${url.scheme}://$host" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null + val serverBase = buildServerBase(url, shaSegmentIndex) return "$LOCAL_CACHE_BASE/$sha.$ext" .toHttpUrl() .newBuilder() - .addQueryParameter("xs", originalHostBase) + .addQueryParameter("xs", serverBase) .build() } - private fun findSha256AndExtensionInPath(url: HttpUrl): Pair? { - for (segment in url.pathSegments) { - val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue + private fun findSha256AndExtensionInPath(url: HttpUrl): Triple? { + url.pathSegments.forEachIndexed { index, segment -> + val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed val sha = match.value.lowercase() val ext = guessExtensionFrom(segment, sha) ?: "bin" - return sha to ext + return Triple(index, sha, ext) } return null } + /** + * Builds the URL prefix that the local cache should append `/` to. + * Preserves any path prefix the upstream CDN uses (e.g. `/i` for + * `https://cdn.nostr.build/i/`) so the cache can fetch the blob + * from its actual location on miss. + */ + private fun buildServerBase( + url: HttpUrl, + shaSegmentIndex: Int, + ): String { + val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else "" + if (shaSegmentIndex == 0) return origin + val prefixSegments = url.pathSegments.subList(0, shaSegmentIndex) + return "$origin/" + prefixSegments.joinToString("/") + } + private fun guessExtensionFrom( segment: String, sha: String, 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 422a88c78..afaff2dcf 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 @@ -114,7 +114,19 @@ class LocalBlossomCacheRedirectInterceptorTest { val captured = mutableListOf() val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured)) assertEquals( - "http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build", + "http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build%2Fi%2Fcache", + captured.single(), + ) + response.close() + } + + @Test + fun bridgeOnPreservesNostrBuildPathPrefix() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://cdn.nostr.build/i/$sha.jpg", captured)) + assertEquals( + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi", captured.single(), ) response.close() 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 f4c90a25c..78d121b6f 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 @@ -76,11 +76,11 @@ fun bridgeProfilePictureUrl( val sha = extractSha256FromUrlPath(url) ?: return url val ext = guessExtension(url, null) - val hostBase = extractHostBase(url) ?: return url + val serverBase = extractServerBase(url, sha) ?: return url val params = buildList { - add("xs=${percentEncode(hostBase)}") + add("xs=${percentEncode(serverBase)}") authorPubKey ?.lowercase() ?.takeIf { sha256HexRegex.matches(it) } @@ -110,7 +110,7 @@ private fun bridgeUrl( ?: return url val ext = guessExtension(url, mimeType) - val hostBase = extractHostBase(url) ?: return url + val serverBase = extractServerBase(url, sha) ?: return url val authors = authorPubKey @@ -122,7 +122,7 @@ private fun bridgeUrl( return BlossomUri( sha256 = sha, extension = ext, - servers = listOf(hostBase), + servers = listOf(serverBase), authors = authors, size = null, ).toUriString() @@ -172,6 +172,35 @@ private fun guessExtension( return "bin" } +/** + * Returns the URL prefix that the local Blossom cache should append `/` + * to in order to reach the original blob, preserving any path prefix the + * upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's + * `/i/` scheme). Falls back to scheme+host when the sha can't be + * located in the path. + */ +private fun extractServerBase( + url: String, + sha: String, +): String? { + val pathPart = url.substringBefore('?').substringBefore('#') + val schemeEnd = pathPart.indexOf("://") + if (schemeEnd < 0) return null + val hostStart = schemeEnd + 3 + if (hostStart >= pathPart.length) return null + + val shaIndex = pathPart.indexOf(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. + val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1) + if (slashBeforeSha > hostStart - 1) { + return pathPart.substring(0, slashBeforeSha) + } + } + return extractHostBase(pathPart) +} + private fun extractHostBase(url: String): String? { val schemeEnd = url.indexOf("://") if (schemeEnd < 0) return null 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 2e3e16c73..db8240220 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 @@ -61,7 +61,21 @@ class MediaUrlContentExtTest { fun bridgeOnRewritesPlainHttpsUrl() { val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha) val result = image.toCoilModel(useLocalBlossomBridge = true) - assertEquals("blossom:$sha.jpg?xs=https://nostr.build", result) + assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result) + } + + @Test + fun bridgeOnPreservesNostrBuildPathPrefix() { + val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha) + val result = image.toCoilModel(useLocalBlossomBridge = true) + assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result) + } + + @Test + fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() { + val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha) + val result = image.toCoilModel(useLocalBlossomBridge = true) + assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result) } @Test @@ -134,11 +148,20 @@ class MediaUrlContentExtTest { val url = "https://nostr.build/i/$sha.jpg" val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" assertEquals( - "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build&as=$authorPub", + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub", bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), ) } + @Test + fun bridgeProfilePictureUrlPreservesNostrBuildPath() { + val url = "https://cdn.nostr.build/i/$sha.jpg" + assertEquals( + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi", + bridgeProfilePictureUrl(url, useBridge = true), + ) + } + @Test fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() { val url = "https://nostr.build/avatar.jpg"