fix(blossom): preserve upstream path prefix in xs= hint

The bridge was emitting `xs=https://cdn.nostr.build` for URLs like
`https://cdn.nostr.build/i/<sha>.jpg`, dropping the `/i/` prefix. The
local cache appends `/<sha>` to the xs server hint per spec, so it would
try to fetch `https://cdn.nostr.build/<sha>` — a 404 on nostr.build's
non-Blossom CDN scheme.

Anchor the server-base extraction on the slash immediately preceding the
sha-bearing path segment so we emit `xs=https://cdn.nostr.build/i`.
The cache then appends `/<sha>` and reaches the original blob.

Affects all three bridge entry points:
- MediaUrlContent.toCoilModel (note media)
- bridgeProfilePictureUrl (profile pictures)
- LocalBlossomCacheRedirectInterceptor (everything else via OkHttp)

Flat Blossom paths (`<host>/<sha>`) keep emitting `xs=<host>` unchanged.
This commit is contained in:
Claude
2026-05-08 11:35:53 +00:00
parent aa598b9949
commit bb871f6c71
4 changed files with 94 additions and 14 deletions
@@ -61,26 +61,42 @@ class LocalBlossomCacheRedirectInterceptor(
val host = url.host
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
val (sha, ext) = findSha256AndExtensionInPath(url) ?: return null
val originalHostBase = "${url.scheme}://$host" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else ""
val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null
val serverBase = buildServerBase(url, shaSegmentIndex)
return "$LOCAL_CACHE_BASE/$sha.$ext"
.toHttpUrl()
.newBuilder()
.addQueryParameter("xs", originalHostBase)
.addQueryParameter("xs", serverBase)
.build()
}
private fun findSha256AndExtensionInPath(url: HttpUrl): Pair<String, String>? {
for (segment in url.pathSegments) {
val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
url.pathSegments.forEachIndexed { index, segment ->
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed
val sha = match.value.lowercase()
val ext = guessExtensionFrom(segment, sha) ?: "bin"
return sha to ext
return Triple(index, sha, ext)
}
return null
}
/**
* Builds the URL prefix that the local cache should append `/<sha>` to.
* Preserves any path prefix the upstream CDN uses (e.g. `/i` for
* `https://cdn.nostr.build/i/<sha>`) so the cache can fetch the blob
* from its actual location on miss.
*/
private fun buildServerBase(
url: HttpUrl,
shaSegmentIndex: Int,
): String {
val origin = "${url.scheme}://${url.host}" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else ""
if (shaSegmentIndex == 0) return origin
val prefixSegments = url.pathSegments.subList(0, shaSegmentIndex)
return "$origin/" + prefixSegments.joinToString("/")
}
private fun guessExtensionFrom(
segment: String,
sha: String,
@@ -114,7 +114,19 @@ class LocalBlossomCacheRedirectInterceptorTest {
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build",
"http://127.0.0.1:24242/$sha.webp?xs=https%3A%2F%2Fnostr.build%2Fi%2Fcache",
captured.single(),
)
response.close()
}
@Test
fun bridgeOnPreservesNostrBuildPathPrefix() {
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val response = interceptor.intercept(fakeChain("https://cdn.nostr.build/i/$sha.jpg", captured))
assertEquals(
"http://127.0.0.1:24242/$sha.jpg?xs=https%3A%2F%2Fcdn.nostr.build%2Fi",
captured.single(),
)
response.close()
@@ -76,11 +76,11 @@ fun bridgeProfilePictureUrl(
val sha = extractSha256FromUrlPath(url) ?: return url
val ext = guessExtension(url, null)
val hostBase = extractHostBase(url) ?: return url
val serverBase = extractServerBase(url, sha) ?: return url
val params =
buildList {
add("xs=${percentEncode(hostBase)}")
add("xs=${percentEncode(serverBase)}")
authorPubKey
?.lowercase()
?.takeIf { sha256HexRegex.matches(it) }
@@ -110,7 +110,7 @@ private fun bridgeUrl(
?: return url
val ext = guessExtension(url, mimeType)
val hostBase = extractHostBase(url) ?: return url
val serverBase = extractServerBase(url, sha) ?: return url
val authors =
authorPubKey
@@ -122,7 +122,7 @@ private fun bridgeUrl(
return BlossomUri(
sha256 = sha,
extension = ext,
servers = listOf(hostBase),
servers = listOf(serverBase),
authors = authors,
size = null,
).toUriString()
@@ -172,6 +172,35 @@ private fun guessExtension(
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.indexOf(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
@@ -61,7 +61,21 @@ class MediaUrlContentExtTest {
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", result)
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
@@ -134,11 +148,20 @@ class MediaUrlContentExtTest {
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&as=$authorPub",
"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"