feat(blossom): catch all sha256-keyed URLs via OkHttp interceptor

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/<sha>.<ext>?xs=<host>
- 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.
This commit is contained in:
Claude
2026-05-08 10:02:08 +00:00
parent 119ddf7cad
commit 9451f7ca9a
5 changed files with 296 additions and 2 deletions
@@ -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
@@ -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<OkHttpClient> =
combine(proxyPortProvider, isMobileDataProvider) { proxy, mobile ->
@@ -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<String, String>? {
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("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
}
}
@@ -36,9 +36,18 @@ class OkHttpClientFactory(
keyCache: EncryptionKeyCache,
val userAgent: String,
private val dns: SurgeDns,
/**
* Returns `true` when sha256-keyed HTTP requests should be transparently
* rewritten to the local Blossom cache (master toggle on, profile-pictures-only
* restriction off, probe up). When `null`, the interceptor is disabled —
* useful for tests or pre-configuration call sites.
*/
val shouldBridgeBlossomCache: (() -> 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()
@@ -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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>()
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<String>,
): 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
}
}