mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
feat(blossom): full-client protocol support across quartz, commons, CLI and Android
Extends Blossom support toward a full client on both the CLI and the mobile app. Quartz (protocol): - BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11 `server` domain scoping on every factory (stops replayable upload/delete tokens) - BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and BUD-07 payment header constants, and a lowercase bare-domain helper - BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08) - BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning) - BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders Commons (shared JVM client, now in jvmAndroid so Android shares it too): - BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05), preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling - BlossomAuth: media/list/delete passthroughs with server scoping CLI (first-class): - amy blossom now routes all HTTP through the shared client and adds `media` and `report` verbs; auth tokens are scoped to --server Android (first-class): - uploads mirror to the user's other Blossom servers (BUD-04) best-effort - new "Manage stored files" screen: per-server presence matrix (BUD-02 list + BUD-01 HEAD), delete, mirror-to-missing, and report actions Tests: quartz URL/auth/descriptor/payment parsing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
+102
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipB7Blossom
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class BlossomAuthorizationEventTest {
|
||||
private val signer = NostrSignerInternal(KeyPair())
|
||||
private val hash = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
|
||||
@Test
|
||||
fun uploadAuthHasRequiredTags() =
|
||||
runTest {
|
||||
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 184292, "Uploading cat.png", signer)
|
||||
|
||||
assertEquals(BlossomAuthorizationEvent.KIND, event.kind)
|
||||
assertEquals("upload", event.tags.first { it[0] == "t" }[1])
|
||||
assertEquals(hash, event.tags.first { it[0] == "x" }[1])
|
||||
assertEquals("184292", event.tags.first { it[0] == "size" }[1])
|
||||
// NIP-40 expiration must be in the future.
|
||||
val expiration = event.tags.first { it[0] == "expiration" }[1].toLong()
|
||||
assertTrue(expiration > event.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaAuthUsesMediaVerb() =
|
||||
runTest {
|
||||
val event = BlossomAuthorizationEvent.createMediaAuth(hash, 100, "Optimizing", signer)
|
||||
assertEquals("media", event.tags.first { it[0] == "t" }[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun serverScopeEmitsLowercaseBareDomainTags() =
|
||||
runTest {
|
||||
val event =
|
||||
BlossomAuthorizationEvent.createDeleteAuth(
|
||||
hash,
|
||||
"Delete blob",
|
||||
signer,
|
||||
servers = listOf("https://CDN.Example.com/", "https://blossom.band:443/upload"),
|
||||
)
|
||||
|
||||
val serverTags = event.tags.filter { it[0] == "server" }.map { it[1] }
|
||||
assertEquals(listOf("cdn.example.com", "blossom.band"), serverTags)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deduplicatesServerScopeByDomain() =
|
||||
runTest {
|
||||
val event =
|
||||
BlossomAuthorizationEvent.createUploadAuth(
|
||||
hash,
|
||||
1,
|
||||
"Upload",
|
||||
signer,
|
||||
servers = listOf("https://cdn.example.com/a", "https://cdn.example.com/b"),
|
||||
)
|
||||
assertEquals(1, event.tags.count { it[0] == "server" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noServerScopeWhenListEmpty() =
|
||||
runTest {
|
||||
val event = BlossomAuthorizationEvent.createUploadAuth(hash, 1, "Upload", signer)
|
||||
assertTrue(event.tags.none { it[0] == "server" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authorizationHeaderIsNostrPrefixedBase64OfTheEvent() =
|
||||
runTest {
|
||||
val event = BlossomAuthorizationEvent.createListAuth(signer, "List blobs")
|
||||
val header = event.toAuthorizationHeader()
|
||||
|
||||
assertTrue(header.startsWith(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME))
|
||||
val decoded = Base64.decode(header.removePrefix(BlossomAuthorizationEvent.AUTH_HEADER_SCHEME)).decodeToString()
|
||||
assertEquals(event.toJson(), decoded)
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipB7Blossom
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class BlossomServerUrlTest {
|
||||
private val sha256 = "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553"
|
||||
private val pubkey = "a8f3721a0dc1b4d5c12f4cc7c54ae14071eb9c1b4f9b2cf0d4ab22c0e9f0c7e0"
|
||||
|
||||
@Test
|
||||
fun buildsEndpointPaths() {
|
||||
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com"))
|
||||
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com"))
|
||||
assertEquals("https://cdn.example.com/media", BlossomServerUrl.media("https://cdn.example.com"))
|
||||
assertEquals("https://cdn.example.com/report", BlossomServerUrl.report("https://cdn.example.com"))
|
||||
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com", pubkey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun collapsesTrailingSlash() {
|
||||
assertEquals("https://cdn.example.com/upload", BlossomServerUrl.upload("https://cdn.example.com/"))
|
||||
assertEquals("https://cdn.example.com/mirror", BlossomServerUrl.mirror("https://cdn.example.com/"))
|
||||
assertEquals("https://cdn.example.com/list/$pubkey", BlossomServerUrl.list("https://cdn.example.com/", pubkey))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildsBlobUrlWithOptionalExtension() {
|
||||
assertEquals("https://cdn.example.com/$sha256", BlossomServerUrl.blob("https://cdn.example.com", sha256))
|
||||
assertEquals("https://cdn.example.com/$sha256.png", BlossomServerUrl.blob("https://cdn.example.com", sha256, "png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractsLowercaseBareDomainForServerScope() {
|
||||
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com"))
|
||||
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://CDN.Example.com/"))
|
||||
assertEquals("cdn.example.com", BlossomServerUrl.domain("https://cdn.example.com:8443/upload"))
|
||||
assertEquals("blossom.band", BlossomServerUrl.domain("https://blossom.band"))
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nipB7Blossom
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class BlossomUploadResultTest {
|
||||
@Test
|
||||
fun parsesMinimalDescriptor() {
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"url": "https://cdn.example.com/b167.png",
|
||||
"sha256": "b167",
|
||||
"size": 184292,
|
||||
"type": "image/png",
|
||||
"uploaded": 1725105921
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
|
||||
assertEquals("https://cdn.example.com/b167.png", result.url)
|
||||
assertEquals("b167", result.sha256)
|
||||
assertEquals(184292, result.size)
|
||||
assertEquals("image/png", result.type)
|
||||
assertNull(result.ox)
|
||||
assertNull(result.nip94)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesMediaDescriptorWithOriginalHashAndNip94() {
|
||||
// BUD-05 /media returns the optimized blob's hash in `sha256` and the
|
||||
// original in `ox`; BUD-08 adds the `nip94` tag array.
|
||||
val json =
|
||||
"""
|
||||
{
|
||||
"url": "https://cdn.example.com/opt.png",
|
||||
"sha256": "optimizedhash",
|
||||
"ox": "originalhash",
|
||||
"size": 123,
|
||||
"type": "image/png",
|
||||
"uploaded": 1725105921,
|
||||
"nip94": [
|
||||
["url", "https://cdn.example.com/opt.png"],
|
||||
["m", "image/png"],
|
||||
["x", "optimizedhash"],
|
||||
["size", "123"]
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
|
||||
assertEquals("optimizedhash", result.sha256)
|
||||
assertEquals("originalhash", result.ox)
|
||||
assertEquals(4, result.nip94?.size)
|
||||
assertEquals(listOf("m", "image/png"), result.nip94?.get(1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresUnknownFields() {
|
||||
val json = """{"url":"https://x/y","sha256":"a","serverSpecific":{"foo":1},"extra":"z"}"""
|
||||
val result = JsonMapper.fromJson<BlossomUploadResult>(json)
|
||||
assertEquals("a", result.sha256)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readsBud07PaymentHeaders() {
|
||||
val headers =
|
||||
mapOf(
|
||||
BlossomServerUrl.X_CASHU_HEADER to "\"cashuBToken...\"",
|
||||
BlossomServerUrl.X_LIGHTNING_HEADER to "lnbc10n1...",
|
||||
BlossomServerUrl.REASON_HEADER to "Payment required: 10 sats",
|
||||
)
|
||||
val payment = BlossomPaymentRequired.fromHeaders { headers[it] }
|
||||
|
||||
assertEquals("cashuBToken...", payment.cashu)
|
||||
assertEquals("lnbc10n1...", payment.lightning)
|
||||
assertEquals("Payment required: 10 sats", payment.reason)
|
||||
assertEquals(true, payment.hasPaymentOption())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paymentWithNoMethodsIsNotPayable() {
|
||||
val payment = BlossomPaymentRequired.fromHeaders { null }
|
||||
assertEquals(false, payment.hasPaymentOption())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user