diff --git a/cli/README.md b/cli/README.md index 0937ea7dc0..3070ff0c95 100644 --- a/cli/README.md +++ b/cli/README.md @@ -244,6 +244,15 @@ Filter flags are shared by `fetch` and `subscribe`: `--kind K[,K]`, `--author U[ | `amy gift wrap --to USER [EVENT-JSON] [--relay …]` | NIP-59: seal a signed inner event for USER and wrap it in a kind:1059 gift wrap. Prints the wrap; `--relay` also broadcasts it. | | `amy gift unwrap [GIFTWRAP-JSON]` | Decrypt + unseal a kind:1059 wrap addressed to the active account; prints the inner event. | +### Blossom blobs (NIP-B7) + +| Command | What it does | +|---|---| +| `amy blossom upload --server URL FILE [--mime-type M]` | Upload a file (BUD-01, authed). Prints the blob URL + sha256. | +| `amy blossom download URL [--out FILE]` | Download a blob (public). Accepts a full URL, or a `HASH` plus `--server URL`. | +| `amy blossom list --server URL [USER]` | List a user's blobs (BUD-04). USER defaults to the active account. | +| `amy blossom delete HASH --server URL` | Delete a blob you own (BUD-02). | + ### Identity | Command | What it does | diff --git a/cli/ROADMAP.md b/cli/ROADMAP.md index a18f7413fe..a90ce9d1f1 100644 --- a/cli/ROADMAP.md +++ b/cli/ROADMAP.md @@ -92,11 +92,16 @@ vs streaming `subscribe`). Stateless verbs run with no account or network. | `relay` (NIP-11) | `amy relay info` | ✅ | stateless NIP-11 doc fetch. | | `outbox` | `amy outbox` | ✅ | NIP-65 read/write relays, cache-first. | | `filter` | `amy filter` | ✅ | stateless — assemble + print a filter JSON. | -| `blossom` | `amy blossom` | 🆕 | upload/download/list/delete (client already used by `dm`/`nsite`). | -| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry). | -| `sync` | `amy relay sync` | 🆕 | NIP-77 Negentropy. | +| `blossom` | `amy blossom` | ✅ | upload/download/list/delete (reuses commons `BlossomClient`). | +| `kind` / `nip` | `amy kind` / `amy nip` | 🆕 | reference lookups (needs a kind registry — only remaining Tier-1 gap). | +| `sync` | `amy relay sync` | 🆕 (tier 2) | NIP-77 Negentropy. | | `bunker` / `serve` / `admin` / `wallet` / `git` / `podcast` / `mcp` / `fs` / `spell` | — | 🆕 (tier 2/3) | larger/niche; some pull new deps. | +**Tier 1 status:** shipped — `decode`, `encode`, `verify`, `key`, `event`, +`publish`, `fetch`, `subscribe`, `count`, `encrypt`, `decrypt`, `gift`, +`filter`, `relay info`, `outbox`, `blossom`. Remaining: `kind` / `nip` +(reference lookups, pending a kind registry). + --- ## Order of operations diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 308c2cf6e0..101e7097b8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -263,6 +263,10 @@ private suspend fun dispatch(argv: Array): Int { Commands.outbox(dataDir, tail) } + "blossom" -> { + Commands.blossom(dataDir, tail) + } + else -> { System.err.println("unknown subcommand: $head") printUsage() @@ -478,6 +482,14 @@ private fun printUsage() { | gift unwrap [GIFTWRAP-JSON] decrypt + unseal a kind:1059 wrap addressed | to the active account. | + |Blossom blobs (NIP-B7 / BUD-01/02/04): + | blossom upload --server URL FILE upload a file (authed); prints the blob URL. + | [--mime-type M] + | blossom download URL [--out FILE] download a blob (public). Accepts a full URL, + | blossom download HASH --server URL or a HASH plus --server. + | blossom list --server URL [USER] list a user's blobs (defaults to self) + | blossom delete HASH --server URL delete a blob you own + | |Static websites (NIP-5A kind:15128/35128): | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt new file mode 100644 index 0000000000..a99a8b66df --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BlossomCommands.kt @@ -0,0 +1,202 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +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.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl +import com.vitorpamplona.quartz.utils.sha256.sha256 +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.nio.file.Files + +/** + * `amy blossom ` — Blossom blob storage + * (nak's `blossom`). Uploads/lists/deletes are authed with the active + * account (BUD-01/02/04 kind:24242 events); downloads are public. + * + * 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) + * delete HASH --server URL + * + * 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. + */ +object BlossomCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "blossom ") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "upload" -> upload(dataDir, rest) + "download" -> download(dataDir, rest) + "list" -> list(dataDir, rest) + "delete" -> delete(dataDir, rest) + else -> Output.error("bad_args", "blossom ${tail[0]} (expected upload|download|list|delete)") + } + } + + private suspend fun upload( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom upload 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") + + val bytes = file.readBytes() + val hash = sha256(bytes).toHexKey() + val mime = args.flag("mime-type") ?: runCatching { Files.probeContentType(file.toPath()) }.getOrNull() ?: "application/octet-stream" + + val ctx = Context.open(dataDir) + try { + 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 + } finally { + ctx.close() + } + } + + private suspend fun download( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val target = args.positional(0, "url-or-hash") + val server = args.flag("server") + val url = if (server != null && !target.startsWith("http")) BlossomServerUrl.blob(server, target) else target + + val ctx = Context.open(dataDir) + try { + val bytes = + BlossomClient().download(url) + ?: return Output.error("not_found", "server returned no blob for $url") + val out = args.flag("out") + if (out != null) { + File(out).writeBytes(bytes) + } + Output.emit( + mapOf( + "url" to url, + "size" to bytes.size, + "sha256" to sha256(bytes).toHexKey(), + "saved_to" to out, + ), + ) + return 0 + } finally { + ctx.close() + } + } + + private suspend fun list( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom list requires --server URL") + + val ctx = Context.open(dataDir) + try { + 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)) + } + return 0 + } finally { + ctx.close() + } + } + + private suspend fun delete( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val server = args.flag("server") ?: return Output.error("bad_args", "blossom delete requires --server URL") + val hash = args.positional(0, "sha256") + + val ctx = Context.open(dataDir) + try { + 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 + } + } finally { + ctx.close() + } + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index c65d29190d..b16d1cf916 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -180,4 +180,9 @@ object Commands { dataDir: DataDir, tail: Array, ): Int = OutboxCommand.run(dataDir, tail) + + suspend fun blossom( + dataDir: DataDir, + tail: Array, + ): Int = BlossomCommands.dispatch(dataDir, tail) }