diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index d27bd89e3d..06bc236b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -320,7 +320,9 @@ class NappletBrokerService : Service() { context = applicationContext, ledger = ledger, storage = storage, - torPort = { Amethyst.instance.torManager.activePortOrNull.value ?: -1 }, + // Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes + // through Tor when asked + active, and falls back to clearnet otherwise. + httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) }, ).broker() cachedBroker = account to broker return broker diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index c99dbcc37d..cee3ccde5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -56,6 +56,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse import com.vitorpamplona.quartz.utils.sha256.sha256 import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.withTimeout +import okhttp3.OkHttpClient import java.io.ByteArrayInputStream import kotlin.time.Duration.Companion.seconds @@ -70,10 +71,14 @@ class AccountNappletGateways( private val context: Context, private val ledger: NappletPermissionLedger, private val storage: NappletStorage, - private val torPort: () -> Int, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { private val consentSummary = NappletConsentSummary(context) - private val resourceFetcher = NappletResourceFetcher(account, torPort) + + // Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor + // routing, Onion-Location discovery/rewriting, Blossom cache and pool as the + // rest of the app, instead of a private client that has to re-wire all of it. + private val resourceFetcher = NappletResourceFetcher(account, httpClient) private val identityReader = AccountIdentityReader(account) fun broker(): NappletBroker { @@ -97,7 +102,7 @@ class AccountNappletGateways( } val wallet = NappletWalletGateway { invoice -> payInvoiceViaNwc(invoice) } - val resource = NappletResourceGateway { url -> resourceFetcher.fetch(url) } + val resource = NappletResourceGateway { url, coordinate -> resourceFetcher.fetch(url, coordinate) } val identityReads = NappletIdentityGateway { method, argument -> identityReader.read(method, argument) } val upload = NappletUploadGateway { bytes, contentType, filename -> uploadBlob(bytes, contentType, filename) } val theme = NappletThemeGateway { currentThemeColors() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index ac6fdfa86b..a5e67039e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.napplet.gateways import android.util.Base64 import com.vitorpamplona.amethyst.commons.napplet.NappletResource import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll @@ -40,8 +41,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import java.net.InetSocketAddress -import java.net.Proxy import java.net.URLDecoder /** @@ -50,25 +49,34 @@ import java.net.URLDecoder * `https:`, and `blossom:` URLs; blossom blobs are content-addressed and **sha256-verified** before * returning, so a wrong server can never substitute the blob. * - * Owns a Tor-routed [OkHttpClient], cached and rebuilt only when the active Tor port ([torPort]) - * changes. Built per account (so it reads the right Blossom server list); consent is enforced by the + * Network goes through the app-wide [OkHttpClient] supplied by [httpClient] (the shared + * [com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager]). Reusing it — rather than + * standing up a private client — means napplet blob fetches inherit the same passive + * `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, connection pool + * and DNS as every other HTTP role. Tor-or-clearnet is chosen per request from the calling + * applet's [NappletNetworkRegistry] mode (locked napplets are pinned to Tor; nSites follow the + * user's per-site toggle), so a brokered fetch routes exactly like that applet's own page loads. + * Built per account (so it reads the right Blossom server list); consent is enforced by the * broker before [fetch] ever runs. */ class NappletResourceFetcher( private val account: Account, - private val torPort: () -> Int, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - // Reused blob HTTP client, keyed by the active Tor port (see client()). - private var cachedHttp: Pair? = null - - /** Fetches an https/data/blossom resource, or null if unsupported/unavailable. */ - suspend fun fetch(url: String): NappletResource? = + /** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */ + suspend fun fetch( + url: String, + coordinate: String, + ): NappletResource? = withContext(Dispatchers.IO) { + // Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise. + NappletNetworkRegistry.awaitReady() + val client = httpClient(NappletNetworkRegistry.useTor(coordinate)) when { url.startsWith("data:") -> decodeDataUrl(url) url.startsWith("https://") -> { runCatching { - client() + client .newCall( Request .Builder() @@ -84,7 +92,7 @@ class NappletResourceFetcher( } }.getOrNull() } - url.startsWith("blossom:") -> fetchBlossom(url) + url.startsWith("blossom:") -> fetchBlossom(url, client) url.startsWith("nostr:") -> resolveNostr(url) else -> null } @@ -141,30 +149,16 @@ class NappletResourceFetcher( .maxByOrNull { it.createdAt } } - /** - * Tor-routed OkHttp client for host-side blob fetches (the applet has no direct network). - * Cached and reused for connection pooling; rebuilt only when the Tor proxy port changes. - */ - @Synchronized - private fun client(): OkHttpClient { - val port = torPort() - cachedHttp?.let { (cachedPort, client) -> if (cachedPort == port) return client } - val client = - if (port > 0) { - OkHttpClient.Builder().proxy(Proxy(Proxy.Type.SOCKS, InetSocketAddress("127.0.0.1", port))).build() - } else { - OkHttpClient() - } - cachedHttp = port to client - return client - } - /** * Fetches a `blossom:` (or `blossom://`) blob from the user's Blossom servers - * (kind:10063), verifying the sha256 before returning — content-addressed, so a wrong server - * can never substitute the blob. Returns null for a malformed hash or if no server serves it. + * (kind:10063) over [client], verifying the sha256 before returning — content-addressed, so a + * wrong server can never substitute the blob. Returns null for a malformed hash or if no server + * serves it. */ - private fun fetchBlossom(url: String): NappletResource? { + private fun fetchBlossom( + url: String, + client: OkHttpClient, + ): NappletResource? { val hash = url .removePrefix("blossom://") @@ -180,7 +174,6 @@ class NappletResourceFetcher( .getBlossomServersList() ?.servers() .orEmpty() - val client = client() for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { val bytes = runCatching { 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 4ec0b4c9a9..794c3ff703 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 @@ -40,7 +40,11 @@ class DualHttpClientManager( scope: CoroutineScope, dns: SurgeDns, shouldBridgeBlossomCache: (() -> Boolean)? = null, - onionCache: OnionLocationCache? = null, + // Required (not nullable): every general-purpose HTTP client we mint must be + // wired into the OnionLocationCache so the app's `.onion`-routing behavior + // is uniform across image, upload, NIP-05, money, preview, and push roles. + // See [OkHttpClientFactory] kdoc. + onionCache: OnionLocationCache, ) : IHttpClientManager { val factory = OkHttpClientFactory(keyCache, userAgent, dns, shouldBridgeBlossomCache, onionCache) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt index 48764445e2..8a1b8eb164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/IsEmulator.kt @@ -22,18 +22,31 @@ package com.vitorpamplona.amethyst.service.okhttp import android.os.Build -internal fun isEmulator(): Boolean = - Build.FINGERPRINT.startsWith("generic") || - Build.FINGERPRINT.lowercase().contains("emulator") || - Build.MODEL.contains("google_sdk") || - Build.MODEL.lowercase().contains("droid4x") || - Build.MODEL.contains("Emulator") || - Build.MODEL.contains("Android SDK built for x86") || - Build.MANUFACTURER.contains("Genymotion") || - (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) || - "google_sdk" == Build.PRODUCT || - Build.HARDWARE.contains("goldfish") || - Build.HARDWARE.contains("ranchu") || - Build.HARDWARE.contains("vbox86") || - Build.HARDWARE.contains("nox") || - Build.HARDWARE.contains("cuttlefish") +// Kotlin types android.os.Build's static fields as non-null String, but the JVM stub +// used in unit tests (testOptions.unitTests.isReturnDefaultValues = true) leaves them +// null. A naive `.startsWith` would NPE there. Coalesce to empty so unit tests can +// instantiate the OkHttp factories — production behavior is unchanged (real Android +// always populates these). +internal fun isEmulator(): Boolean { + val fingerprint = Build.FINGERPRINT ?: "" + val model = Build.MODEL ?: "" + val manufacturer = Build.MANUFACTURER ?: "" + val brand = Build.BRAND ?: "" + val device = Build.DEVICE ?: "" + val product = Build.PRODUCT ?: "" + val hardware = Build.HARDWARE ?: "" + return fingerprint.startsWith("generic") || + fingerprint.lowercase().contains("emulator") || + model.contains("google_sdk") || + model.lowercase().contains("droid4x") || + model.contains("Emulator") || + model.contains("Android SDK built for x86") || + manufacturer.contains("Genymotion") || + (brand.startsWith("generic") && device.startsWith("generic")) || + "google_sdk" == product || + hardware.contains("goldfish") || + hardware.contains("ranchu") || + hardware.contains("vbox86") || + hardware.contains("nox") || + hardware.contains("cuttlefish") +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt index 6021e1a349..7c67aafef7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/OkHttpClientFactory.kt @@ -32,6 +32,22 @@ import java.net.Proxy import java.time.Duration import java.util.concurrent.TimeUnit +/** + * Builder for the general-purpose (non-relay) OkHttp client used by image + * downloads, NIP-96/Blossom uploads, NIP-05 lookups, LNURL/money, link + * previews, push registration, etc. Each [DualHttpClientManager] holds one + * of these and rebuilds clients on Tor proxy / mobile-data changes. + * + * [OnionLocationCache] is **required**: every OkHttp client minted by this + * factory installs [OnionLocationInterceptor] on the no-proxy and Tor-proxied + * variants so any HTTP/WebSocket response that carries an `Onion-Location` + * header (NIP-11 docs, image hosts, blossom servers, NIP-96 endpoints, LNURL + * providers, anything) populates the cache. Tor-proxied clients additionally + * install [OnionUrlRewriteInterceptor] so subsequent requests are rerouted + * to the cached `.onion` and avoid exit nodes entirely. Treating the cache as + * required (not nullable) prevents a future call site from silently disabling + * onion-routing for an entire HTTP role. + */ class OkHttpClientFactory( keyCache: EncryptionKeyCache, val userAgent: String, @@ -43,7 +59,7 @@ class OkHttpClientFactory( * useful for tests or pre-configuration call sites. */ val shouldBridgeBlossomCache: (() -> Boolean)? = null, - private val onionCache: OnionLocationCache? = null, + private val onionCache: OnionLocationCache, ) { // val logging = LoggingInterceptor() val keyDecryptor = EncryptedBlobInterceptor(keyCache) @@ -92,7 +108,11 @@ class OkHttpClientFactory( } // .addNetworkInterceptor(logging) .addNetworkInterceptor(keyDecryptor) - .apply { onionCache?.let { addInterceptor(OnionLocationInterceptor(it)) } } + // Passively populates [onionCache] from any HTTP/WebSocket response + // that carries an `Onion-Location` header. Application-scoped so the + // cache key is always the original clearnet host (see kdoc on + // [OnionLocationInterceptor]). + .addInterceptor(OnionLocationInterceptor(onionCache)) .build() private var lastProxy: Proxy? = null @@ -109,7 +129,10 @@ class OkHttpClientFactory( return rootClient .newBuilder() .proxy(proxy) - .apply { if (proxy != null) onionCache?.let { addInterceptor(OnionUrlRewriteInterceptor(it)) } } + // Only the Tor-routed variant rewrites outbound URLs to known + // `.onion`s — clearnet clients must never try to resolve `.onion` + // (DNS would fail, and we don't want fingerprintable lookups). + .apply { if (proxy != null) addInterceptor(OnionUrlRewriteInterceptor(onionCache)) } .connectTimeout(Duration.ofSeconds(seconds.toLong())) .readTimeout(Duration.ofSeconds(seconds.toLong() * 3)) .writeTimeout(Duration.ofSeconds(seconds.toLong() * 3)) diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt new file mode 100644 index 0000000000..73f77b28c5 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/okhttp/OnionInterceptorWiringTest.kt @@ -0,0 +1,312 @@ +/* + * 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.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.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Test +import java.lang.reflect.Proxy + +/** + * Pins the Onion-Location interceptor behavior the entire app's onion-routing + * story relies on. + * + * Onion-Location is a free passive discovery mechanism: an `.onion`-equipped server + * advertises its onion address via an HTTP response header, and the app caches the + * mapping for later Tor-routed connections. The mechanism only works if **every** + * OkHttp client built by the app installs the interceptors: + * + * - [OnionLocationInterceptor] on every client (no-proxy AND Tor-proxied) so we capture + * the header from whichever client happened to make the request. + * - [OnionUrlRewriteInterceptor] on Tor-proxied clients only (clearnet clients must never + * try to resolve `.onion`). + * + * Wiring is enforced by [OkHttpClientFactory] and [OkHttpClientFactoryForRelays] taking + * the cache as a **non-nullable** constructor parameter and unconditionally adding the + * interceptors in their builder chain. Factory-instantiation in unit tests is blocked by + * OkHttp 5's Android platform shim (it calls `android.util.Log.isLoggable`, which only + * exists on a real device), so the wiring is pinned at the type level rather than via + * reflective inspection of a built client. This suite covers the interceptors themselves + * and the cache they share so any regression in their behavior — missing put, wrong + * rewrite scheme, stale TTL, etc. — fails fast. + * + * The chain is a [Proxy] that only implements [Interceptor.Chain.request] and + * [Interceptor.Chain.proceed]; same pattern as [LocalBlossomCacheRedirectInterceptorTest]. + */ +class OnionInterceptorWiringTest { + // --- OnionLocationInterceptor: passively captures the header --- + + @Test + fun locationInterceptor_recordsHeaderUnderClearnetHost() { + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://example.com/some/path", + responseHeaders = mapOf("Onion-Location" to "http://abcdef.onion/"), + ) + + assertEquals( + "Cache key must be the original clearnet host, not the .onion — otherwise " + + "the rewriter on the next request looks up a key that was never written.", + "http://abcdef.onion/", + cache.get("example.com"), + ) + } + + @Test + fun locationInterceptor_websocketUpgrade_alsoRecords() { + // The Onion-Location header is captured from any response — including the + // WebSocket 101 Switching Protocols upgrade handshake on a Nostr relay + // connection. This is the primary discovery path for Tor-routed relays. + // (OkHttp normalizes ws/wss to http/https on Request.url, so even though + // the relay URL is `wss://`, the interceptor sees `https://`. The cache + // entry is keyed by that normalized host, which is what the rewriter + // later looks up on the next outbound `wss://` request.) + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://relay.example/", + responseCode = 101, + responseHeaders = mapOf("Onion-Location" to "http://relay.onion/"), + ) + + assertEquals("http://relay.onion/", cache.get("relay.example")) + } + + @Test + fun locationInterceptor_noHeader_writesNothing() { + val cache = OnionLocationCache() + val interceptor = OnionLocationInterceptor(cache) + + runWithResponse( + interceptor = interceptor, + url = "https://example.com/", + responseHeaders = emptyMap(), + ) + + assertNull( + "A response with no Onion-Location must not pollute the cache.", + cache.get("example.com"), + ) + } + + // --- OnionUrlRewriteInterceptor: rewrites outbound URLs to known .onions --- + + @Test + fun rewriteInterceptor_unknownHost_passesThrough() { + val cache = OnionLocationCache() + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/") + + assertEquals( + "Unknown hosts must reach the original URL untouched.", + "https://example.com/", + captured, + ) + } + + @Test + fun rewriteInterceptor_https_to_httpsOnion_keepsHttps() { + val cache = OnionLocationCache().apply { put("example.com", "https://abc.onion/") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/foo") + val url = captured.toHttpUrl() + + assertEquals("abc.onion", url.host) + assertEquals("https", url.scheme) + assertEquals("/foo", url.encodedPath) + } + + @Test + fun rewriteInterceptor_https_to_httpOnion_downgradesToHttp() { + // An onion service advertising http:// is not a real downgrade: the Tor + // hidden-service descriptor already provides authenticated end-to-end + // encryption. We keep the request in the http protocol family. + val cache = OnionLocationCache().apply { put("example.com", "http://abc.onion:8080/") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val url = captureRewrite(interceptor, "https://example.com/").toHttpUrl() + + assertEquals("http", url.scheme) + assertEquals(8080, url.port) + } + + // Note: OkHttp normalizes `ws/wss` URLs to `http/https` on `Request.url` + // before any interceptor runs, so the `ws/wss` branches in + // OnionUrlRewriteInterceptor.intercept() are defensive code only — + // unreachable from a real OkHttp call site. Tests of them would have to + // construct an HttpUrl with a ws/wss scheme, which the OkHttp 5 API + // refuses to parse. The behavior we DO need to pin is that the http/https + // path keeps the protocol family across the onion swap, which the + // `rewriteInterceptor_https_to_*` cases above already cover. + + @Test + fun rewriteInterceptor_unparseableOnionUrl_passesThrough() { + // Malformed cache entries must not break connectivity — fall back to original. + val cache = OnionLocationCache().apply { put("example.com", "not a url") } + val interceptor = OnionUrlRewriteInterceptor(cache) + + val captured = captureRewrite(interceptor, "https://example.com/") + assertEquals("https://example.com/", captured) + } + + // --- OnionLocationCache: TTL + shared identity --- + + @Test + fun cache_putGet_roundTripsValue() { + val cache = OnionLocationCache() + cache.put("example.com", "http://abc.onion/") + assertEquals("http://abc.onion/", cache.get("example.com")) + } + + @Test + fun cache_unknownHost_returnsNull() { + val cache = OnionLocationCache() + assertNull(cache.get("never-seen.example")) + } + + @Test + fun cache_isSharedAcrossInterceptorPair() { + // Critical invariant: an entry the location interceptor writes must be + // visible to the rewriter on the next request. Both interceptors close + // over the SAME cache instance — nothing fancier than object identity. + val cache = OnionLocationCache() + val location = OnionLocationInterceptor(cache) + val rewriter = OnionUrlRewriteInterceptor(cache) + + // 1. First response advertises an onion. + runWithResponse( + interceptor = location, + url = "https://example.com/", + responseHeaders = mapOf("Onion-Location" to "http://abc.onion/"), + ) + + // 2. A subsequent request to the same host hits the rewriter and goes to .onion. + val capturedUrl = captureRewrite(rewriter, "https://example.com/api/v1").toHttpUrl() + + assertEquals("abc.onion", capturedUrl.host) + assertEquals( + "Path must be preserved across the rewrite.", + "/api/v1", + capturedUrl.encodedPath, + ) + assertNotEquals( + "Host must have changed.", + "example.com", + capturedUrl.host, + ) + } + + // --- Compile-time pin: the interceptors must remain OkHttp [Interceptor]s --- + + @Test + fun interceptorClasses_remainOkHttpInterceptors() { + val cache = OnionLocationCache() + + // Assigning to an `Interceptor` reference is the compile-time pin: a future + // refactor that drops the interface from either class breaks this build, + // because addInterceptor() in both factories takes Interceptor. + @Suppress("UNUSED_VARIABLE") + val a: Interceptor = OnionLocationInterceptor(cache) + + @Suppress("UNUSED_VARIABLE") + val b: Interceptor = OnionUrlRewriteInterceptor(cache) + } + + // --- Test helpers --- + + /** + * Runs [interceptor] against a request to [url], having `proceed` return a synthetic + * response with [responseCode] / [responseHeaders]. Returns the URL that + * `proceed` was ultimately called with (so rewrite tests can read it). + */ + private fun runWithResponse( + interceptor: Interceptor, + url: String, + responseCode: Int = 200, + responseHeaders: Map = emptyMap(), + ): String { + val captured = mutableListOf() + val chain = fakeChain(url, captured, responseCode, responseHeaders) + interceptor.intercept(chain).close() + return captured.single() + } + + /** + * Convenience for rewrite-interceptor tests: returns the URL that `proceed` + * was called with (i.e. the rewritten URL, or the original if no rewrite). + */ + private fun captureRewrite( + interceptor: Interceptor, + url: String, + ): String = runWithResponse(interceptor, url) + + /** + * A dynamic-proxy chain that implements only [Interceptor.Chain.request] and + * [Interceptor.Chain.proceed]. Used because OkHttp 5's `Chain` declares ~40 + * members (client-config getters and `withX` reconfigurers) that these tests + * never exercise. + */ + private fun fakeChain( + url: String, + captured: MutableList, + responseCode: Int, + responseHeaders: Map, + ): Interceptor.Chain { + val request = Request.Builder().url(url.toHttpUrl()).build() + return Proxy.newProxyInstance( + Interceptor.Chain::class.java.classLoader, + arrayOf(Interceptor.Chain::class.java), + ) { _, method, args -> + when (method.name) { + "request" -> request + "proceed" -> { + val proceeded = args[0] as Request + captured.add(proceeded.url.toString()) + val builder = + Response + .Builder() + .request(proceeded) + .protocol(Protocol.HTTP_1_1) + .code(responseCode) + .message(if (responseCode == 101) "Switching Protocols" else "OK") + .body("".toResponseBody(null)) + responseHeaders.forEach { (k, v) -> builder.header(k, v) } + builder.build() + } + else -> throw UnsupportedOperationException(method.name) + } + } as Interceptor.Chain + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index d5214f7a3c..0d035a4ad2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -274,7 +274,7 @@ class NappletBroker( is NappletRequest.ResourceBytes -> { val gateway = resource ?: return NappletResponse.Unsupported("resource.bytes") - val fetched = gateway.fetch(request.url) ?: return NappletResponse.Failed("Could not fetch the resource.") + val fetched = gateway.fetch(request.url, identity.coordinate) ?: return NappletResponse.Failed("Could not fetch the resource.") NappletResponse.Bytes(fetched.bytes, fetched.contentType) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt index f53ba394b1..e229884068 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerCollaborators.kt @@ -161,9 +161,16 @@ class NappletResource( * (`resource.bytes`). The host fetches https/blossom/nostr/data URLs on the applet's behalf — * the applet itself has no direct network (CSP `connect-src 'none'`). Returns `null` for an * unsupported scheme or a failed fetch. + * + * [coordinate] is the calling applet's identity coordinate (`author:identifier`), so the host can + * route the fetch the same way the applet's own page loads — through Tor or the open web — per that + * applet's/site's network mode. */ fun interface NappletResourceGateway { - suspend fun fetch(url: String): NappletResource? + suspend fun fetch( + url: String, + coordinate: String, + ): NappletResource? } /** A completed upload: where the blob lives plus NIP-94-ish metadata. */