diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 109e5737bf..9ea8909821 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -1594,12 +1594,26 @@ class Account( hash: HexKey, size: Long, alt: String, - ) = blossomServers.createBlossomUploadAuth(hash, size, alt) + servers: List = emptyList(), + ) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers) + + suspend fun createBlossomMediaAuth( + hash: HexKey, + size: Long, + alt: String, + servers: List = emptyList(), + ) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ) = blossomServers.createBlossomDeleteAuth(hash, alt) + servers: List = emptyList(), + ) = blossomServers.createBlossomDeleteAuth(hash, alt, servers) + + suspend fun createBlossomListAuth( + alt: String, + servers: List = emptyList(), + ) = blossomServers.createBlossomListAuth(alt, servers) suspend fun boost(note: Note) { val powDifficulty = powDifficultyFor(RepostEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index e4533b8f0b..88c5ea3e3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -120,12 +120,26 @@ class BlossomServerListState( hash: HexKey, size: Long, alt: String, - ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer) + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers) + + suspend fun createBlossomMediaAuth( + hash: HexKey, + size: Long, + alt: String, + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers) suspend fun createBlossomDeleteAuth( hash: HexKey, alt: String, - ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer) + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers) + + suspend fun createBlossomListAuth( + alt: String, + servers: List = emptyList(), + ): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers) } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt index ed5b3e7b84..fe264d3f0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/UploadOrchestrator.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt new file mode 100644 index 0000000000..7b290f1ce4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerScreen.kt @@ -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)) } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt new file mode 100644 index 0000000000..baf6f84481 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomBlobManagerViewModel.kt @@ -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, + val serversMissing: List, +) + +/** + * Backs the Blossom blob-manager screen. For the active account it fans a + * `GET /list/` (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 /` 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>(emptyList()) + val blobs = _blobs.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading = _isLoading.asStateFlow() + + private val _error = MutableStateFlow(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 = + 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 { + 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>() + val meta = mutableMapOf() + val listCapable = mutableSetOf() + + 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?, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c60fbefdca..6165aaaef2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { RequestToVanishScreen(accountViewModel, nav) } composableFromEnd { VanishEventsScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { BlossomBlobManagerScreen(accountViewModel, nav) } composableFromEndArgs { com.vitorpamplona.amethyst.ui.actions.nestsServers .NestsServersScreen(accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index c2c39eb3ac..d6f6780592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -465,6 +465,8 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object ManageBlossomBlobs : Route() + @Serializable object EditNestsServers : Route() @Serializable object EditFavoriteAlgoFeeds : Route() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt index 5e85e88c94..a2fdee8592 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SettingsCatalogBuilder.kt @@ -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()), diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 67af627ae0..68f323a66b 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1545,6 +1545,15 @@ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Manage stored files + No stored files found on your Blossom servers. + Mirror to missing + Delete from… + Report + Report blob + Reason (optional) + Send + Recommended Media Servers Amethyst\'s default list. You can add them individually or add the list. Use Default List diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt index 56d1847c30..d550b087b4 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -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 ` — 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 ` — 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 ", + "blossom ", 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, + 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, + ): 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)" } ?: ""), + ) + } } diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt similarity index 65% rename from commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt index 0bda515f8a..7c1597f992 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomAuth.kt @@ -30,7 +30,29 @@ object BlossomAuth { size: Long, alt: String, signer: NostrSigner, - ): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader() + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader() + + suspend fun createMediaAuth( + hash: HexKey, + size: Long, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader() + + suspend fun createListAuth( + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader() + + suspend fun createDeleteAuth( + hash: HexKey, + alt: String, + signer: NostrSigner, + servers: List = emptyList(), + ): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader() fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader() } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt new file mode 100644 index 0000000000..510631c6d2 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -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/`. 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 = + 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>(body) + } + } + + /** + * BUD-02 delete: `DELETE /[.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 + * `/`. 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(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, + ) +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt deleted file mode 100644 index edc9702643..0000000000 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ /dev/null @@ -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(body) - } - } - - /** - * Download a blob from an absolute URL — typically a Blossom GET endpoint - * `/`. 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(body) - } - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt index dcf9fe9e85..9cabfaa20e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEvent.kt @@ -57,29 +57,47 @@ class BlossomAuthorizationEvent( hash: HexKey, alt: String, signer: NostrSigner, + servers: List = 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 = 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 = 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 = 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 = 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 = 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) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt new file mode 100644 index 0000000000..7b13a75332 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomPaymentRequired.kt @@ -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 }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt new file mode 100644 index 0000000000..7f1e239ab1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomReport.kt @@ -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) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt index 3444b78052..05cd6f8180 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrl.kt @@ -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 `/upload` and `X-Reason` by hand. @@ -32,15 +33,51 @@ object BlossomServerUrl { /** BUD-01 upload endpoint path: `PUT /upload`. */ const val UPLOAD_PATH = "/upload" + /** BUD-04 mirror endpoint path: `PUT /mirror`. */ + const val MIRROR_PATH = "/mirror" + + /** BUD-05 media-optimization endpoint path: `PUT /media`. */ + const val MEDIA_PATH = "/media" + + /** BUD-02 list endpoint path prefix: `GET /list/`. */ + const val LIST_PATH = "/list/" + + /** BUD-09 report endpoint path: `PUT /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" + /** `/upload`, collapsing any trailing slash on [serverBaseUrl]. */ fun upload(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + UPLOAD_PATH + /** BUD-04 `/mirror`, collapsing any trailing slash on [serverBaseUrl]. */ + fun mirror(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MIRROR_PATH + + /** BUD-05 `/media`, collapsing any trailing slash on [serverBaseUrl]. */ + fun media(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + MEDIA_PATH + + /** BUD-02 `/list/`, collapsing any trailing slash on [serverBaseUrl]. */ + fun list( + serverBaseUrl: String, + pubkey: HexKey, + ): String = serverBaseUrl.removeSuffix("/") + LIST_PATH + pubkey + + /** BUD-09 `/report`, collapsing any trailing slash on [serverBaseUrl]. */ + fun report(serverBaseUrl: String): String = serverBaseUrl.removeSuffix("/") + REPORT_PATH + /** * BUD-01 blob endpoint `/[.]`, 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() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt index 674e734097..611e3e9e94 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResult.kt @@ -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>? = null, ) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt new file mode 100644 index 0000000000..6d82a3913a --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomAuthorizationEventTest.kt @@ -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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt new file mode 100644 index 0000000000..f27d8b4ace --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomServerUrlTest.kt @@ -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")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt new file mode 100644 index 0000000000..be732c00fd --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipB7Blossom/BlossomUploadResultTest.kt @@ -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(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(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(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()) + } +}