fix(blossom): profile pictures now route through local cache

bridgeProfilePictureUrl previously returned a `blossom:` URI, but
profile pictures go through ProfilePictureFetcher (Coil routes by type
on `ProfilePictureUrl`), which feeds the URL straight to NetworkFetcher
and never sees BlossomFetcher. NetworkFetcher then tries to issue an
HTTP request against the `blossom:` scheme and fails — silently — so
profile pictures from Blossom servers stopped loading.

Return a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pub>`
URL instead. The OkHttp request goes to the local cache normally; the
new redirect interceptor sees it's already on localhost and passes
through unchanged.
This commit is contained in:
Claude
2026-05-08 10:25:30 +00:00
parent 9451f7ca9a
commit aa598b9949
2 changed files with 55 additions and 15 deletions
@@ -49,30 +49,49 @@ fun MediaUrlContent.toCoilModel(useLocalBlossomBridge: Boolean): String =
) )
/** /**
* Bridge entry point for raw URL strings (e.g. profile pictures) where the * Bridge entry point for raw URL strings (e.g. profile pictures) that go
* hash isn't available on a structured model. Tries to recover the sha256 * through a Coil fetcher routed by type (`ProfilePictureUrl`) and therefore
* from the URL path itself; falls back to the original URL when no hash * bypass [com.vitorpamplona.quartz.nipB7Blossom.BlossomUri] processing
* can be determined. * entirely.
* *
* @param authorPubKey 64-char lowercase hex pubkey to send as `as=` so the * Returns a direct `http://127.0.0.1:24242/<sha>.<ext>?xs=<host>&as=<pubkey>`
* local cache can consult that author's BUD-03 server list. * 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( fun bridgeProfilePictureUrl(
url: String?, url: String?,
useBridge: Boolean, useBridge: Boolean,
authorPubKey: String? = null, authorPubKey: String? = null,
localCacheBase: String = DEFAULT_LOCAL_CACHE_BASE,
): String? { ): String? {
if (url == null) return null if (url == null) return null
return bridgeUrl( if (!useBridge) return url
url = url, if (url.startsWith("blossom:", ignoreCase = true)) return url
useBridge = useBridge, if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) return url
explicitHash = null,
mimeType = null, val sha = extractSha256FromUrlPath(url) ?: return url
authorPubKey = authorPubKey, val ext = guessExtension(url, null)
skipBridge = false, val hostBase = extractHostBase(url) ?: return url
)
val params =
buildList {
add("xs=${percentEncode(hostBase)}")
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( private fun bridgeUrl(
url: String, url: String,
useBridge: Boolean, useBridge: Boolean,
@@ -109,6 +128,21 @@ private fun bridgeUrl(
).toUriString() ).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? { private fun extractSha256FromUrlPath(url: String): String? {
val pathPart = url.substringBefore('?').substringBefore('#') val pathPart = url.substringBefore('?').substringBefore('#')
val match = sha256InPathRegex.find(pathPart) ?: return null val match = sha256InPathRegex.find(pathPart) ?: return null
@@ -134,7 +134,7 @@ class MediaUrlContentExtTest {
val url = "https://nostr.build/i/$sha.jpg" val url = "https://nostr.build/i/$sha.jpg"
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5" val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
assertEquals( assertEquals(
"blossom:$sha.jpg?xs=https://nostr.build&as=$authorPub", "http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fnostr.build&as=$authorPub",
bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub), bridgeProfilePictureUrl(url, useBridge = true, authorPubKey = authorPub),
) )
} }
@@ -144,4 +144,10 @@ class MediaUrlContentExtTest {
val url = "https://nostr.build/avatar.jpg" val url = "https://nostr.build/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true)) assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
} }
@Test
fun bridgeProfilePictureUrlBlossomUriReturnedUnchanged() {
val uri = "blossom:$sha.jpg?xs=https://nostr.build"
assertEquals(uri, bridgeProfilePictureUrl(uri, useBridge = true))
}
} }