From 9451f7ca9a63300071ce25a794e68b9dcd152e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 10:02:08 +0000 Subject: [PATCH] feat(blossom): catch all sha256-keyed URLs via OkHttp interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pictures hosted on Blossom servers (URLs with a 64-char sha256 hex in the path) bypassed the bridge whenever they were rendered through a composable that wasn't explicitly updated to call toCoilModel — link previews, video thumbnails, badge images, audio room covers, etc. Add LocalBlossomCacheRedirectInterceptor as an app-level OkHttp interceptor that catches every HTTP request transparently: - Detects a 64-char hex segment in any path segment - Rewrites the request URL to http://127.0.0.1:24242/.?xs= - Coil's disk cache continues to key by the original URL so cached blobs survive toggling the bridge off Activates only when the master toggle is on, the profile-pictures-only restriction is off, and the probe sees localhost up. Profile-only mode still routes profile pics via the existing composable bridge. --- .../com/vitorpamplona/amethyst/AppModules.kt | 11 +- .../service/okhttp/DualHttpClientManager.kt | 3 +- .../LocalBlossomCacheRedirectInterceptor.kt | 103 +++++++++++ .../service/okhttp/OkHttpClientFactory.kt | 12 ++ ...ocalBlossomCacheRedirectInterceptorTest.kt | 169 ++++++++++++++++++ 5 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 764240d34..b612b71f5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -216,7 +216,7 @@ class AppModules( val dnsStore = SurgeDnsStore(appContext, surgeDns) // manages all the other connections separately from relays. - val okHttpClients = + val okHttpClients: DualHttpClientManager = DualHttpClientManager( userAgent = appAgent, proxyPortProvider = torManager.activePortOrNull, @@ -224,6 +224,15 @@ class AppModules( keyCache = keyCache, scope = applicationIOScope, dns = surgeDns, + // Transparently rewrites sha256-keyed HTTP requests to the local + // Blossom cache when the master toggle is on, the profile-pictures-only + // restriction is off, and the probe sees 127.0.0.1:24242 as available. + shouldBridgeBlossomCache = { + val settings = sessionManager.loggedInAccount()?.settings + val master = settings?.useLocalBlossomCache?.value ?: false + val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false + master && !profileOnly && localBlossomCacheProbe.available.value + }, ) // Offers easy methods to know when connections are happening through Tor or not diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt index 32679800f..7cb4d3362 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/DualHttpClientManager.kt @@ -39,8 +39,9 @@ class DualHttpClientManager( keyCache: EncryptionKeyCache, scope: CoroutineScope, dns: SurgeDns, + shouldBridgeBlossomCache: (() -> Boolean)? = null, ) : IHttpClientManager { - val factory = OkHttpClientFactory(keyCache, userAgent, dns) + val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache) val defaultHttpClient: StateFlow = combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile -> 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 new file mode 100644 index 000000000..0ef5f5ffe --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptor.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * App-wide OkHttp interceptor that transparently rewrites HTTP requests for + * sha256-keyed blobs to a local Blossom cache running on `127.0.0.1:24242`, + * per https://github.com/hzrd149/blossom/blob/master/implementations/local-blossom-cache.md + * + * Activates when [shouldBridge] returns `true` AND the request URL contains + * a 64-char hex sha256 segment in its path AND the host isn't already + * `127.0.0.1`/`localhost`. The original scheme+host is appended as a `xs=` + * proxy hint so the cache can fetch upstream on miss. + * + * Coil's disk cache keys responses by the original `ImageRequest.data`, so + * disk caching continues to work transparently even though the network + * request now goes to localhost. + */ +class LocalBlossomCacheRedirectInterceptor( + private val shouldBridge: () -> Boolean, +) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (!shouldBridge()) return chain.proceed(request) + + val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request) + + return chain.proceed( + request + .newBuilder() + .url(rewritten) + .build(), + ) + } + + private fun rewriteIfApplicable(url: HttpUrl): HttpUrl? { + 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 "" + + return "$LOCAL_CACHE_BASE/$sha.$ext" + .toHttpUrl() + .newBuilder() + .addQueryParameter("xs", originalHostBase) + .build() + } + + private fun findSha256AndExtensionInPath(url: HttpUrl): Pair? { + for (segment in url.pathSegments) { + val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue + val sha = match.value.lowercase() + val ext = guessExtensionFrom(segment, sha) ?: "bin" + return sha to ext + } + return null + } + + private fun guessExtensionFrom( + segment: String, + sha: String, + ): String? { + val idx = segment.indexOf(sha, ignoreCase = true) + val after = if (idx >= 0) segment.substring(idx + sha.length) else return null + if (!after.startsWith('.')) return null + val rest = after.substring(1).lowercase() + if (rest.isEmpty() || rest.length > 8) return null + if (!rest.all { it.isLetterOrDigit() }) return null + return rest + } + + companion object { + 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("(? Boolean)? = null, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) + private val blossomCacheRedirect = + shouldBridgeBlossomCache?.let { LocalBlossomCacheRedirectInterceptor(it) } // Most images/videos in a feed come from a small set of hosts (e.g. a single // Blossom/imgproxy server). OkHttp's default dispatcher caps inflight requests @@ -69,6 +78,9 @@ class OkHttpClientFactory( .followRedirects(true) .followSslRedirects(true) .addInterceptor(DefaultContentTypeInterceptor(userAgent)) + .apply { + blossomCacheRedirect?.let { addInterceptor(it) } + } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) .build() 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 new file mode 100644 index 000000000..422a88c78 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/LocalBlossomCacheRedirectInterceptorTest.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.okhttp + +import okhttp3.Connection +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Test + +class LocalBlossomCacheRedirectInterceptorTest { + private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553" + + @Test + fun bridgeOffPassesThrough() { + val interceptor = LocalBlossomCacheRedirectInterceptor { false } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured)) + assertEquals("https://blossom.example.com/$sha.jpg", captured.single()) + response.close() + } + + @Test + fun bridgeOnRewritesShaInPath() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha.jpg", captured)) + assertEquals( + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com", + captured.single(), + ) + response.close() + } + + @Test + fun bridgeOnPreservesNonHexUrls() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://example.com/avatar.jpg", captured)) + assertEquals("https://example.com/avatar.jpg", captured.single()) + response.close() + } + + @Test + fun bridgeOnSkipsLocalhost() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("http://127.0.0.1:24242/$sha.jpg", captured)) + assertEquals("http://127.0.0.1:24242/$sha.jpg", captured.single()) + response.close() + } + + @Test + fun bridgeOnHandlesNonStandardPort() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://blossom.example.com:8443/$sha.png", captured)) + assertEquals( + "http://127.0.0.1:24242/$sha.png?xs=https%3A%2F%2Fblossom.example.com%3A8443", + captured.single(), + ) + response.close() + } + + @Test + fun bridgeOnHandlesUppercaseSha() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://blossom.example.com/${sha.uppercase()}.jpg", captured)) + assertEquals( + "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fblossom.example.com", + captured.single(), + ) + response.close() + } + + @Test + fun bridgeOnFallsBackToBinExtension() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + val captured = mutableListOf() + val response = interceptor.intercept(fakeChain("https://blossom.example.com/$sha", captured)) + assertEquals( + "http://127.0.0.1:24242/$sha.bin?xs=https%3A%2F%2Fblossom.example.com", + captured.single(), + ) + response.close() + } + + @Test + fun bridgeOnHandlesShaInDeeperPathSegment() { + val interceptor = LocalBlossomCacheRedirectInterceptor { true } + 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", + captured.single(), + ) + response.close() + } + + private fun fakeChain( + url: String, + captured: MutableList, + ): Interceptor.Chain = + object : Interceptor.Chain { + private val request = Request.Builder().url(url.toHttpUrl()).build() + + override fun request(): Request = request + + override fun proceed(request: Request): Response { + captured.add(request.url.toString()) + return Response + .Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body("".toResponseBody(null)) + .build() + } + + override fun connection(): Connection? = null + + override fun call() = throw UnsupportedOperationException() + + override fun connectTimeoutMillis(): Int = 0 + + override fun readTimeoutMillis(): Int = 0 + + override fun writeTimeoutMillis(): Int = 0 + + override fun withConnectTimeout( + timeout: Int, + unit: java.util.concurrent.TimeUnit, + ): Interceptor.Chain = this + + override fun withReadTimeout( + timeout: Int, + unit: java.util.concurrent.TimeUnit, + ): Interceptor.Chain = this + + override fun withWriteTimeout( + timeout: Int, + unit: java.util.concurrent.TimeUnit, + ): Interceptor.Chain = this + } +}