mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 16:33:27 +00:00
feat(cli): add nak-style blossom blob commands (upload/download/list/delete)
Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04): - `amy blossom upload --server URL FILE [--mime-type M]` — authed upload, prints the blob URL + sha256. - `amy blossom download URL|HASH [--out FILE]` — public download (accepts a full URL or a HASH + --server). - `amy blossom list --server URL [USER]` — list a user's blobs (authed). - `amy blossom delete HASH --server URL` — delete a blob you own. Reuses commons BlossomClient + BlossomAuth and quartz BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with the quartz-built kind:24242 auth header. Verified a full upload->download sha256 round-trip against blossom.primal.net and an authed list. This completes the Tier-1 nak-parity surface (decode/encode/verify/key/ event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay info/outbox/blossom); only the kind/nip reference lookups remain. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
This commit is contained in:
@@ -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 |
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
@@ -263,6 +263,10 @@ private suspend fun dispatch(argv: Array<String>): 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
|
||||
|
||||
@@ -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 <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.
|
||||
*
|
||||
* 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<String>,
|
||||
): Int {
|
||||
if (tail.isEmpty()) return Output.error("bad_args", "blossom <upload|download|list|delete>")
|
||||
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<String>,
|
||||
): 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<String>,
|
||||
): 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<String>,
|
||||
): 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<String>,
|
||||
): 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,4 +180,9 @@ object Commands {
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = OutboxCommand.run(dataDir, tail)
|
||||
|
||||
suspend fun blossom(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = BlossomCommands.dispatch(dataDir, tail)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user