refactor(blossom): route media to the local cache only at the HTTP layer

Media reached the local Blossom cache two ways: the UI rewrote https URLs
into `blossom:` URIs (toCoilModel / bridgeProfilePictureUrl) that a
resolver later mapped to 127.0.0.1, and an OkHttp interceptor rewrote the
same URLs in flight. Nearly every bug in this area came from the first
path, because everything downstream expects the real URL: the Tor
decision was made on the unresolved URI, decryption keys and disk-cache
keys missed, settings drifted between the two gates.

Now the interceptor is the only bridge for http(s) media:

- Feed images, videos, gallery thumbs and profile pictures keep their
  real URL. The Tor choice, Coil/ExoPlayer cache keys and key lookups all
  see the origin; MediaUrlContentExt is removed.
- Tor-proxied clients don't carry the interceptor, so media the user
  routes through Tor skips the local cache instead of being fetched from
  the origin outside Tor. The resolver applies the same rule to native
  `blossom:` URIs.
- "Profile pictures only" is enforced in the interceptor: profile-picture
  downloads are tagged (ProfilePictureCallFactory) and the gate lets only
  those through when the setting is on.
- Native `blossom:` URIs still resolve through BlossomServerResolver.

The cost: a cache miss no longer carries the `as=` author hint for
http(s) media, only `xs=` (the origin server).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UVu6oE2QJ5beoN23wjkCJ1
This commit is contained in:
Claude
2026-09-23 14:45:09 +00:00
parent 311557c7ce
commit 1a5c77e77f
15 changed files with 102 additions and 645 deletions
@@ -458,13 +458,14 @@ class AppModules(
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 = {
// Blossom cache when the master toggle is on, the probe sees
// 127.0.0.1:24242 as available, and either the profile-pictures-only
// restriction is off or the request is a profile picture.
shouldBridgeBlossomCache = { profilePicture ->
val settings = sessionManager.loggedInAccount()?.settings
val master = settings?.useLocalBlossomCache?.value ?: false
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
master && (profilePicture || !profileOnly) && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
usageInterceptor = httpUsageInterceptor,
@@ -36,6 +36,7 @@ import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient
import coil3.request.Options
import com.vitorpamplona.amethyst.commons.service.http.BlossomReadAuthTokenProvider
import com.vitorpamplona.amethyst.commons.service.http.ProfilePictureCallFactory
import com.vitorpamplona.amethyst.commons.service.image.readAuthAware
import com.vitorpamplona.amethyst.commons.service.image.withAuthHeader
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
@@ -121,7 +122,8 @@ class ProfilePictureFetcher(
NetworkFetcher(
url = data.url,
options = options.withAuthHeader(authHeader),
networkClient = lazy { networkClient(data.url).asNetworkClient() },
// Tagged so the local Blossom cache bridge can honour "profile pictures only".
networkClient = lazy { ProfilePictureCallFactory(networkClient(data.url)).asNetworkClient() },
diskCache = diskCacheLazy,
cacheStrategy = cacheStrategyLazy,
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
@@ -124,11 +124,14 @@ class BlossomServerResolver(
suspend fun findServersInner(uriStr: String): BlossomUriServer? {
val uri = BlossomUri.parse(uriStr) ?: return null
if (useLocalBlossomCache() && localCacheProbe?.isAvailable() == true) {
val expectedMimeType = mimeTypeMap[uri.extension]
// Same rule as the HTTP-level bridge, which Tor-routed clients don't carry: media the
// user sends through Tor must not be handed to the local cache, which would fetch it
// from the origin outside Tor.
if (useLocalBlossomCache() && !isTorRouted(uri, expectedMimeType) && localCacheProbe?.isAvailable() == true) {
return BlossomUriServer(uri, uri.toLocalCacheUrl(LocalBlossomCacheProbe.LOCAL_CACHE_BASE))
}
val expectedMimeType = mimeTypeMap[uri.extension]
val filename = uri.filename()
if (uri.servers.isNotEmpty()) {
@@ -186,6 +189,12 @@ class BlossomServerResolver(
}
}
/** Whether this blob's media type would be fetched through Tor from a regular (clearnet) server. */
private fun isTorRouted(
uri: BlossomUri,
mimeType: String?,
): Boolean = client(uri.toServerUrl() ?: "https://blossom.invalid/${uri.filename()}", mimeType).proxy != null
fun client(
url: String,
mimeType: String?,
@@ -212,7 +212,7 @@ fun MediaCacheSection(accountViewModel: AccountViewModel) {
*/
@Composable
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
val probeAvailable by accountViewModel.localBlossomCacheDetected
.collectAsStateWithLifecycle()
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
@@ -29,7 +29,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
@@ -39,48 +38,17 @@ import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.asDrawable
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
import coil3.compose.SubcomposeAsyncImageContent
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter
import com.vitorpamplona.amethyst.commons.richtext.bridgeProfilePictureUrl
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.commons.ui.components.forwardingPainter
import com.vitorpamplona.amethyst.ui.screen.AccountState
import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
private fun rememberLocalBlossomBridgeForProfilePics(): Boolean {
val sessionManager =
try {
Amethyst.instance.sessionManager
} catch (e: UninitializedPropertyAccessException) {
return false
}
val probe = Amethyst.instance.localBlossomCacheProbe
val flow =
remember {
combine(
sessionManager.accountContent.flatMapLatest { state ->
if (state is AccountState.LoggedIn) state.account.settings.useLocalBlossomCache else flowOf(false)
},
probe.available,
) { toggle, probeUp -> toggle && probeUp }
}
val state by flow.collectAsStateWithLifecycle(initialValue = false)
return state
}
@Composable
fun RobohashAsyncImage(
@@ -120,21 +88,16 @@ fun RobohashFallbackAsyncImage(
loadRobohash: Boolean,
autoPlayGif: Boolean = true,
) {
val useBridge = rememberLocalBlossomBridgeForProfilePics()
val bridgedModel =
remember(model, robot, useBridge) {
bridgeProfilePictureUrl(model, useBridge, robot)
}
if (bridgedModel != null && loadProfilePicture && isAnimatedMediaUrl(bridgedModel)) {
if (model != null && loadProfilePicture && isAnimatedMediaUrl(model)) {
GifProfilePicture(
userHex = robot,
userPicture = bridgedModel,
userPicture = model,
contentDescription = contentDescription,
modifier = modifier,
loadRobohash = loadRobohash,
autoPlay = autoPlayGif,
)
} else if (bridgedModel != null && loadProfilePicture) {
} else if (model != null && loadProfilePicture) {
val fallbackPainter =
if (loadRobohash) {
rememberVectorPainter(
@@ -154,10 +117,10 @@ fun RobohashFallbackAsyncImage(
// file://) would fail there. Route only remote http(s) pictures through the thumbnail
// cache; hand local/content URIs to Coil's native fetchers, which load them directly.
model =
if (bridgedModel.startsWith("http://", ignoreCase = true) || bridgedModel.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(bridgedModel)
if (model.startsWith("http://", ignoreCase = true) || model.startsWith("https://", ignoreCase = true)) {
ProfilePictureUrl(model)
} else {
bridgedModel
model
},
contentDescription = contentDescription,
modifier = modifier,
@@ -64,7 +64,6 @@ import androidx.compose.ui.util.lerp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_save_the_image
@@ -81,7 +80,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.service.playback.composable.VideoViewInner
@@ -505,12 +503,6 @@ private fun RenderImageOrVideo(
}
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val modifier =
if (ratio != null) {
Modifier.aspectRatio(ratio)
@@ -520,7 +512,7 @@ private fun RenderImageOrVideo(
Box(modifier, contentAlignment = Alignment.Center) {
VideoViewInner(
videoUri = bridgedUrl,
videoUri = content.url,
mimeType = content.mimeType,
aspectRatio = ratio,
title = content.description,
@@ -66,7 +66,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter
@@ -105,7 +104,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.service.image.placeholderModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
@@ -197,26 +195,20 @@ fun ZoomableContentView(
sourceBounds = coordinates.boundsInWindow()
}
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
when (content) {
is MediaUrlImage -> {
val ratio = content.dim?.aspectRatioOrNull() ?: MediaAspectRatioCache.get(content.url)
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
preloadUrls = listOf(bridgedUrl),
preloadUrls = listOf(content.url),
accountViewModel = accountViewModel,
modifier = mediaSizingModifier(ratio, contentScale),
backdrop = (content.thumbhash ?: content.blurhash)?.let { { BlurhashBackdrop(content.blurhash, content.description, content.thumbhash) } },
) {
if (content.isAnimatedMedia()) {
GifVideoView(
videoUri = bridgedUrl,
videoUri = content.url,
contentDescription = content.description,
dimensions = content.dim,
blurhash = content.blurhash,
@@ -251,10 +243,6 @@ fun ZoomableContentView(
content.dim?.aspectRatioOrNull()
?: MediaAspectRatioCache.get(content.url)
?: fallbackRatio
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
ContentWarningGate(
isSensitive = content.contentWarning != null,
reasons = setOfNotNull(content.contentWarning),
@@ -274,7 +262,7 @@ fun ZoomableContentView(
contentAlignment = Alignment.Center,
) {
VideoView(
videoUri = bridgedUrl,
videoUri = content.url,
mimeType = content.mimeType,
title = content.description,
artworkUri = content.artworkUri,
@@ -541,22 +529,17 @@ fun UrlImageView(
}
val context = LocalContext.current
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModel =
if (fullResolution) {
remember(bridgedUrl, context) {
remember(content.url, context) {
ImageRequest
.Builder(context)
.data(bridgedUrl)
.data(content.url)
.size(Size.ORIGINAL)
.build()
}
} else {
bridgedUrl
content.url
}
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1278,15 +1261,9 @@ private suspend fun shareLocalVideoFile(
private fun verifyHash(content: MediaUrlContent): Boolean? {
if (content.hash == null) return null
val keys = mutableListOf(content.url)
val bridged = content.toCoilModel(true)
if (bridged != content.url) keys.add(bridged)
for (key in keys) {
Amethyst.instance.diskCache.openSnapshot(key)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
Amethyst.instance.diskCache.openSnapshot(content.url)?.use { snapshot ->
val (hashBytes, _) = sha256StreamWithCount(snapshot.data.toFile().inputStream())
return hashBytes.toHexKey() == content.hash
}
return null
@@ -275,32 +275,10 @@ class AccountViewModel(
val feedStates = AccountFeedContentStates(account, viewModelScope)
/**
* `true` when feed/note media (images and videos in `MediaUrlContent`)
* should be routed through the local Blossom cache. Requires the master
* toggle on, the probe up, AND the profile-pictures-only restriction
* to be off.
* `true` when the local Blossom cache is enabled and the probe sees it up. Only drives the
* "detected" chip in settings: the routing itself happens in LocalBlossomCacheRedirectInterceptor.
*/
val useLocalBlossomBridge: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
account.settings.localBlossomCacheProfilePicturesOnly,
Amethyst.instance.localBlossomCacheProbe.available,
) { toggle, profileOnly, probeUp -> toggle && probeUp && !profileOnly }.stateIn(
viewModelScope,
SharingStarted.Eagerly,
false,
)
} catch (e: UninitializedPropertyAccessException) {
MutableStateFlow(false)
}
/**
* `true` when profile pictures should be routed through the local
* Blossom cache. Requires only the master toggle and the probe to be
* up; the profile-pictures-only restriction does not gate this flow.
*/
val useLocalBlossomBridgeForProfilePics: StateFlow<Boolean> =
val localBlossomCacheDetected: StateFlow<Boolean> =
try {
combine(
account.settings.useLocalBlossomCache,
@@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
import coil3.compose.AsyncImagePainter
import coil3.compose.SubcomposeAsyncImage
@@ -50,7 +49,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isHlsMimeType
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl
import com.vitorpamplona.amethyst.commons.richtext.toCoilModel
import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.isHlsMedia
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
@@ -256,16 +254,11 @@ fun UrlImageView(
val isVideo = content is MediaUrlVideo
val artworkUri = (content as? MediaUrlVideo)?.artworkUri
val useLocalBlossomBridge by accountViewModel.useLocalBlossomBridge.collectAsStateWithLifecycle()
// Coil's VideoFrameDecoder can extract a frame from .mp4/.webm but not from an HLS .m3u8
// playlist (it's a text manifest). For an HLS video without a separate artwork URL, sending
// the playlist to SubcomposeAsyncImage just produces an Error state and a stand-in icon.
// Skip the fetch in that case and render blurhash + play overlay directly.
val bridgedUrl =
remember(content.url, useLocalBlossomBridge) {
content.toCoilModel(useLocalBlossomBridge)
}
val imageModelUrl = artworkUri ?: bridgedUrl
val imageModelUrl = artworkUri ?: content.url
val canLoadAsImage = !isVideo || artworkUri != null || !isHlsMedia(content.url, content.mimeType)
CrossfadeIfEnabled(targetState = showImage.value, contentAlignment = Alignment.Center, accountViewModel = accountViewModel) {
@@ -1,229 +0,0 @@
/*
* 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.commons.richtext
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
private val sha256HexRegex = Regex("[0-9a-f]{64}")
private val blossomLastSegmentRegex = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
/**
* Converts this media content into a Coil/ExoPlayer-friendly model string.
*
* When the local-Blossom-cache bridge is active and the content has a
* known sha256 hash, returns a `blossom:<sha256>.<ext>?xs=<originalHostBase>&as=<authorPubKey>`
* URI. The Coil pipeline recognises the scheme and routes the request
* through `BlossomServerResolver`, which short-circuits to the local cache
* at `127.0.0.1:24242`.
*
* Otherwise (bridge off, no hash, hash invalid, already a `blossom:` URI,
* or a live stream) returns the original URL unchanged so today's
* direct-to-CDN behaviour is preserved.
*/
fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
bridgeUrl(
url = url,
useBridge = useLocalBlossomBridge,
mimeType = mimeType,
authorPubKey = authorPubKey,
// Encrypted blobs are decrypted by a key registered under their URL. A `blossom:`
// URI resolves to a local-cache URL no key is registered under, so the ciphertext
// would reach the decoder. They still reach the cache through the HTTP-level
// redirect, which carries the key over to the rewritten URL.
skipBridge = (this is MediaUrlVideo && isLiveStream) || this is EncryptedMediaUrlImage || this is EncryptedMediaUrlVideo,
)
/**
* Bridge entry point for raw URL strings (e.g. profile pictures) that go
* through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
* bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
* entirely.
*
* Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
* URL so the request can flow through `NetworkFetcher` unchanged. Falls
* back to the original URL when no sha256 can be recovered from the path
* or when the bridge is off.
*
* @param localCacheBase the local Blossom cache origin (default `http://127.0.0.1:24242`).
* @param authorPubKey 64-char lowercase hex pubkey appended as `as=` so the
* cache can consult that author's BUD-03 server list on miss.
*/
fun bridgeProfilePictureUrl(
url: String?,
useBridge: Boolean,
authorPubKey: String? = null,
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
): String? {
if (url == null) return null
if (!useBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, null)
val serverBase = extractServerBase(url, sha) ?: return url
val params =
buildList {
add("xs=${percentEncode(serverBase)}")
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { add("as=$it") }
}
return "${localCacheBase.removeSuffix("/")}/$sha.$ext?${params.joinToString("&")}"
}
const val DEFAULT_LOCAL_CACHE_BASE = "http://127.0.0.1:24242"
private fun bridgeUrl(
url: String,
useBridge: Boolean,
mimeType: String?,
authorPubKey: String?,
skipBridge: Boolean,
): String {
if (!useBridge || skipBridge) return url
if (url.startsWith("blossom:", ignoreCase = true)) return url
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
// The local Blossom cache fetches `<xs>/<sha>.<ext>` on miss per BUD-01,
// which only works when the upstream URL is itself BUD-01 layout — the
// file at `<xs>/<sha>.<ext>` is the one named in the URL path, not the
// imeta `x` hash (which on resizing CDNs may identify a different blob
// than the URL filename, e.g. `x` = post-resize, `ox` = original).
// Always use the URL's sha; never trust the imeta override.
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, mimeType)
val serverBase = extractServerBase(url, sha) ?: return url
val authors =
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
?.let { listOf(it) }
?: emptyList()
return BlossomUri(
sha256 = sha,
extension = ext,
servers = listOf(serverBase),
authors = authors,
size = null,
).toUriString()
}
private fun percentEncode(input: String): String {
val sb = StringBuilder(input.length)
for (c in input) {
if (c.isLetterOrDigit() || c in "-._~") {
sb.append(c)
} else {
for (b in c.toString().encodeToByteArray()) {
sb.append('%')
sb.append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase())
}
}
}
return sb.toString()
}
private fun extractSha256FromUrlPath(url: String): String? {
// Per Blossom (BUD-01) the last path segment must be exactly
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
// the bridge must leave them alone — rewriting them would point the
// local cache at a fallback `xs=` server that doesn't host the blob.
val pathPart = url.substringBefore('?').substringBefore('#')
val lastSegment = pathPart.substringAfterLast('/')
val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null
return match.groupValues[1].lowercase()
}
private fun guessExtension(
url: String,
mimeType: String?,
): String {
val pathPart = url.substringBefore('?').substringBefore('#')
val lastDot = pathPart.lastIndexOf('.')
val lastSlash = pathPart.lastIndexOf('/')
if (lastDot > lastSlash && lastDot >= 0) {
val ext = pathPart.substring(lastDot + 1).lowercase()
if (ext.isNotEmpty() && ext.length <= 8 && ext.all { it.isLetterOrDigit() }) {
return ext
}
}
if (mimeType != null) {
for ((extension, mt) in mimeTypeMap) {
if (mt.equals(mimeType, ignoreCase = true)) return extension
}
}
return "bin"
}
/**
* Returns the URL prefix that the local Blossom cache should append `/<sha>`
* to in order to reach the original blob, preserving any path prefix the
* upstream CDN uses (e.g. `https://cdn.nostr.build/i` for nostr.build's
* `/i/<sha>` scheme). Falls back to scheme+host when the sha can't be
* located in the path.
*/
private fun extractServerBase(
url: String,
sha: String,
): String? {
val pathPart = url.substringBefore('?').substringBefore('#')
val schemeEnd = pathPart.indexOf("://")
if (schemeEnd < 0) return null
val hostStart = schemeEnd + 3
if (hostStart >= pathPart.length) return null
val shaIndex = pathPart.lastIndexOf(sha, ignoreCase = true)
if (shaIndex >= 0) {
// Anchor on the slash immediately preceding the sha so the cache
// can append "/<sha>" verbatim per the local-blossom-cache spec.
val slashBeforeSha = pathPart.lastIndexOf('/', shaIndex - 1)
if (slashBeforeSha > hostStart - 1) {
return pathPart.substring(0, slashBeforeSha)
}
}
return extractHostBase(pathPart)
}
private fun extractHostBase(url: String): String? {
val schemeEnd = url.indexOf("://")
if (schemeEnd < 0) return null
val afterScheme = schemeEnd + 3
var end = url.length
for (i in afterScheme until url.length) {
val c = url[i]
if (c == '/' || c == '?' || c == '#') {
end = i
break
}
}
if (end <= afterScheme) return null
return url.substring(0, end)
}
@@ -1,282 +0,0 @@
/*
* 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.commons.richtext
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MediaUrlContentExtTest {
private val sha = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
@Test
fun bridgeOffReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/$sha.jpg", hash = sha)
assertEquals("https://cdn.example.com/$sha.jpg", image.toCoilModel(useLocalBlossomBridge = false))
}
@Test
fun nullHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = null)
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun invalidHashReturnsOriginalUrl() {
val image = MediaUrlImage(url = "https://cdn.example.com/foo.jpg", hash = "not-hex")
assertEquals("https://cdn.example.com/foo.jpg", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun blossomUriReturnedUnchanged() {
val image = MediaUrlImage(url = "blossom:$sha.jpg?xs=https://cdn.example.com", hash = sha)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun encryptedMediaIsNotTurnedIntoBlossomUri() {
val url = "https://cdn.example.com/$sha.bin"
val image = EncryptedMediaUrlImage(url = url, hash = sha, encryptionAlgo = "aes-gcm", encryptionKey = ByteArray(32), encryptionNonce = ByteArray(12))
val video = EncryptedMediaUrlVideo(url = url, hash = sha, encryptionAlgo = "aes-gcm", encryptionKey = ByteArray(32), encryptionNonce = ByteArray(12))
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
assertEquals(url, video.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun liveStreamReturnsOriginalUrl() {
val video = MediaUrlVideo(url = "https://stream.example.com/play.m3u8", hash = sha, isLiveStream = true)
assertEquals("https://stream.example.com/play.m3u8", video.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeOnRewritesPlainHttpsUrl() {
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://nostr.build/i/abc", result)
}
@Test
fun bridgeOnPreservesNostrBuildPathPrefix() {
val image = MediaUrlImage(url = "https://cdn.nostr.build/i/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.nostr.build/i", result)
}
@Test
fun bridgeOnFlatBlossomPathYieldsHostOnlyXs() {
val image = MediaUrlImage(url = "https://blossom.primal.net/$sha.jpg", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://blossom.primal.net", result)
}
@Test
fun bridgeOnInfersExtensionFromMimeType() {
// BUD-01 allows `<sha>` without an extension; mimeType supplies one.
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha, mimeType = "image/png")
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.png?xs="), "expected png extension from mime, got $result")
}
@Test
fun bridgeOnFallsBackToBinExtension() {
val image = MediaUrlImage(url = "https://nostr.build/i/$sha", hash = sha)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.bin?xs="), "expected bin extension fallback, got $result")
}
@Test
fun nonHttpUrlReturnsOriginal() {
val image = MediaUrlImage(url = "ftp://example.com/file", hash = sha)
assertEquals("ftp://example.com/file", image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun uppercaseHashNormalisedToLowercase() {
val image = MediaUrlImage(url = "https://cdn.example.com/${sha.uppercase()}.jpg", hash = sha.uppercase())
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertTrue(result.startsWith("blossom:$sha.jpg?xs="), "expected lowercase sha, got $result")
}
@Test
fun authorPubKeyAddedAsAsParam() {
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
val image =
MediaUrlImage(
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = authorPub,
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com&as=$authorPub", result)
}
@Test
fun invalidAuthorPubKeyDropped() {
val image =
MediaUrlImage(
url = "https://cdn.example.com/$sha.jpg",
hash = sha,
authorPubKey = "not-a-pubkey",
)
val result = image.toCoilModel(useLocalBlossomBridge = true)
assertEquals("blossom:$sha.jpg?xs=https://cdn.example.com", result)
}
@Test
fun bridgeOnSkipsNonBud01UrlEvenWithImetaHash() {
// The imeta `x` hash refers to a blob whose canonical Blossom location
// is /<sha>.<ext>, but the upstream URL serves it under a different
// path (https://i.nostr.build/M5AwJ.gif). Routing this through the
// local cache would set xs=https://i.nostr.build, and the cache would
// fetch https://i.nostr.build/<sha>.gif on miss, which 404s.
val url = "https://i.nostr.build/M5AwJ.gif"
val image = MediaUrlImage(url = url, hash = sha)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsNonBud01UrlEvenWithImetaHash() {
val url = "https://i.nostr.build/M5AwJ.gif"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlNullReturnsNull() {
assertEquals(null, bridgeProfilePictureUrl(null, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlOffReturnsOriginal() {
assertEquals(
"https://cdn.example.com/avatar.jpg",
bridgeProfilePictureUrl("https://cdn.example.com/avatar.jpg", useBridge = false),
)
}
@Test
fun bridgeProfilePictureUrlExtractsShaFromPath() {
val url = "https://nostr.build/i/$sha.jpg"
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build%2Fi&as=$authorPub",
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
)
}
@Test
fun bridgeProfilePictureUrlPreservesNostrBuildPath() {
val url = "https://cdn.nostr.build/i/$sha.jpg"
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
bridgeProfilePictureUrl(url, useBridge = true),
)
}
@Test
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
val url = "https://nostr.build/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
}
@Test
fun bridgeOnRewritesShaInLastPathSegmentWithHexPrefix() {
// share.yabu.me layout: <cache-prefix-sha>/<blob-sha>.<ext>
val image =
MediaUrlImage(
url = "https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
hash = null,
)
assertEquals(
"blossom:28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
image.toCoilModel(useLocalBlossomBridge = true),
)
}
@Test
fun bridgeProfilePictureUrlRewritesShaInLastPathSegmentWithHexPrefix() {
assertEquals(
"http://127.0.0.1:24242/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp?xs=https%3A%2F%2Fshare.yabu.me%2F84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5",
bridgeProfilePictureUrl(
"https://share.yabu.me/84b0c46ab699ac35eb2ca286470b85e081db2087cdef63932236c397417782f5/28fa4d999af6ae3e4e11bfc2727130ef1b3a13cc0f981e5a93c3996cb2f524e5.webp",
useBridge = true,
),
)
}
@Test
fun bridgeOnSkipsWhenLastSegmentIsNotSha() {
// Per BUD-01 the last segment is the blob; if it isn't a sha256, the
// URL isn't a Blossom blob even if an earlier segment is hex.
val url = "https://example.com/$sha/avatar.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentIsNotSha() {
val url = "https://example.com/$sha/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
// filename isn't a Blossom blob per BUD-01 — the last segment must
// be exactly <sha> or <sha>.<ext>.
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() {
val url = "https://example.com/${sha}_thumb.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeOnUsesUrlShaNotImetaWhenTheyDiffer() {
// On resizing CDNs the imeta `x` (post-resize) can differ from the
// `ox` (original) embedded in the URL. The upstream file is named
// after the URL's sha, so the cache request must use that — using
// the imeta `x` would point xs= at a non-existent path on miss.
val urlSha = "f24026b7281e598973a775adefb1b9a13b9f037a94ac98dd48ccc91b83f4b7b3"
val imetaX = "6932a918de1bfae3bf6611794ff54dd677013d22b760a9212117a0bd9079badf"
val image = MediaUrlImage(url = "https://image.nostr.build/$urlSha.png", hash = imetaX)
assertEquals(
"blossom:$urlSha.png?xs=https://image.nostr.build",
image.toCoilModel(useLocalBlossomBridge = true),
)
}
}
@@ -42,7 +42,7 @@ class DualHttpClientManager(
keyCache: EncryptionKeyCache,
scope: CoroutineScope,
dns: SurgeDns,
shouldBridgeBlossomCache: (() -> Boolean)? = null,
shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = 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.
@@ -20,9 +20,11 @@
*/
package com.vitorpamplona.amethyst.commons.service.http
import okhttp3.Call
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import java.net.ConnectException
@@ -31,6 +33,11 @@ import java.net.ConnectException
* 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
*
* This is the only place feed media, videos and profile pictures are routed
* to the cache: callers keep the real URL, so the Tor decision, Coil/ExoPlayer
* cache keys and decryption-key lookups all see the origin. Tor-proxied
* clients don't carry this interceptor, so Tor-routed media skips the cache.
*
* 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=`
@@ -54,7 +61,9 @@ class LocalBlossomCacheRedirectInterceptor(
private val keyCache: EncryptionKeyCache? = null,
private val onUnreachable: () -> Unit = {},
// Last, so the `LocalBlossomCacheRedirectInterceptor { enabled }` trailing-lambda form binds here.
private val shouldBridge: () -> Boolean,
// Told whether the request is a profile picture (tagged [ProfilePictureRequest]), for the
// "profile pictures only" setting.
private val shouldBridge: (profilePicture: Boolean) -> Boolean,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
@@ -72,7 +81,7 @@ class LocalBlossomCacheRedirectInterceptor(
}
}
if (!shouldBridge()) return chain.proceed(request)
if (!shouldBridge(request.tag(ProfilePictureRequest::class.java) != null)) return chain.proceed(request)
val rewritten = rewriteIfApplicable(request.url) ?: return chain.proceed(request)
@@ -159,3 +168,13 @@ class LocalBlossomCacheRedirectInterceptor(
private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
}
}
/** OkHttp request tag marking a profile-picture download. See [LocalBlossomCacheRedirectInterceptor]. */
object ProfilePictureRequest
/** Tags every call it creates with [ProfilePictureRequest]. */
class ProfilePictureCallFactory(
private val delegate: Call.Factory,
) : Call.Factory {
override fun newCall(request: Request): Call = delegate.newCall(request.newBuilder().tag(ProfilePictureRequest::class.java, ProfilePictureRequest).build())
}
@@ -55,12 +55,13 @@ class OkHttpClientFactory(
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.
* Returns `true` when a sha256-keyed HTTP request should be transparently
* rewritten to the local Blossom cache, given whether it is a profile picture
* (master toggle on, probe up, and either not restricted to profile pictures
* or this is one). When `null`, the interceptor is disabled — useful for
* tests or pre-configuration call sites.
*/
val shouldBridgeBlossomCache: (() -> Boolean)? = null,
val shouldBridgeBlossomCache: ((profilePicture: Boolean) -> Boolean)? = null,
private val onionCache: OnionLocationCache,
/**
* Resource-usage ledger counter, installed OUTERMOST on the shared base
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.service.http
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -87,6 +88,38 @@ class LocalBlossomCacheRedirectSafetyTest {
assertEquals(bridged, sent.single().url.toString())
}
@Test
fun profilePicturesOnlyBridgesTaggedRequestsAlone() {
// The "profile pictures only" setting: bridge a request only when it is a profile picture.
val interceptor = LocalBlossomCacheRedirectInterceptor { profilePicture -> profilePicture }
val feedImage = mutableListOf<Request>()
interceptor.intercept(chain(Request.Builder().url(origin).build(), feedImage)).close()
assertEquals(origin, feedImage.single().url.toString())
val avatar = mutableListOf<Request>()
val tagged =
Request
.Builder()
.url(origin)
.tag(ProfilePictureRequest::class.java, ProfilePictureRequest)
.build()
interceptor.intercept(chain(tagged, avatar)).close()
assertEquals(bridged, avatar.single().url.toString())
}
@Test
fun profilePictureCallFactoryTagsItsRequests() {
var seen: Request? = null
val factory =
ProfilePictureCallFactory { request ->
seen = request
OkHttpClient().newCall(request)
}
factory.newCall(Request.Builder().url(origin).build())
assertNotNull(seen?.tag(ProfilePictureRequest::class.java))
}
@Test
fun serverSpecificMethodsAreNotBridged() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }