feat(blossom): full-client protocol support across quartz, commons, CLI and Android

Extends Blossom support toward a full client on both the CLI and the mobile app.

Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
  `server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
  BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders

Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
  preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping

CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
  and `report` verbs; auth tokens are scoped to --server

Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
  BUD-01 HEAD), delete, mirror-to-missing, and report actions

Tests: quartz URL/auth/descriptor/payment parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
This commit is contained in:
Claude
2026-07-17 23:36:41 +00:00
parent cd5060e5dc
commit bb03cd2a3c
21 changed files with 1557 additions and 254 deletions
@@ -30,7 +30,29 @@ object BlossomAuth {
size: Long,
alt: String,
signer: NostrSigner,
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer).toAuthorizationHeader()
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createMediaAuth(
hash: HexKey,
size: Long,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers).toAuthorizationHeader()
suspend fun createListAuth(
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createListAuth(signer, alt, servers).toAuthorizationHeader()
suspend fun createDeleteAuth(
hash: HexKey,
alt: String,
signer: NostrSigner,
servers: List<String> = emptyList(),
): String = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers).toAuthorizationHeader()
fun encodeAuthHeader(event: BlossomAuthorizationEvent): String = event.toAuthorizationHeader()
}
@@ -0,0 +1,336 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.BufferedSink
import okio.source
import java.io.File
/**
* Thrown when a Blossom server answers with `402 Payment Required` (BUD-07). The
* caller pays [payment] (Cashu or Lightning) and retries the request with the
* proof attached.
*/
class BlossomPaymentException(
val server: String,
val payment: BlossomPaymentRequired,
) : RuntimeException("Payment required by $server: ${payment.reason ?: "402 Payment Required"}")
/** Result of a BUD-06 `HEAD /upload` or `HEAD /media` preflight. */
data class BlossomPreflightResult(
val accepted: Boolean,
val status: Int,
val reason: String? = null,
)
/**
* Blossom HTTP client for JVM consumers (desktop + CLI + Android's
* shared logic). Owns no global state — pass a configured [OkHttpClient] (e.g.
* desktop's Tor-aware `DesktopHttpClient.currentClient()`) for proxying /
* connection pooling. The default constructor uses a fresh OkHttpClient — fine
* for one-shot uses such as the CLI.
*
* Covers BUD-01 (download), BUD-02 (upload/list/delete), BUD-04 (mirror),
* BUD-05 (media), BUD-06 (preflight), BUD-07 (402), and BUD-09 (report). Every
* optional endpoint degrades gracefully so callers can fan out across servers of
* varying capability.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.upload(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-05 media-optimization upload: `PUT /media`. The server MAY transform the
* blob, so the returned descriptor's `sha256` is the *optimized* hash and `ox`
* the original. Requires a `t=media` auth token.
*/
open suspend fun media(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), fileBody(file, contentType), serverBaseUrl, authHeader)
open suspend fun media(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult = putBlob(BlossomServerUrl.media(serverBaseUrl), bytes.toRequestBody(contentType.toMediaType()), serverBaseUrl, authHeader)
/**
* BUD-04 mirror: ask [serverBaseUrl] to fetch and store the blob already at
* [sourceUrl]. The server verifies the downloaded bytes hash to the `x` tag in
* the (upload) auth token. Returns the mirrored blob's descriptor.
*/
open suspend fun mirror(
sourceUrl: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val body = JsonMapper.toJson(MirrorRequest(sourceUrl)).toRequestBody("application/json".toMediaType())
val request =
Request
.Builder()
.url(BlossomServerUrl.mirror(serverBaseUrl))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
/**
* BUD-02 list: `GET /list/<pubkey>`. Returns the pubkey's blob descriptors on
* this server (may be empty; servers MAY not implement it). [authHeader] is a
* `t=list` token — some servers require it, others allow anonymous listing.
*/
open suspend fun list(
serverBaseUrl: String,
pubkey: HexKey,
authHeader: String?,
): List<BlossomUploadResult> =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.list(serverBaseUrl, pubkey))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("List failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { "[]" }
JsonMapper.fromJson<List<BlossomUploadResult>>(body)
}
}
/**
* BUD-02 delete: `DELETE /<sha256>[.ext]`. [authHeader] is a `t=delete` token
* scoped to the hash (and ideally to this server). Returns true on 2xx.
*/
open suspend fun delete(
hash: HexKey,
serverBaseUrl: String,
authHeader: String?,
extension: String = "",
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash, extension))
.apply { authHeader?.let { addHeader("Authorization", it) } }
.delete()
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* BUD-01 HEAD probe: does [serverBaseUrl] hold [hash]? A cheap "which server
* has which blob" check that needs no auth on most servers.
*/
open suspend fun has(
hash: HexKey,
serverBaseUrl: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.blob(serverBaseUrl, hash))
.head()
.build()
try {
okHttpClient.newCall(request).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
}
/**
* BUD-06 preflight: `HEAD /upload` (or `/media` when [media] is true). A 200
* means the server would accept the blob; any other status carries an optional
* `X-Reason`. Per spec this is only a hint — never gate an upload hard on it.
*/
open suspend fun preflight(
hash: HexKey,
size: Long,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
media: Boolean = false,
): BlossomPreflightResult =
withContext(Dispatchers.IO) {
val endpoint = if (media) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
val request =
Request
.Builder()
.url(endpoint)
.head()
.addHeader(BlossomServerUrl.X_SHA_256_HEADER, hash)
.addHeader(BlossomServerUrl.X_CONTENT_LENGTH_HEADER, size.toString())
.addHeader(BlossomServerUrl.X_CONTENT_TYPE_HEADER, contentType)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.build()
okHttpClient.newCall(request).execute().use { response ->
BlossomPreflightResult(
accepted = response.isSuccessful,
status = response.code,
reason = response.headers[BlossomServerUrl.REASON_HEADER],
)
}
}
/**
* BUD-09 report: `PUT /report` with a signed NIP-56 (kind 1984) report event as
* the JSON body. Returns true on 2xx.
*/
open suspend fun report(
serverBaseUrl: String,
reportEventJson: String,
): Boolean =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(BlossomServerUrl.report(serverBaseUrl))
.put(reportEventJson.toRequestBody("application/json".toMediaType()))
.build()
okHttpClient.newCall(request).execute().use { it.isSuccessful }
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
private fun fileBody(
file: File,
contentType: String,
): RequestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
private suspend fun putBlob(
endpoint: String,
body: RequestBody,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(endpoint)
.apply { authHeader?.let { addHeader("Authorization", it) } }
.put(body)
.build()
okHttpClient.newCall(request).execute().use { parseDescriptor(it, serverBaseUrl) }
}
private fun parseDescriptor(
response: Response,
serverBaseUrl: String,
): BlossomUploadResult {
check402(response, serverBaseUrl)
if (!response.isSuccessful) {
val reason = response.headers[BlossomServerUrl.REASON_HEADER] ?: response.code.toString()
throw RuntimeException("Request failed ($serverBaseUrl): $reason")
}
val body = response.body.string().ifBlank { throw RuntimeException("$serverBaseUrl returned no body") }
return JsonMapper.fromJson<BlossomUploadResult>(body)
}
/** Surfaces a BUD-07 `402 Payment Required` as a typed exception the caller can act on. */
private fun check402(
response: Response,
serverBaseUrl: String,
) {
if (response.code == 402) {
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
}
}
@kotlinx.serialization.Serializable
private data class MirrorRequest(
val url: String,
)
}
@@ -1,140 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import java.io.File
/**
* Blossom HTTP client for JVM consumers (desktop + CLI). Owns no global
* state — pass a configured [OkHttpClient] (e.g. desktop's Tor-aware
* `DesktopHttpClient.currentClient()`) for proxying / connection pooling.
* The default constructor uses a fresh OkHttpClient — fine for one-shot
* uses such as the CLI.
*/
open class BlossomClient(
private val okHttpClient: OkHttpClient = OkHttpClient(),
) {
open suspend fun upload(
file: File,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody =
object : RequestBody() {
override fun contentType() = contentType.toMediaType()
override fun contentLength() = file.length()
override fun writeTo(sink: BufferedSink) {
file.inputStream().source().use(sink::writeAll)
}
}
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
/**
* Download a blob from an absolute URL — typically a Blossom GET endpoint
* `<server>/<sha256>`. Returns the raw bytes, or `null` when the server
* responds with a non-2xx status. Connection-level failures (DNS, refused,
* timeout) propagate as [java.io.IOException] so the caller can try the next
* server.
*
* This does NOT verify the blob's hash — content-addressed verification is
* the caller's responsibility (see quartz `StaticSiteResolver.verify`), since
* a Blossom server is untrusted and may return a substituted blob.
*/
open suspend fun download(url: String): ByteArray? =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.url(url)
.get()
.build()
okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) response.body.bytes() else null
}
}
/**
* Upload raw bytes (e.g. encrypted blobs) to a Blossom server.
*/
open suspend fun upload(
bytes: ByteArray,
contentType: String,
serverBaseUrl: String,
authHeader: String?,
): BlossomUploadResult =
withContext(Dispatchers.IO) {
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
val requestBody = bytes.toRequestBody(contentType.toMediaType())
val requestBuilder =
Request
.Builder()
.url(apiUrl)
.put(requestBody)
authHeader?.let { requestBuilder.addHeader("Authorization", it) }
val response = okHttpClient.newCall(requestBuilder.build()).execute()
response.use {
if (!it.isSuccessful) {
val reason = it.headers[BlossomServerUrl.REASON_HEADER] ?: it.code.toString()
throw RuntimeException("Upload failed ($serverBaseUrl): $reason")
}
val body = it.body.string().ifBlank { throw RuntimeException("Upload to $serverBaseUrl returned no body") }
JsonMapper.fromJson<BlossomUploadResult>(body)
}
}
}