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
* hash isn't available on a structured model. Tries to recover the sha256
* from the URL path itself; falls back to the original URL when no hash
* can be determined.
* 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.
*
* @param authorPubKey 64-char lowercase hex pubkey to send as `as=` so the
* local cache can consult that author's BUD-03 server list.
* 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
return bridgeUrl(
url = url,
useBridge = useBridge,
explicitHash = null,
mimeType = null,
authorPubKey = authorPubKey,
skipBridge = false,
)
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 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(
url: String,
useBridge: Boolean,
@@ -109,6 +128,21 @@ private fun bridgeUrl(
).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? {
val pathPart = url.substringBefore('?').substringBefore('#')
val match = sha256InPathRegex.find(pathPart) ?: return null
@@ -134,7 +134,7 @@ class MediaUrlContentExtTest {
val url = "https://nostr.build/i/$sha.jpg"
val authorPub = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e5"
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),
)
}
@@ -144,4 +144,10 @@ class MediaUrlContentExtTest {
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))
}
}