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
@@ -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)" } ?: ""),
)
}
}