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:
+9
-8
@@ -72,16 +72,17 @@ class LocalBlossomCacheRedirectInterceptor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
|
private fun findSha256AndExtensionInPath(url: HttpUrl): Triple<Int, String, String>? {
|
||||||
// Per Blossom (BUD-01) the blob is always the last path segment. If the
|
// Per Blossom (BUD-01) the last path segment must be exactly
|
||||||
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
|
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
|
||||||
// must leave it alone — even if an earlier path segment happens to be
|
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
|
||||||
// a 64-char hex (e.g. a per-user cache prefix). The prefix segments
|
// the bridge must leave them alone — rewriting them would point the
|
||||||
// are preserved verbatim via `buildServerBase`.
|
// 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
|
val lastIndex = url.pathSegments.lastIndex
|
||||||
if (lastIndex < 0) return null
|
if (lastIndex < 0) return null
|
||||||
val segment = url.pathSegments[lastIndex]
|
val segment = url.pathSegments[lastIndex]
|
||||||
val match = SHA256_SEGMENT_REGEX.find(segment) ?: return null
|
val match = BLOSSOM_LAST_SEGMENT_REGEX.matchEntire(segment) ?: return null
|
||||||
val sha = match.value.lowercase()
|
val sha = match.groupValues[1].lowercase()
|
||||||
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
val ext = guessExtensionFrom(segment, sha) ?: "bin"
|
||||||
return Triple(lastIndex, sha, ext)
|
return Triple(lastIndex, sha, ext)
|
||||||
}
|
}
|
||||||
@@ -119,6 +120,6 @@ class LocalBlossomCacheRedirectInterceptor(
|
|||||||
const val LOCAL_CACHE_HOST = "127.0.0.1"
|
const val LOCAL_CACHE_HOST = "127.0.0.1"
|
||||||
const val LOCAL_CACHE_PORT = 24242
|
const val LOCAL_CACHE_PORT = 24242
|
||||||
const val LOCAL_CACHE_BASE = "http://$LOCAL_CACHE_HOST:$LOCAL_CACHE_PORT"
|
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})(?:\\.[^./]+)?$")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
@@ -163,6 +163,31 @@ class LocalBlossomCacheRedirectInterceptorTest {
|
|||||||
response.close()
|
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(
|
private fun fakeChain(
|
||||||
url: String,
|
url: String,
|
||||||
captured: MutableList<String>,
|
captured: MutableList<String>,
|
||||||
|
|||||||
+8
-7
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.richtext
|
|||||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUri
|
||||||
|
|
||||||
private val sha256HexRegex = Regex("[0-9a-f]{64}")
|
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.
|
* 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? {
|
private fun extractSha256FromUrlPath(url: String): String? {
|
||||||
// Per Blossom (BUD-01) the blob is always the last path segment. If the
|
// Per Blossom (BUD-01) the last path segment must be exactly
|
||||||
// last segment isn't a sha256, this isn't a Blossom URL and the bridge
|
// `<sha256>` or `<sha256>.<ext>`. URLs whose filename merely embeds a
|
||||||
// must leave it alone — even if an earlier path segment happens to be
|
// 64-char hex (e.g. "nostr.build_<sha>.jpg") aren't Blossom blobs and
|
||||||
// a 64-char hex (e.g. a per-user cache prefix).
|
// 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 pathPart = url.substringBefore('?').substringBefore('#')
|
||||||
val lastSegment = pathPart.substringAfterLast('/')
|
val lastSegment = pathPart.substringAfterLast('/')
|
||||||
val match = sha256InPathRegex.find(lastSegment) ?: return null
|
val match = blossomLastSegmentRegex.matchEntire(lastSegment) ?: return null
|
||||||
return match.value.lowercase()
|
return match.groupValues[1].lowercase()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun guessExtension(
|
private fun guessExtension(
|
||||||
|
|||||||
+23
@@ -213,4 +213,27 @@ class MediaUrlContentExtTest {
|
|||||||
val url = "https://example.com/$sha/avatar.jpg"
|
val url = "https://example.com/$sha/avatar.jpg"
|
||||||
assertEquals(url, bridgeProfilePictureUrl(url, useBridge = true))
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user