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