From 1dbfd78b4467712d3b5753ad5ded376190eeb5a5 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Fri, 26 Jun 2026 16:50:53 -0400 Subject: [PATCH] fix(napplet): route brokered resource fetches by the applet's own Tor mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The consolidation passed `useProxy = true` for every brokered `resource.bytes` fetch, forcing them through Tor whenever Tor was active — regardless of the napplet/nSite's actual network mode. That overrides the user's explicit choice: an nSite running in "open web" mode would still have its blob fetches tunneled, inconsistent with how its own WebView page loads. The authoritative per-applet preference already exists main-side in NappletNetworkRegistry.useTor(coordinate) (locked napplets pinned to Tor; nSites follow the persisted per-site toggle, which relaunches on change) — the same source NappletLauncher reads to set the WebView proxy. Thread the calling applet's coordinate through NappletResourceGateway.fetch so the broker can resolve it, and pick the shared client with getHttpClient(useProxy = NappletNetworkRegistry.useTor(coordinate)). This mirrors the host's own `effectiveProxy = if (useTor) proxyPort else -1` exactly, so a brokered fetch now routes like the applet's page. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../amethyst/napplet/NappletBrokerService.kt | 5 +- .../gateways/AccountNappletGateways.kt | 4 +- .../gateways/NappletResourceFetcher.kt | 47 ++++++++++--------- .../amethyst/commons/napplet/NappletBroker.kt | 2 +- .../napplet/NappletBrokerCollaborators.kt | 9 +++- 5 files changed, 40 insertions(+), 27 deletions(-) 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 391773cbc5..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,8 +320,9 @@ class NappletBrokerService : Service() { context = applicationContext, ledger = ledger, storage = storage, - // Prefer Tor when active; the shared manager falls back to clearnet when it isn't. - httpClient = { Amethyst.instance.okHttpClients.getHttpClient(useProxy = true) }, + // 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 ee56c95694..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 @@ -71,7 +71,7 @@ class AccountNappletGateways( private val context: Context, private val ledger: NappletPermissionLedger, private val storage: NappletStorage, - private val httpClient: () -> OkHttpClient, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { private val consentSummary = NappletConsentSummary(context) @@ -102,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 b8ce3fd977..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 @@ -50,23 +51,32 @@ import java.net.URLDecoder * * 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 Tor routing, - * passive `Onion-Location` discovery + `.onion` rewriting, local Blossom cache redirect, - * connection pool and DNS as every other HTTP role. Built per account (so it reads the right - * Blossom server list); consent is enforced by the broker before [fetch] ever runs. + * 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 httpClient: () -> OkHttpClient, + private val httpClient: (useProxy: Boolean) -> OkHttpClient, ) { - /** 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() @@ -82,7 +92,7 @@ class NappletResourceFetcher( } }.getOrNull() } - url.startsWith("blossom:") -> fetchBlossom(url) + url.startsWith("blossom:") -> fetchBlossom(url, client) url.startsWith("nostr:") -> resolveNostr(url) else -> null } @@ -139,20 +149,16 @@ class NappletResourceFetcher( .maxByOrNull { it.createdAt } } - /** - * The app-wide OkHttp client for host-side blob fetches (the applet has no direct network). - * The shared manager already routes through Tor when active, captures `Onion-Location` and - * rewrites `.onion`s, bridges the local Blossom cache, and pools connections — so there is no - * private client to build or cache here. - */ - private fun client(): OkHttpClient = httpClient() - /** * 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://") @@ -168,7 +174,6 @@ class NappletResourceFetcher( .getBlossomServersList() ?.servers() .orEmpty() - val client = client() for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) { val bytes = runCatching { 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. */