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:
@@ -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
|
||||
|
||||
+2
-1
@@ -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 ->
|
||||
|
||||
+103
@@ -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])")
|
||||
}
|
||||
}
|
||||
+12
@@ -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()
|
||||
|
||||
+169
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user