mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +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:
@@ -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)" } ?: ""),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user