fix(blossom): require sha256 at start of last path segment

URLs like https://nostr.build/i/nostr.build_<sha>.jpg embed a 64-char
hex in the filename but aren't BUD-01 Blossom blobs — the last path
segment must be exactly <sha256> or <sha256>.<ext>. The previous regex
matched the embedded hash and rewrote the request to the local cache
with xs=https://nostr.build/i, which 404s on miss because the real blob
lives at /i/nostr.build_<sha>.jpg, not /i/<sha>.

Switch both extraction sites (MediaUrlContentExt and the OkHttp
interceptor) to a matchEntire regex that anchors the sha at the start
of the segment with at most a .<ext> suffix.
This commit is contained in:
Claude
2026-05-15 22:32:01 +00:00
parent 74c0d6906e
commit 62d97c6d82
4 changed files with 65 additions and 15 deletions
@@ -72,16 +72,17 @@ class LocalBlossomCacheRedirectInterceptor(
}
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
// Per Blossom (BUD-01) the blob is always the last path segment. If the
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
// must leave it alone — even if an earlier path segment happens to be
// a 64-char hex (e.g. a per-user cache prefix). The prefix segments
// are preserved verbatim via `buildServerBase`.
// Per Blossom (BUD-01) the last path segment must be exactly
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
// the bridge must leave them alone — rewriting them would point the
// local cache at a fallback `xs=` server that doesn't host the blob.
// Prefix segments are preserved verbatim via `buildServerBase`.
val lastIndex = url.pathSegments.lastIndex
if (lastIndex < 0) return null
val segment = url.pathSegments[lastIndex]
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null
val sha = match.value.lowercase()
val match = BLOSSOM_LAST_SEGMENT_REGEX.matchEntire(segment) ?: return null
val sha = match.groupValues[1].lowercase()
val ext = guessExtensionFrom(segment, sha) ?: "bin"
return Triple(lastIndex, sha, ext)
}
@@ -119,6 +120,6 @@ class LocalBlossomCacheRedirectInterceptor(
const val LOCAL_CACHE_HOST = "127.0.0.1"
const val LOCAL_CACHE_PORT = 24242
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
private val SHA256_SEGMENT_REGEX = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
private val BLOSSOM_LAST_SEGMENT_REGEX = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
}
}
@@ -163,6 +163,31 @@ class LocalBlossomCacheRedirectInterceptorTest {
response.close()
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
// filename isn't a Blossom blob — rewriting to the local cache and
// sending xs=https://nostr.build/i would 404 because the real blob
// is at /i/nostr.build_<sha>.jpg, not /i/<sha>.
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
assertEquals(url, captured.single())
response.close()
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() {
// A filename like <sha>_thumb.jpg isn't a BUD-01 blob URL either.
val interceptor = LocalBlossomCacheRedirectInterceptor { true }
val captured = mutableListOf<String>()
val url = "https://example.com/${sha}_thumb.jpg"
val response = interceptor.intercept(fakeChain(url, captured))
assertEquals(url, captured.single())
response.close()
}
private fun fakeChain(
url: String,
captured: MutableList<String>,
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.richtext
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
private val sha256HexRegex = Regex("[0-9a-f]{64}")
private val sha256InPathRegex = Regex("(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
private val blossomLastSegmentRegex = Regex("^([0-9a-fA-F]{64})(?:\\.[^./]+)?$")
/**
* Converts this media content into a Coil/ExoPlayer-friendly model string.
@@ -144,14 +144,15 @@ private fun percentEncode(input: String): String {
}
private fun extractSha256FromUrlPath(url: String): String? {
// Per Blossom (BUD-01) the blob is always the last path segment. If the
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
// must leave it alone — even if an earlier path segment happens to be
// a 64-char hex (e.g. a per-user cache prefix).
// Per Blossom (BUD-01) the last path segment must be exactly
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
// the bridge must leave them alone — rewriting them would point the
// local cache at a fallback `xs=` server that doesn't host the blob.
val pathPart = url.substringBefore('?').substringBefore('#')
val lastSegment = pathPart.substringAfterLast('/')
val match = sha256InPathRegex.find(lastSegment) ?: return null
return match.value.lowercase()
val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null
return match.groupValues[1].lowercase()
}
private fun guessExtension(
@@ -213,4 +213,27 @@ class MediaUrlContentExtTest {
val url = "https://example.com/$sha/avatar.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
// nostr.build /i/ layout: <prefix>_<sha>.<ext>. The hex inside the
// filename isn't a Blossom blob per BUD-01 — the last segment must
// be exactly <sha> or <sha>.<ext>.
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
@Test
fun bridgeProfilePictureUrlSkipsWhenLastSegmentHasNonHexPrefixBeforeSha() {
val url = "https://nostr.build/i/nostr.build_$sha.jpg"
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
}
@Test
fun bridgeOnSkipsWhenLastSegmentHasSuffixAfterSha() {
val url = "https://example.com/${sha}_thumb.jpg"
val image = MediaUrlImage(url = url, hash = null)
assertEquals(url, image.toCoilModel(useLocalBlossomBridge = true))
}
}