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:
+23
-7
@@ -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,
|
||||
|
||||
+13
-1
@@ -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()
|
||||
|
||||
+33
-4
@@ -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
|
||||
|
||||
+25
-2
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user