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
@@ -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))
}
}