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:
Claude
2026-07-17 23:36:41 +00:00
parent cd5060e5dc
commit bb03cd2a3c
21 changed files with 1557 additions and 254 deletions
@@ -1594,12 +1594,26 @@ class Account(
hash: HexKey,
size: Long,
alt: String,
) = blossomServers.createBlossomUploadAuth(hash, size, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
) = blossomServers.createBlossomDeleteAuth(hash, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomDeleteAuth(hash, alt, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomListAuth(alt, servers)
suspend fun boost(note: Note) {
val powDifficulty = powDifficultyFor(RepostEvent.KIND)
@@ -120,12 +120,26 @@ class BlossomServerListState(
hash: HexKey,
size: Long,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers)
}
/**
@@ -24,6 +24,7 @@ import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -34,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.flow.MutableStateFlow
@@ -211,22 +213,32 @@ class UploadOrchestrator {
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Scope the upload token to the target server (BUD-11) so it can't be replayed elsewhere.
httpAuth =
if (forcedSigner != null) {
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner, listOf(serverBaseUrl)) }
} else {
account::createBlossomUploadAuth
{ hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt, listOf(serverBaseUrl)) }
},
context = context,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Best-effort — a mirror failure never fails the upload the user already completed.
if (finalState is UploadingState.Finished && forcedSigner == null) {
mirrorToOtherServers(result, serverBaseUrl, account)
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: Exception) {
@@ -235,6 +247,44 @@ class UploadOrchestrator {
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* after the primary upload is confirmed, so the user's post is never delayed by
* a slow/offline mirror; failures are swallowed per-server. Requires the blob's
* sha256 (to scope the mirror auth and let server B verify the download).
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.hostNameFlow.value
.filter { it.type == ServerType.Blossom && BlossomServerUrl.domain(it.baseUrl) != primaryDomain }
.map { it.baseUrl }
.distinct()
if (targets.isEmpty()) return
updateState(0.95, UploadingState.ServerProcessing)
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash", listOf(target)).toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(sourceUrl, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}
private suspend fun verifyHeader(
uploadResult: MediaUploadResult,
localContentType: String?,
@@ -0,0 +1,286 @@
/*
* 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.amethyst.ui.actions.mediaServers
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun BlossomBlobManagerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: BlossomBlobManagerViewModel = viewModel()
vm.init(accountViewModel)
LaunchedEffect(accountViewModel) { vm.refresh() }
val blobs by vm.blobs.collectAsStateWithLifecycle()
val loading by vm.isLoading.collectAsStateWithLifecycle()
val error by vm.error.collectAsStateWithLifecycle()
Scaffold(
topBar = { TopBarWithBackButton(stringRes(R.string.manage_stored_files), nav) },
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
start = 12.dp,
end = 12.dp,
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
) {
when {
loading && blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { CircularProgressIndicator() }
error != null && blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(error ?: "", color = MaterialTheme.colorScheme.error)
TextButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
}
blobs.isEmpty() ->
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { Text(stringRes(R.string.manage_stored_files_empty), color = MaterialTheme.colorScheme.grayText) }
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobCard(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
) {
var deleteMenuOpen by remember { mutableStateOf(false) }
var reportOpen by remember { mutableStateOf(false) }
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = row.hash.take(16) + "",
style = MaterialTheme.typography.titleSmall,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
row.serversPresent.forEach { server ->
AssistChip(
onClick = {},
label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.primary,
),
)
}
row.serversMissing.forEach { server ->
AssistChip(
onClick = {},
label = { Text(vm.hostOf(server), style = MaterialTheme.typography.labelSmall) },
colors =
AssistChipDefaults.assistChipColors(
labelColor = MaterialTheme.colorScheme.grayText,
),
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (row.serversMissing.isNotEmpty() && row.url != null) {
OutlinedButton(onClick = { vm.mirrorToMissing(row) }) {
Text(stringRes(R.string.blossom_mirror_to_missing), style = MaterialTheme.typography.labelMedium)
}
}
if (row.serversPresent.isNotEmpty()) {
Column {
OutlinedButton(onClick = { deleteMenuOpen = true }) {
Text(stringRes(R.string.blossom_delete_from), style = MaterialTheme.typography.labelMedium)
}
DropdownMenu(expanded = deleteMenuOpen, onDismissRequest = { deleteMenuOpen = false }) {
row.serversPresent.forEach { server ->
DropdownMenuItem(
text = { Text(vm.hostOf(server)) },
onClick = {
deleteMenuOpen = false
vm.delete(row.hash, server)
},
)
}
}
}
}
if (row.serversPresent.isNotEmpty()) {
TextButton(onClick = { reportOpen = true }) {
Text(stringRes(R.string.blossom_report), style = MaterialTheme.typography.labelMedium)
}
}
}
}
}
if (reportOpen) {
BlossomReportDialog(
row = row,
vm = vm,
onDismiss = { reportOpen = false },
)
}
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
bytes >= 1_000 -> "${bytes / 1_000} KB"
else -> "$bytes B"
}
@Composable
private fun BlossomReportDialog(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
var typeMenuOpen by remember { mutableStateOf(false) }
var type by remember { mutableStateOf(ReportType.OTHER) }
// Report to the first server that actually holds the blob.
val server = row.serversPresent.firstOrNull() ?: return
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.blossom_report_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Column {
OutlinedButton(onClick = { typeMenuOpen = true }) { Text(type.code) }
DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) {
ReportType.entries.forEach { rt ->
DropdownMenuItem(
text = { Text(rt.code) },
onClick = {
type = rt
typeMenuOpen = false
},
)
}
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text(stringRes(R.string.blossom_report_comment_hint)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(onClick = {
vm.report(row.hash, server, type, comment)
onDismiss()
}) { Text(stringRes(R.string.blossom_send)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@@ -0,0 +1,227 @@
/*
* 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.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* One stored blob, plus which of the user's Blossom servers currently hold it —
* the "seen files per server" view (BUD-01 HEAD / BUD-02 list). [serversPresent]
* and [serversMissing] are server base URLs from the user's kind-10063 list.
*/
@Immutable
data class BlobRow(
val hash: HexKey,
val url: String?,
val size: Long?,
val type: String?,
val serversPresent: List<String>,
val serversMissing: List<String>,
)
/**
* Backs the Blossom blob-manager screen. For the active account it fans a
* `GET /list/<pubkey>` (BUD-02) across every server in the user's kind-10063 list,
* inverts the results into a per-blob presence matrix, and backfills servers that
* don't implement `/list` with cheap `HEAD /<sha256>` probes (BUD-01). Exposes
* delete (BUD-02), mirror-to-missing (BUD-04), and report (BUD-09) actions.
*/
@Stable
class BlossomBlobManagerViewModel : ViewModel() {
private lateinit var account: Account
private val _blobs = MutableStateFlow<List<BlobRow>>(emptyList())
val blobs = _blobs.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
}
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
private fun servers(): List<String> =
account.blossomServers.hostNameFlow.value
.filter { it.type == ServerType.Blossom }
.map { it.baseUrl }
.distinct()
fun refresh() {
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
try {
_blobs.value = loadMatrix()
} catch (e: Exception) {
Log.w("BlossomBlobManager", "Failed to load blob list", e)
_error.value = e.message ?: e.javaClass.simpleName
} finally {
_isLoading.value = false
}
}
}
private suspend fun loadMatrix(): List<BlobRow> {
val pubkey = account.signer.pubKey
val servers = servers()
if (servers.isEmpty()) return emptyList()
// Per-server /list results, keyed by hash. Servers that don't implement
// /list (or error) contribute an empty holding and get HEAD-backfilled below.
val presence = mutableMapOf<HexKey, MutableSet<String>>()
val meta = mutableMapOf<HexKey, BlobMeta>()
val listCapable = mutableSetOf<String>()
servers.forEach { server ->
try {
val auth = account.createBlossomListAuth("List blobs", listOf(server)).toAuthorizationHeader()
val blobs = clientFor(server).list(server, pubkey, auth)
listCapable.add(server)
blobs.forEach { d ->
val hash = d.sha256 ?: return@forEach
presence.getOrPut(hash) { mutableSetOf() }.add(server)
meta.putIfAbsent(hash, BlobMeta(d.url, d.size, d.type))
}
} catch (e: Exception) {
Log.w("BlossomBlobManager", "list failed on $server", e)
}
}
// BUD-01 backfill: for every known hash, HEAD-probe the servers that
// didn't (or couldn't) list it, so the presence matrix is complete.
val allHashes = presence.keys.toList()
servers.forEach { server ->
allHashes.forEach { hash ->
if (server !in presence[hash].orEmpty()) {
if (clientFor(server).has(hash, server)) {
presence.getOrPut(hash) { mutableSetOf() }.add(server)
}
}
}
}
return presence
.map { (hash, present) ->
val m = meta[hash]
BlobRow(
hash = hash,
url = m?.url,
size = m?.size,
type = m?.type,
serversPresent = servers.filter { it in present },
serversMissing = servers.filter { it !in present },
)
}.sortedByDescending { it.serversPresent.size }
}
/** BUD-02 delete: remove [hash] from a single [server]. */
fun delete(
hash: HexKey,
server: String,
onDone: (Boolean) -> Unit = {},
) {
viewModelScope.launch(Dispatchers.IO) {
val ok =
try {
val auth = account.createBlossomDeleteAuth(hash, "Delete blob", listOf(server)).toAuthorizationHeader()
clientFor(server).delete(hash, server, auth)
} catch (e: Exception) {
Log.w("BlossomBlobManager", "delete failed on $server", e)
false
}
if (ok) refresh()
withContext(Dispatchers.Main) { onDone(ok) }
}
}
/** BUD-04: mirror a blob to every server in the user's list that doesn't have it yet. */
fun mirrorToMissing(
row: BlobRow,
onDone: (Int) -> Unit = {},
) {
val source = row.url ?: return onDone(0)
viewModelScope.launch(Dispatchers.IO) {
var mirrored = 0
row.serversMissing.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(row.hash, row.size ?: 0L, "Mirror ${row.hash}", listOf(target)).toAuthorizationHeader()
clientFor(target).mirror(source, target, auth)
mirrored++
} catch (e: Exception) {
Log.w("BlossomBlobManager", "mirror to $target failed", e)
}
}
if (mirrored > 0) refresh()
withContext(Dispatchers.Main) { onDone(mirrored) }
}
}
/** BUD-09: report a blob to a server as problematic content. */
fun report(
hash: HexKey,
server: String,
type: ReportType,
comment: String,
onDone: (Boolean) -> Unit = {},
) {
viewModelScope.launch(Dispatchers.IO) {
val ok =
try {
val event = account.signer.sign(BlossomReport.build(hash, type, account.signer.pubKey, comment))
clientFor(server).report(server, event.toJson())
} catch (e: Exception) {
Log.w("BlossomBlobManager", "report failed on $server", e)
false
}
withContext(Dispatchers.Main) { onDone(ok) }
}
}
fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl)
private data class BlobMeta(
val url: String?,
val size: Long?,
val type: String?,
)
}
@@ -56,6 +56,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAler
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
@@ -561,6 +562,7 @@ fun BuildNavigation(
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
@@ -465,6 +465,8 @@ sealed class Route {
@Serializable object EditMediaServers : Route()
@Serializable object ManageBlossomBlobs : Route()
@Serializable object EditNestsServers : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@@ -66,6 +66,7 @@ fun buildSettingsCatalog(
symEntry(R.string.event_sync_title, MaterialSymbols.Sync, R.string.event_sync_search_keywords, Route.EventSync),
symEntry(R.string.route_import_follows, MaterialSymbols.GroupAdd, R.string.import_follows_search_keywords, Route.ImportFollowsSelectUser),
symEntry(R.string.media_servers, MaterialSymbols.CloudUpload, R.string.media_servers_search_keywords, Route.EditMediaServers),
symEntry(R.string.manage_stored_files, MaterialSymbols.Storage, R.string.media_servers_search_keywords, Route.ManageBlossomBlobs),
symEntry(R.string.nests_servers_title, MaterialSymbols.CloudUpload, R.string.nests_servers_search_keywords, Route.EditNestsServers),
symEntry(R.string.reactions, MaterialSymbols.FavoriteBorder, R.string.reactions_search_keywords, Route.UpdateReactionType),
symEntry(R.string.zaps, MaterialSymbols.Bolt, R.string.zaps_search_keywords, Route.UpdateZapAmount()),
+9
View File
@@ -1545,6 +1545,15 @@
<string name="no_blossom_server_message">You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓</string>
<string name="manage_stored_files">Manage stored files</string>
<string name="manage_stored_files_empty">No stored files found on your Blossom servers.</string>
<string name="blossom_mirror_to_missing">Mirror to missing</string>
<string name="blossom_delete_from">Delete from…</string>
<string name="blossom_report">Report</string>
<string name="blossom_report_title">Report blob</string>
<string name="blossom_report_comment_hint">Reason (optional)</string>
<string name="blossom_send">Send</string>
<string name="recommended_media_servers">Recommended Media Servers</string>
<string name="built_in_servers_description">Amethyst\'s default list. You can add them individually or add the list.</string>
<string name="use_default_servers">Use Default List</string>
@@ -26,31 +26,34 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.service.upload.BlossomAuth
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.sha256.sha256
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.nio.file.Files
/**
* `amy blossom <upload|download|list|delete>` — Blossom blob storage
* (nak's `blossom`). Uploads/lists/deletes are authed with the active
* account (BUD-01/02/04 kind:24242 events); downloads are public.
* `amy blossom <upload|media|download|list|delete|check|mirror|report>` — Blossom
* blob storage (nak's `blossom`, but fuller). Auth'd operations use the active
* account (BUD-01/02/04/05/09 kind:24242 events); downloads and HEAD checks are
* public.
*
* upload --server URL FILE [--mime-type M]
* download URL [--out FILE] (or: download HASH --server URL)
* list --server URL [USER] (USER defaults to the active account)
* media --server URL FILE [--mime-type M] (BUD-05 optimize on upload)
* download URL [--out FILE] (or: download HASH --server URL)
* list --server URL [USER] (USER defaults to the active account)
* delete HASH --server URL
* check --server URL HASH[,HASH] (BUD-01 HEAD probe)
* mirror --server URL SOURCE-URL (BUD-04)
* report --server URL HASH [--type T] [--comment C] [--uploader HEX]
*
* Thin assembly only: HTTP + auth live in commons `BlossomClient` /
* `BlossomAuth` and quartz `BlossomAuthorizationEvent`; this file wires
* flags and shapes output. List/delete use OkHttp directly (no client
* method exists) with the quartz-built auth header.
* Thin assembly only: all HTTP + auth live in commons `BlossomClient` /
* `BlossomAuth` and quartz `BlossomAuthorizationEvent` / `BlossomReport`; this
* file wires flags and shapes output. Auth tokens are scoped to `--server` so
* they can't be replayed elsewhere (BUD-11).
*/
object BlossomCommands {
suspend fun dispatch(
@@ -60,14 +63,16 @@ object BlossomCommands {
route(
"blossom",
tail,
"blossom <upload|download|list|delete|check|mirror>",
"blossom <upload|media|download|list|delete|check|mirror|report>",
mapOf(
"upload" to { rest -> upload(dataDir, rest) },
"upload" to { rest -> upload(dataDir, rest, media = false) },
"media" to { rest -> upload(dataDir, rest, media = true) },
"download" to { rest -> download(dataDir, rest) },
"list" to { rest -> list(dataDir, rest) },
"delete" to { rest -> delete(dataDir, rest) },
"check" to { rest -> check(dataDir, rest) },
"mirror" to { rest -> mirror(dataDir, rest) },
"report" to { rest -> report(dataDir, rest) },
),
)
@@ -87,22 +92,11 @@ object BlossomCommands {
// Read-only HEAD probe — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { _ ->
val http = OkHttpClient()
val client = BlossomClient()
val results =
hashes.map { hash ->
val req =
Request
.Builder()
.url(BlossomServerUrl.blob(server, hash))
.head()
.build()
val (found, status) =
try {
http.newCall(req).execute().use { it.isSuccessful to it.code }
} catch (e: Exception) {
false to -1
}
mapOf("sha256" to hash, "found" to found, "status" to status)
val found = client.has(hash, server)
mapOf("sha256" to hash, "found" to found)
}
val allFound = results.all { it["found"] == true }
Output.emit(mapOf("server" to server, "all_found" to allFound, "results" to results))
@@ -129,32 +123,23 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuthorizationEvent.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer).toAuthorizationHeader()
val body = """{"url":${Output.mapper.writeValueAsString(sourceUrl)}}""".toRequestBody("application/json".toMediaType())
val req =
Request
.Builder()
.url(server.removeSuffix("/") + "/mirror")
.header("Authorization", auth)
.put(body)
.build()
OkHttpClient().newCall(req).execute().use { response ->
if (!response.isSuccessful) {
return Output.error("http_error", "mirror failed: HTTP ${response.code} ${response.headers[BlossomServerUrl.REASON_HEADER] ?: ""}")
}
val node = Output.mapper.readTree(response.body.string())
val auth = BlossomAuth.createUploadAuth(hash, 0, "Mirror $hash", ctx.signer, servers = listOf(server))
return withPayment(server) {
val node = BlossomClient().mirror(sourceUrl, server, auth)
Output.emit(mapOf("server" to server, "sha256" to hash, "blob" to node))
0
}
return 0
}
}
private suspend fun upload(
dataDir: DataDir,
rest: Array<String>,
media: Boolean,
): Int {
val args = Args(rest)
val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload requires --server URL")
val verb = if (media) "media" else "upload"
val server = args.flag("server") ?: return Output.error("bad_args", "blossom $verb requires --server URL")
val path = args.positional(0, "file")
val file = File(path)
if (!file.isFile) return Output.error("bad_args", "no such file: $path")
@@ -165,18 +150,27 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer)
val result = BlossomClient().upload(file, mime, server, auth)
Output.emit(
mapOf(
"url" to result.url,
"sha256" to (result.sha256 ?: hash),
"size" to (result.size ?: file.length()),
"type" to (result.type ?: mime),
"server" to server,
),
)
return 0
val client = BlossomClient()
val auth =
if (media) {
BlossomAuth.createMediaAuth(hash, file.length(), "Optimize ${file.name}", ctx.signer, servers = listOf(server))
} else {
BlossomAuth.createUploadAuth(hash, file.length(), "Upload ${file.name}", ctx.signer, servers = listOf(server))
}
return withPayment(server) {
val result = if (media) client.media(file, mime, server, auth) else client.upload(file, mime, server, auth)
Output.emit(
mapOf(
"url" to result.url,
"sha256" to (result.sha256 ?: hash),
"ox" to result.ox,
"size" to (result.size ?: file.length()),
"type" to (result.type ?: mime),
"server" to server,
),
)
0
}
}
}
@@ -190,7 +184,7 @@ object BlossomCommands {
val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target
// Public download — no auth, so it runs anonymously without an account.
Context.openOrAnonymous(dataDir).use { ctx ->
Context.openOrAnonymous(dataDir).use { _ ->
val bytes =
BlossomClient().download(url)
?: return Output.error("not_found", "server returned no blob for $url")
@@ -220,21 +214,9 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val pubkey = args.positionalOrNull(0)?.let { ctx.requireUserHex(it) } ?: ctx.identity.pubKeyHex
val auth = BlossomAuthorizationEvent.createListAuth(ctx.signer, "List blobs").toAuthorizationHeader()
val listUrl = server.removeSuffix("/") + "/list/" + pubkey
val request =
Request
.Builder()
.url(listUrl)
.header("Authorization", auth)
.get()
.build()
OkHttpClient().newCall(request).execute().use { response ->
if (!response.isSuccessful) return Output.error("http_error", "server returned HTTP ${response.code} for $listUrl")
val node = Output.mapper.readTree(response.body.string())
Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to node.size(), "blobs" to node))
}
val auth = BlossomAuth.createListAuth("List blobs", ctx.signer, servers = listOf(server))
val blobs = BlossomClient().list(server, pubkey, auth)
Output.emit(mapOf("server" to server, "pubkey" to pubkey, "count" to blobs.size, "blobs" to blobs))
return 0
}
}
@@ -249,26 +231,56 @@ object BlossomCommands {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val auth = BlossomAuthorizationEvent.createDeleteAuth(hash, "Delete blob", ctx.signer).toAuthorizationHeader()
val blobUrl = BlossomServerUrl.blob(server, hash)
val request =
Request
.Builder()
.url(blobUrl)
.header("Authorization", auth)
.delete()
.build()
OkHttpClient().newCall(request).execute().use { response ->
Output.emit(
mapOf(
"sha256" to hash,
"server" to server,
"deleted" to response.isSuccessful,
"status" to response.code,
),
)
return if (response.isSuccessful) 0 else 1
}
val auth = BlossomAuth.createDeleteAuth(hash, "Delete blob", ctx.signer, servers = listOf(server))
val deleted = BlossomClient().delete(hash, server, auth)
Output.emit(mapOf("sha256" to hash, "server" to server, "deleted" to deleted))
return if (deleted) 0 else 1
}
}
/**
* `blossom report --server URL HASH [--type T] [--comment C] [--uploader HEX]`
* — PUT a signed NIP-56 (kind 1984) blob report to the server's /report
* endpoint (BUD-09). [type] is a NIP-56 report code (spam, illegal, nudity,
* malware, …), defaulting to `other`.
*/
private suspend fun report(
dataDir: DataDir,
rest: Array<String>,
): Int {
val args = Args(rest)
val server = args.flag("server") ?: return Output.error("bad_args", "blossom report requires --server URL")
val hash = args.positional(0, "sha256")
val type =
args.flag("type")?.let { code ->
ReportType.entries.firstOrNull { it.code.equals(code, ignoreCase = true) }
?: return Output.error("bad_args", "unknown --type '$code' (use ${ReportType.entries.joinToString("|") { it.code }})")
} ?: ReportType.OTHER
val comment = args.flag("comment") ?: ""
val uploader = args.flag("uploader")
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = ctx.signer.sign(BlossomReport.build(hash, type, uploader, comment))
val ok = BlossomClient().report(server, event.toJson())
Output.emit(mapOf("server" to server, "sha256" to hash, "type" to type.code, "reported" to ok))
return if (ok) 0 else 1
}
}
/** Runs [block], turning a BUD-07 402 into a clean payment-required error. */
private inline fun withPayment(
server: String,
block: () -> Int,
): Int =
try {
block()
} catch (e: BlossomPaymentException) {
Output.error(
"payment_required",
"server $server requires payment: ${e.payment.reason ?: "402"}" +
(e.payment.cashu?.let { " (cashu available)" } ?: "") +
(e.payment.lightning?.let { " (lightning invoice available)" } ?: ""),
)
}
}
@@ -30,7 +30,29 @@ object BlossomAuth {
size: Long,
alt: String,
signer: NostrSigner,
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader()
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createMediaAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createListAuth(
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader()
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader()
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader()
}
@@ -0,0 +1,336 @@
/*
* 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.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.File
/**
* Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The
* caller pays [payment] (Cashu or Lightning) and retries the request with the
* proof attached.
*/
class BlossomPaymentException(
val server: String,
val payment: BlossomPaymentRequired,
) : RuntimeException("Payment required by $server: ${payment.reason ?: "402 Payment Required"}")
/** Result of a BUD-06 `HEAD /upload` or `HEAD /media` preflight. */
data class BlossomPreflightResult(
val accepted: Boolean,
val status: Int,
val reason: String? = null,
)
/**
* Blossom HTTP client for JVM consumers (desktop + CLI + Android's
* shared logic). Owns no global state — pass a configured [OkHttpClient] (e.g.
* desktop's Tor-aware `DesktopHttpClient.currentClient()`) for proxying /
* connection pooling. The default constructor uses a fresh OkHttpClient — fine
* for one-shot uses such as the CLI.
*
* Covers BUD-01 (download), BUD-02 (upload/list/delete), BUD-04 (mirror),
* BUD-05 (media), BUD-06 (preflight), BUD-07 (402), and BUD-09 (report). Every
* optional endpoint degrades gracefully so callers can fan out across servers of
* varying capability.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-05 media-optimization upload: `PUT /media`. The server MAY transform the
* blob, so the returned descriptor's `sha256` is the *optimized* hash and `ox`
* the original. Requires a `t=media` auth token.
*/
open suspend fun media(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
open suspend fun media(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-04 mirror: ask [serverBaseUrl] to fetch and store the blob already at
* [sourceUrl]. The server verifies the downloaded bytes hash to the `x` tag in
* the (upload) auth token. Returns the mirrored blob's descriptor.
*/
open suspend fun mirror(
sourceUrl: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType())
val request =
Request
.Builder()
.url(BlossomServerUrl.mirror(serverBaseUrl))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
/**
* BUD-02 list: `GET /list/<pubkey>`. Returns the pubkey's blob descriptors on
* this server (may be empty; servers MAY not implement it). [authHeader] is a
* `t=list` token — some servers require it, others allow anonymous listing.
*/
open suspend fun list(
serverBaseUrl: String,
pubkey: HexKey,
authHeader: String?,
): List<BlossomUploadResult> =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.list(serverBaseUrl, pubkey))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("List failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { "[]" }
JsonMapper.fromJson<List<BlossomUploadResult>>(body)
}
}
/**
* BUD-02 delete: `DELETE /<sha256>[.ext]`. [authHeader] is a `t=delete` token
* scoped to the hash (and ideally to this server). Returns true on 2xx.
*/
open suspend fun delete(
hash: HexKey,
serverBaseUrl: String,
authHeader: String?,
extension: String = "",
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash, extension))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.delete()
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* BUD-01 HEAD probe: does [serverBaseUrl] hold [hash]? A cheap "which server
* has which blob" check that needs no auth on most servers.
*/
open suspend fun has(
hash: HexKey,
serverBaseUrl: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash))
.head()
.build()
try {
okHttpClient.newCall(request).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}
/**
* BUD-06 preflight: `HEAD /upload` (or `/media` when [media] is true). A 200
* means the server would accept the blob; any other status carries an optional
* `X-Reason`. Per spec this is only a hint — never gate an upload hard on it.
*/
open suspend fun preflight(
hash: HexKey,
size: Long,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
media: Boolean = false,
): BlossomPreflightResult =
withContext(Dispatchers.IO) {
val endpoint = if (media) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
val request =
Request
.Builder()
.url(endpoint)
.head()
.addHeader(BlossomServerUrl.X_SHA_256_HEADER, hash)
.addHeader(BlossomServerUrl.X_CONTENT_LENGTH_HEADER, size.toString())
.addHeader(BlossomServerUrl.X_CONTENT_TYPE_HEADER, contentType)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.build()
okHttpClient.newCall(request).execute().use { response ->
BlossomPreflightResult(
accepted = response.isSuccessful,
status = response.code,
reason = response.headers[BlossomServerUrl.REASON_HEADER],
)
}
}
/**
* BUD-09 report: `PUT /report` with a signed NIP-56 (kind 1984) report event as
* the JSON body. Returns true on 2xx.
*/
open suspend fun report(
serverBaseUrl: String,
reportEventJson: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.report(serverBaseUrl))
.put(reportEventJson.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
private fun fileBody(
file: File,
contentType: String,
): RequestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
private suspend fun putBlob(
endpoint: String,
body: RequestBody,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(endpoint)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
private fun parseDescriptor(
response: Response,
serverBaseUrl: String,
): BlossomUploadResult {
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("Request failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { throw RuntimeException("$serverBaseUrl returned no body") }
return JsonMapper.fromJson<BlossomUploadResult>(body)
}
/** Surfaces a BUD-07 `402 Payment Required` as a typed exception the caller can act on. */
private fun check402(
response: Response,
serverBaseUrl: String,
) {
if (response.code == 402) {
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
}
}
@kotlinx.serialization.Serializable
private data class MirrorRequest(
val url: String,
)
}
@@ -1,140 +0,0 @@
/*
* 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.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
/**
* Blossom HTTP client for JVM consumers (desktop + CLI). Owns no global
* state — pass a configured [OkHttpClient] (e.g. desktop's Tor-aware
* `DesktopHttpClient.currentClient()`) for proxying / connection pooling.
* The default constructor uses a fresh OkHttpClient — fine for one-shot
* uses such as the CLI.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
}
@@ -57,29 +57,47 @@ class BlossomAuthorizationEvent(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("get", hash, null, alt, signer, createdAt)
) = createAuth("get", hash, null, alt, signer, servers, createdAt)
suspend fun createListAuth(
signer: NostrSigner,
alt: String,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("list", null, null, alt, signer, createdAt)
) = createAuth("list", null, null, alt, signer, servers, createdAt)
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("delete", hash, null, alt, signer, createdAt)
) = createAuth("delete", hash, null, alt, signer, servers, createdAt)
suspend fun createUploadAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("upload", hash, size, alt, signer, createdAt)
) = createAuth("upload", hash, size, alt, signer, servers, createdAt)
/**
* BUD-05 media-optimization auth (`t=media`). The [hash] is the sha256 of
* the *original* bytes the client sends to `PUT /media`; the server returns
* a descriptor whose hash is the optimized blob's.
*/
suspend fun createMediaAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
) = createAuth("media", hash, size, alt, signer, servers, createdAt)
private suspend fun createAuth(
type: String,
@@ -87,15 +105,26 @@ class BlossomAuthorizationEvent(
fileSize: Long?,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
createdAt: Long = TimeUtils.now(),
): BlossomAuthorizationEvent {
// BUD-11 `server` tags scope the token to specific domains so an upload
// or delete token can't be replayed against another server. The value
// MUST be the lowercase bare domain (no scheme/port/path).
val serverTags =
servers
.map { BlossomServerUrl.domain(it) }
.filter { it.isNotBlank() }
.distinct()
.map { arrayOf("server", it) }
val tags =
listOfNotNull(
arrayOf("t", type),
arrayOf("expiration", TimeUtils.oneHourAhead().toString()),
fileSize?.let { arrayOf("size", it.toString()) },
hash?.let { arrayOf("x", it) },
)
) + serverTags
return signer.sign(createdAt, KIND, tags.toTypedArray(), alt)
}
@@ -0,0 +1,58 @@
/*
* 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
/**
* BUD-07 payment challenge parsed from a `402 Payment Required` response. A paid
* Blossom server answers upload/mirror/media requests with a 402 and one or both
* of the payment headers; the client pays, then retries the same request with the
* proof attached.
*
* - [cashu] — a NUT-24 Cashu token request string from the `X-Cashu` header. The
* client pays it (e.g. via a NIP-60 wallet) and retries with the settled token.
* - [lightning] — a BOLT-11 invoice string from the `X-Lightning` header. The
* client pays it and retries; the preimage is the proof.
* - [reason] — the optional human-readable `X-Reason` message.
*
* Kept transport-agnostic (no OkHttp) so it can live in `commonMain`: build it
* from any per-name header lookup via [fromHeaders].
*/
data class BlossomPaymentRequired(
val cashu: String? = null,
val lightning: String? = null,
val reason: String? = null,
) {
/** True when the server offered at least one payment method we could attempt. */
fun hasPaymentOption(): Boolean = !cashu.isNullOrBlank() || !lightning.isNullOrBlank()
companion object {
/**
* Reads the BUD-07 headers from a 402 response. [header] returns the value
* for a header name (case-insensitive at the transport layer), or null.
*/
inline fun fromHeaders(header: (String) -> String?): BlossomPaymentRequired =
BlossomPaymentRequired(
cashu = header(BlossomServerUrl.X_CASHU_HEADER)?.trim('"', ' ')?.ifBlank { null },
lightning = header(BlossomServerUrl.X_LIGHTNING_HEADER)?.trim('"', ' ')?.ifBlank { null },
reason = header(BlossomServerUrl.REASON_HEADER)?.ifBlank { null },
)
}
}
@@ -0,0 +1,51 @@
/*
* 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.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip56Reports.hash
import com.vitorpamplona.quartz.nip56Reports.user
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* BUD-09 blob report: a NIP-56 (kind 1984) report event scoped to a blob by its
* sha256 (`x` tag) rather than to a nostr event. The signed event is PUT to a
* server's `/report` endpoint so the operator can review problematic content.
*
* Reuses the shared NIP-56 tag builders ([hash], [user]) so a blob report is a
* regular [ReportEvent] — clients that already parse kind 1984 pick up the
* reported hash via `HashSha256Tag`.
*/
object BlossomReport {
fun build(
blobHash: HexKey,
type: ReportType,
uploader: HexKey? = null,
comment: String = "",
createdAt: Long = TimeUtils.now(),
) = eventTemplate(ReportEvent.KIND, comment, createdAt) {
hash(blobHash, type)
uploader?.let { user(it, type) }
}
}
@@ -21,9 +21,10 @@
package com.vitorpamplona.quartz.nipB7Blossom
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Rfc3986
/**
* Endpoint helpers for the Blossom HTTP API (BUD-01 / BUD-02). Centralizes the
* Endpoint helpers for the Blossom HTTP API (BUD-01 BUD-12). Centralizes the
* protocol's URL shapes and well-known header names so every transport — the
* commons JVM `BlossomClient`, the Android uploader, the CLI — builds them the
* same way instead of re-deriving `<server>/upload` and `X-Reason` by hand.
@@ -32,15 +33,51 @@ object BlossomServerUrl {
/** BUD-01 upload endpoint path: `PUT <server>/upload`. */
const val UPLOAD_PATH = "/upload"
/** BUD-04 mirror endpoint path: `PUT <server>/mirror`. */
const val MIRROR_PATH = "/mirror"
/** BUD-05 media-optimization endpoint path: `PUT <server>/media`. */
const val MEDIA_PATH = "/media"
/** BUD-02 list endpoint path prefix: `GET <server>/list/<pubkey>`. */
const val LIST_PATH = "/list/"
/** BUD-09 report endpoint path: `PUT <server>/report`. */
const val REPORT_PATH = "/report"
/**
* Header a Blossom server SHOULD set with a human-readable failure reason on
* a non-2xx response (BUD-01).
*/
const val REASON_HEADER = "X-Reason"
/** BUD-06 preflight request headers for `HEAD /upload` and `HEAD /media`. */
const val X_SHA_256_HEADER = "X-SHA-256"
const val X_CONTENT_TYPE_HEADER = "X-Content-Type"
const val X_CONTENT_LENGTH_HEADER = "X-Content-Length"
/** BUD-07 payment headers carried on a `402 Payment Required` response. */
const val X_CASHU_HEADER = "X-Cashu"
const val X_LIGHTNING_HEADER = "X-Lightning"
/** `<server>/upload`, collapsing any trailing slash on [serverBaseUrl]. */
fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH
/** BUD-04 `<server>/mirror`, collapsing any trailing slash on [serverBaseUrl]. */
fun mirror(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MIRROR_PATH
/** BUD-05 `<server>/media`, collapsing any trailing slash on [serverBaseUrl]. */
fun media(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MEDIA_PATH
/** BUD-02 `<server>/list/<pubkey>`, collapsing any trailing slash on [serverBaseUrl]. */
fun list(
serverBaseUrl: String,
pubkey: HexKey,
): String = serverBaseUrl.removeSuffix("/") + LIST_PATH + pubkey
/** BUD-09 `<server>/report`, collapsing any trailing slash on [serverBaseUrl]. */
fun report(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + REPORT_PATH
/**
* BUD-01 blob endpoint `<server>/<sha256>[.<ext>]`, used for GET and DELETE.
* A blank [extension] omits the suffix.
@@ -53,4 +90,20 @@ object BlossomServerUrl {
val suffix = if (extension.isBlank()) "" else ".$extension"
return serverBaseUrl.removeSuffix("/") + "/" + hash + suffix
}
/**
* The lowercase bare domain of [serverBaseUrl], as required by the BUD-11
* `server` authorization tag ("lowercase domain name only", no scheme/port).
* Falls back to a best-effort strip when the URL can't be parsed.
*/
fun domain(serverBaseUrl: String): String =
try {
Rfc3986.host(serverBaseUrl).substringBefore(":").lowercase()
} catch (_: Exception) {
serverBaseUrl
.substringAfter("://")
.substringBefore("/")
.substringBefore(":")
.lowercase()
}
}
@@ -35,10 +35,18 @@ data class BlossomUploadResult(
val type: String? = null,
// upload time
val uploaded: Long? = null,
// (BUD-05) The sha256 hash of the *original* blob, before server-side
// optimization. Only returned by the `/media` endpoint when the server
// transforms the file (so sha256 != ox).
val ox: HexKey? = null,
// magnet link
val magnet: String? = null,
// info hash
val infohash: String? = null,
// ipfs link
val ipfs: String? = null,
// (BUD-08) Optional NIP-94 file-metadata tags describing this blob, so a
// client gets standardized metadata (dimensions, blurhash, alt, …) without
// a separate request. Each entry is a NIP-94 tag: [name, value, …].
val nip94: List<List<String>>? = null,
)
@@ -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)
}
}
@@ -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"))
}
}
@@ -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())
}
}