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
|
val host = url.host
|
||||||
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
|
if (host == LOCAL_CACHE_HOST || host.equals("localhost", ignoreCase = true)) return null
|
||||||
|
|
||||||
val (sha, ext) = findSha256AndExtensionInPath(url) ?: return null
|
val (shaSegmentIndex, sha, ext) = findSha256AndExtensionInPath(url) ?: return null
|
||||||
val originalHostBase = "${url.scheme}://$host" + if (url.port != HttpUrl.defaultPort(url.scheme)) ":${url.port}" else ""
|
val serverBase = buildServerBase(url, shaSegmentIndex)
|
||||||
|
|
||||||
return "$LOCAL_CACHE_BASE/$sha.$ext"
|
return "$LOCAL_CACHE_BASE/$sha.$ext"
|
||||||
.toHttpUrl()
|
.toHttpUrl()
|
||||||
.newBuilder()
|
.newBuilder()
|
||||||
.addQueryParameter("xs", originalHostBase)
|
.addQueryParameter("xs", serverBase)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun findSha256AndExtensionInPath(url: HttpUrl): Pair<String, String>? {
|
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
|
||||||
for (segment in url.pathSegments) {
|
url.pathSegments.forEachIndexed { index, segment ->
|
||||||
val match = SHA256_SEGMENT_REGEX.find(segment) ?: continue
|
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return@forEachIndexed
|
||||||
val sha = match.value.lowercase()
|
val sha = match.value.lowercase()
|
||||||
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
||||||
return sha to ext
|
return Triple(index, sha, ext)
|
||||||
}
|
}
|
||||||
return null
|
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(
|
private fun guessExtensionFrom(
|
||||||
segment: String,
|
segment: String,
|
||||||
sha: String,
|
sha: String,
|
||||||
|
|||||||
+13
-1
@@ -114,7 +114,19 @@ class LocalBlossomCacheRedirectInterceptorTest {
|
|||||||
val captured = mutableListOf<String>()
|
val captured = mutableListOf<String>()
|
||||||
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
|
val response = interceptor.intercept(fakeChain("https://nostr.build/i/cache/$sha.webp", captured))
|
||||||
assertEquals(
|
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(),
|
captured.single(),
|
||||||
)
|
)
|
||||||
response.close()
|
response.close()
|
||||||
|
|||||||
+33
-4
@@ -76,11 +76,11 @@ fun bridgeProfilePictureUrl(
|
|||||||
|
|
||||||
val sha = extractSha256FromUrlPath(url) ?: return url
|
val sha = extractSha256FromUrlPath(url) ?: return url
|
||||||
val ext = guessExtension(url, null)
|
val ext = guessExtension(url, null)
|
||||||
val hostBase = extractHostBase(url) ?: return url
|
val serverBase = extractServerBase(url, sha) ?: return url
|
||||||
|
|
||||||
val params =
|
val params =
|
||||||
buildList {
|
buildList {
|
||||||
add("xs=${percentEncode(hostBase)}")
|
add("xs=${percentEncode(serverBase)}")
|
||||||
authorPubKey
|
authorPubKey
|
||||||
?.lowercase()
|
?.lowercase()
|
||||||
?.takeIf { sha256HexRegex.matches(it) }
|
?.takeIf { sha256HexRegex.matches(it) }
|
||||||
@@ -110,7 +110,7 @@ private fun bridgeUrl(
|
|||||||
?: return url
|
?: return url
|
||||||
|
|
||||||
val ext = guessExtension(url, mimeType)
|
val ext = guessExtension(url, mimeType)
|
||||||
val hostBase = extractHostBase(url) ?: return url
|
val serverBase = extractServerBase(url, sha) ?: return url
|
||||||
|
|
||||||
val authors =
|
val authors =
|
||||||
authorPubKey
|
authorPubKey
|
||||||
@@ -122,7 +122,7 @@ private fun bridgeUrl(
|
|||||||
return BlossomUri(
|
return BlossomUri(
|
||||||
sha256 = sha,
|
sha256 = sha,
|
||||||
extension = ext,
|
extension = ext,
|
||||||
servers = listOf(hostBase),
|
servers = listOf(serverBase),
|
||||||
authors = authors,
|
authors = authors,
|
||||||
size = null,
|
size = null,
|
||||||
).toUriString()
|
).toUriString()
|
||||||
@@ -172,6 +172,35 @@ private fun guessExtension(
|
|||||||
return "bin"
|
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? {
|
private fun extractHostBase(url: String): String? {
|
||||||
val schemeEnd = url.indexOf("://")
|
val schemeEnd = url.indexOf("://")
|
||||||
if (schemeEnd < 0) return null
|
if (schemeEnd < 0) return null
|
||||||
|
|||||||
+25
-2
@@ -61,7 +61,21 @@ class MediaUrlContentExtTest {
|
|||||||
fun bridgeOnRewritesPlainHttpsUrl() {
|
fun bridgeOnRewritesPlainHttpsUrl() {
|
||||||
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
|
val image = MediaUrlImage(url = "https://nostr.build/i/abc/$sha.jpg", hash = sha)
|
||||||
val result = image.toCoilModel(useLocalBlossomBridge = true)
|
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
|
@Test
|
||||||
@@ -134,11 +148,20 @@ 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(
|
||||||
"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),
|
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
|
@Test
|
||||||
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
|
fun bridgeProfilePictureUrlNoShaInPathReturnsOriginal() {
|
||||||
val url = "https://nostr.build/avatar.jpg"
|
val url = "https://nostr.build/avatar.jpg"
|
||||||
|
|||||||
Reference in New Issue
Block a user