mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(cli): add amy napplet fetch for NIP-5D napplets
Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite` but adding the napplet-specific runtime checks. - NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID [--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a root (15129), named (35129, via --d), or snapshot (5129, via --snapshot <event-id>) manifest; recomputes the NIP-5A aggregate hash and refuses a manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`) before touching any blob; then resolves the path with per-blob sha256 verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`, and `aggregate_verified`. - StaticSiteFetch: new shared helper holding the Blossom download + resolve + emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is slimmed down to use it (also now reports the manifest `kind`). Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to not_found with the correct kind for all three napplet variants (15129/35129/5129) plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification logic itself is covered by the quartz unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
This commit is contained in:
@@ -166,6 +166,10 @@ private suspend fun dispatch(argv: Array<String>): Int {
|
|||||||
Commands.nsite(dataDir, tail)
|
Commands.nsite(dataDir, tail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"napplet" -> {
|
||||||
|
Commands.napplet(dataDir, tail)
|
||||||
|
}
|
||||||
|
|
||||||
"store" -> {
|
"store" -> {
|
||||||
Commands.store(dataDir, tail)
|
Commands.store(dataDir, tail)
|
||||||
}
|
}
|
||||||
@@ -362,13 +366,21 @@ private fun printUsage() {
|
|||||||
| [--since TS] [--until TS]
|
| [--since TS] [--until TS]
|
||||||
| [--timeout SECS]
|
| [--timeout SECS]
|
||||||
|
|
|
|
||||||
|Static websites / napplets (NIP-5A kind:15128/35128):
|
|Static websites (NIP-5A kind:15128/35128):
|
||||||
| nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and
|
| 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
|
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
|
||||||
| [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain;
|
| [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain;
|
||||||
| [--max-inline-bytes N] --d selects a kind:35128 named site, else the
|
| [--max-inline-bytes N] --d selects a kind:35128 named site, else the
|
||||||
| kind:15128 root site; --path defaults to /)
|
| kind:15128 root site; --path defaults to /)
|
||||||
|
|
|
|
||||||
|
|Napplets (NIP-5D kind:5129/15129/35129):
|
||||||
|
| napplet fetch AUTHOR [--d ID] [--path P] like `nsite fetch`, plus NIP-5D verification:
|
||||||
|
| [--server URL[,URL]] [--relay URL[,URL]] recompute + check the `x` aggregate hash and
|
||||||
|
| [--out FILE] [--timeout SECS] report the napplet's `requires` capabilities
|
||||||
|
| [--max-inline-bytes N] (--d selects a kind:35129 named napplet, else
|
||||||
|
| napplet fetch --snapshot EVENT-ID the kind:15129 root; --snapshot pins a kind:5129
|
||||||
|
| [--path P] … immutable snapshot by event id)
|
||||||
|
|
|
||||||
|Contacts (NIP-02 kind:3):
|
|Contacts (NIP-02 kind:3):
|
||||||
| follow USER [--timeout SECS] add USER to your contact list
|
| follow USER [--timeout SECS] add USER to your contact list
|
||||||
| unfollow USER [--timeout SECS] remove USER from your contact list
|
| unfollow USER [--timeout SECS] remove USER from your contact list
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ object Commands {
|
|||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
): Int = NsiteCommands.dispatch(dataDir, tail)
|
): Int = NsiteCommands.dispatch(dataDir, tail)
|
||||||
|
|
||||||
|
suspend fun napplet(
|
||||||
|
dataDir: DataDir,
|
||||||
|
tail: Array<String>,
|
||||||
|
): Int = NappletCommands.dispatch(dataDir, tail)
|
||||||
|
|
||||||
suspend fun store(
|
suspend fun store(
|
||||||
dataDir: DataDir,
|
dataDir: DataDir,
|
||||||
tail: Array<String>,
|
tail: Array<String>,
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/*
|
||||||
|
* 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.quartz.nip01Core.core.Event
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
|
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
|
||||||
|
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||||
|
import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent
|
||||||
|
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `amy napplet fetch` — resolve a single path of a NIP-5D napplet over
|
||||||
|
* Nostr + Blossom with the full runtime verification contract:
|
||||||
|
*
|
||||||
|
* 1. fetch + signature-verify the manifest (kind 15129 root, 35129 named with
|
||||||
|
* `--d`, or 5129 snapshot with `--snapshot <event-id>`),
|
||||||
|
* 2. recompute the NIP-5A aggregate hash and check it against the manifest's `x`
|
||||||
|
* tag — a mismatch is refused up front (`aggregate_mismatch`),
|
||||||
|
* 3. download the path's blob from Blossom and accept only a copy whose sha256
|
||||||
|
* matches the per-path pin (an untrusted server cannot substitute a blob).
|
||||||
|
*
|
||||||
|
* It also reports the napplet's `requires` capabilities, so a shell can see which
|
||||||
|
* NAP domains it would have to broker. Steps 2–3 live in quartz
|
||||||
|
* (`NappletManifest.verifyAggregate`, `StaticSiteResolver`); this is thin glue,
|
||||||
|
* shared with `amy nsite` via [StaticSiteFetch].
|
||||||
|
*/
|
||||||
|
object NappletCommands {
|
||||||
|
suspend fun dispatch(
|
||||||
|
dataDir: DataDir,
|
||||||
|
tail: Array<String>,
|
||||||
|
): Int {
|
||||||
|
if (tail.isEmpty()) return Output.error("bad_args", "napplet <fetch> …")
|
||||||
|
val rest = tail.drop(1).toTypedArray()
|
||||||
|
return when (tail[0]) {
|
||||||
|
"fetch" -> fetch(dataDir, rest)
|
||||||
|
else -> Output.error("bad_args", "napplet ${tail[0]}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun fetch(
|
||||||
|
dataDir: DataDir,
|
||||||
|
rest: Array<String>,
|
||||||
|
): Int {
|
||||||
|
val args = Args(rest)
|
||||||
|
val snapshotId = args.flag("snapshot")
|
||||||
|
val author = args.positionalOrNull(0)
|
||||||
|
if (snapshotId == null && author == null) {
|
||||||
|
return Output.error("bad_args", "napplet fetch <author> [--d ID] | --snapshot <event-id> [--path P]")
|
||||||
|
}
|
||||||
|
val identifier = args.flag("d")
|
||||||
|
val requestPath = args.flag("path", "/")!!
|
||||||
|
val outFile = args.flag("out")
|
||||||
|
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||||
|
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
|
||||||
|
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||||
|
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||||
|
|
||||||
|
val ctx = Context.open(dataDir)
|
||||||
|
try {
|
||||||
|
ctx.prepare()
|
||||||
|
val relays =
|
||||||
|
extraRelays
|
||||||
|
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||||
|
.toSet()
|
||||||
|
.ifEmpty { ctx.bootstrapRelays() }
|
||||||
|
|
||||||
|
val event =
|
||||||
|
if (snapshotId != null) {
|
||||||
|
fetchSnapshot(ctx, snapshotId, relays, timeoutSecs * 1000)
|
||||||
|
} else {
|
||||||
|
val authorHex = ctx.requireUserHex(author!!)
|
||||||
|
fetchByAuthor(ctx, authorHex, identifier, relays, timeoutSecs * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event == null) {
|
||||||
|
return Output.error(
|
||||||
|
"not_found",
|
||||||
|
"no napplet manifest found",
|
||||||
|
mapOf(
|
||||||
|
"kind" to expectedKind(snapshotId, identifier),
|
||||||
|
"snapshot" to snapshotId,
|
||||||
|
"d" to identifier,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val manifest = event as NappletManifest
|
||||||
|
|
||||||
|
// NIP-5D step 3: the signed aggregate MUST match the path tags. Refuse a
|
||||||
|
// tampered/inconsistent manifest before fetching any third-party blob.
|
||||||
|
if (!manifest.verifyAggregate()) {
|
||||||
|
return Output.error(
|
||||||
|
"aggregate_mismatch",
|
||||||
|
"manifest x aggregate does not match its path tags",
|
||||||
|
mapOf(
|
||||||
|
"kind" to event.kind,
|
||||||
|
"manifest_event_id" to event.id,
|
||||||
|
"declared" to manifest.declaredAggregateHash(),
|
||||||
|
"computed" to manifest.computeAggregateHash(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val declaredAggregate = manifest.declaredAggregateHash()
|
||||||
|
return StaticSiteFetch.resolveAndEmit(
|
||||||
|
requestPath = requestPath,
|
||||||
|
paths = manifest.paths(),
|
||||||
|
servers = (manifest.servers() + extraServers).distinct(),
|
||||||
|
manifestFields =
|
||||||
|
mapOf(
|
||||||
|
"kind" to event.kind,
|
||||||
|
"manifest_event_id" to event.id,
|
||||||
|
"d" to identifier,
|
||||||
|
"requires" to manifest.requires(),
|
||||||
|
"aggregate_sha256" to declaredAggregate,
|
||||||
|
"aggregate_verified" to (declaredAggregate != null),
|
||||||
|
),
|
||||||
|
outFile = outFile,
|
||||||
|
maxInlineBytes = maxInlineBytes,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
ctx.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectedKind(
|
||||||
|
snapshotId: String?,
|
||||||
|
identifier: String?,
|
||||||
|
): Int =
|
||||||
|
when {
|
||||||
|
snapshotId != null -> NappletSnapshotEvent.KIND
|
||||||
|
identifier != null -> NamedNappletEvent.KIND
|
||||||
|
else -> RootNappletEvent.KIND
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the latest napplet manifest for [authorHex]: a [NamedNappletEvent] (kind
|
||||||
|
* 35129) addressed by [identifier] when `--d` is given, otherwise the author's
|
||||||
|
* root [RootNappletEvent] (kind 15129).
|
||||||
|
*/
|
||||||
|
private suspend fun fetchByAuthor(
|
||||||
|
ctx: Context,
|
||||||
|
authorHex: String,
|
||||||
|
identifier: String?,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
timeoutMs: Long,
|
||||||
|
): Event? {
|
||||||
|
if (relays.isEmpty()) return null
|
||||||
|
val filter =
|
||||||
|
if (identifier != null) {
|
||||||
|
Filter(
|
||||||
|
kinds = listOf(NamedNappletEvent.KIND),
|
||||||
|
authors = listOf(authorHex),
|
||||||
|
tags = mapOf("d" to listOf(identifier)),
|
||||||
|
limit = 1,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Filter(kinds = listOf(RootNappletEvent.KIND), authors = listOf(authorHex), limit = 1)
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
.drain(relays.associateWith { listOf(filter) }, timeoutMs)
|
||||||
|
.map { (_, ev) -> ev }
|
||||||
|
.filter { it.pubKey == authorHex && matchesIdentifier(it, identifier) }
|
||||||
|
.maxByOrNull { it.createdAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch a specific immutable snapshot ([NappletSnapshotEvent] / kind 5129) by event id. */
|
||||||
|
private suspend fun fetchSnapshot(
|
||||||
|
ctx: Context,
|
||||||
|
eventId: String,
|
||||||
|
relays: Set<NormalizedRelayUrl>,
|
||||||
|
timeoutMs: Long,
|
||||||
|
): Event? {
|
||||||
|
if (relays.isEmpty()) return null
|
||||||
|
val filter = Filter(ids = listOf(eventId), kinds = listOf(NappletSnapshotEvent.KIND), limit = 1)
|
||||||
|
return ctx
|
||||||
|
.drain(relays.associateWith { listOf(filter) }, timeoutMs)
|
||||||
|
.map { (_, ev) -> ev }
|
||||||
|
.firstOrNull { it is NappletSnapshotEvent && it.id == eventId }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun matchesIdentifier(
|
||||||
|
event: Event,
|
||||||
|
identifier: String?,
|
||||||
|
): Boolean =
|
||||||
|
when (event) {
|
||||||
|
is NamedNappletEvent -> event.identifier() == identifier
|
||||||
|
is RootNappletEvent -> identifier == null
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,32 +24,29 @@ import com.vitorpamplona.amethyst.cli.Args
|
|||||||
import com.vitorpamplona.amethyst.cli.Context
|
import com.vitorpamplona.amethyst.cli.Context
|
||||||
import com.vitorpamplona.amethyst.cli.DataDir
|
import com.vitorpamplona.amethyst.cli.DataDir
|
||||||
import com.vitorpamplona.amethyst.cli.Output
|
import com.vitorpamplona.amethyst.cli.Output
|
||||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
|
||||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
|
|
||||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
|
||||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* `amy nsite fetch` — resolve a single path of a NIP-5A static website / napplet
|
* `amy nsite fetch` — resolve a single path of a NIP-5A static website over
|
||||||
* over Nostr + Blossom, **verifying** the content against the signed manifest.
|
* Nostr + Blossom, **verifying** the content against the signed manifest.
|
||||||
*
|
*
|
||||||
* The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with
|
* The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with
|
||||||
* `--d`) pins each path to a sha256. This command fetches the manifest from
|
* `--d`) pins each path to a sha256. This command fetches the manifest from
|
||||||
* relays, then downloads the requested path's blob from the manifest's Blossom
|
* relays, then downloads the requested path's blob from the manifest's Blossom
|
||||||
* servers and accepts the first copy whose recomputed sha256 matches the pin — an
|
* servers and accepts the first copy whose recomputed sha256 matches the pin — an
|
||||||
* untrusted server that substitutes or corrupts the blob is skipped. This is the
|
* untrusted server that substitutes or corrupts the blob is skipped.
|
||||||
* same trust boundary a napplet shell relies on, exercised end-to-end so the
|
|
||||||
* resolver can be checked against real-world manifests (interop / agents).
|
|
||||||
*
|
*
|
||||||
* Thin-assembly only: all resolution + verification lives in quartz
|
* For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability
|
||||||
* (`StaticSiteResolver`) and the byte fetch in commons (`BlossomClient.download`).
|
* verification) use `amy napplet fetch`.
|
||||||
|
*
|
||||||
|
* Thin-assembly only: resolution + verification live in quartz (`StaticSiteResolver`),
|
||||||
|
* the byte fetch in commons (`BlossomClient.download`), shared via [StaticSiteFetch].
|
||||||
*/
|
*/
|
||||||
object NsiteCommands {
|
object NsiteCommands {
|
||||||
suspend fun dispatch(
|
suspend fun dispatch(
|
||||||
@@ -75,8 +72,8 @@ object NsiteCommands {
|
|||||||
val outFile = args.flag("out")
|
val outFile = args.flag("out")
|
||||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||||
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
|
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
|
||||||
val extraServers = commaList(args.flag("server"))
|
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||||
val extraRelays = commaList(args.flag("relay"))
|
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||||
|
|
||||||
val ctx = Context.open(dataDir)
|
val ctx = Context.open(dataDir)
|
||||||
try {
|
try {
|
||||||
@@ -102,88 +99,25 @@ object NsiteCommands {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val paths = manifest.paths
|
|
||||||
// Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe.
|
// Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe.
|
||||||
val servers = (manifest.servers + extraServers).distinct()
|
return StaticSiteFetch.resolveAndEmit(
|
||||||
if (servers.isEmpty()) {
|
requestPath = requestPath,
|
||||||
return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL")
|
paths = manifest.paths,
|
||||||
}
|
servers = (manifest.servers + extraServers).distinct(),
|
||||||
|
manifestFields =
|
||||||
val blossom = BlossomClient()
|
mapOf(
|
||||||
val resolution =
|
"kind" to manifest.kind,
|
||||||
StaticSiteResolver.resolve(
|
"manifest_event_id" to manifest.id,
|
||||||
requestPath = requestPath,
|
"d" to identifier,
|
||||||
paths = paths,
|
),
|
||||||
servers = servers,
|
outFile = outFile,
|
||||||
fetch = { url -> blossom.download(url) },
|
maxInlineBytes = maxInlineBytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
return when (resolution) {
|
|
||||||
is StaticSiteResolution.PathNotInManifest ->
|
|
||||||
Output.error(
|
|
||||||
"path_not_found",
|
|
||||||
"manifest declares no such path",
|
|
||||||
mapOf(
|
|
||||||
"path" to requestPath,
|
|
||||||
"available_paths" to paths.map { it.path },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
is StaticSiteResolution.Unresolvable ->
|
|
||||||
Output.error(
|
|
||||||
"unresolvable",
|
|
||||||
"no server returned a blob matching the manifest hash",
|
|
||||||
mapOf(
|
|
||||||
"path" to requestPath,
|
|
||||||
"sha256" to resolution.hash,
|
|
||||||
"servers" to servers,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
is StaticSiteResolution.Resolved -> {
|
|
||||||
emitResolved(resolution, requestPath, manifest, identifier, outFile, maxInlineBytes)
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
ctx.close()
|
ctx.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emitResolved(
|
|
||||||
resolved: StaticSiteResolution.Resolved,
|
|
||||||
requestPath: String,
|
|
||||||
manifest: SiteManifest,
|
|
||||||
identifier: String?,
|
|
||||||
outFile: String?,
|
|
||||||
maxInlineBytes: Long,
|
|
||||||
) {
|
|
||||||
val base =
|
|
||||||
linkedMapOf<String, Any?>(
|
|
||||||
"found" to true,
|
|
||||||
"verified" to true,
|
|
||||||
"request_path" to requestPath,
|
|
||||||
"manifest_path" to resolved.path,
|
|
||||||
"sha256" to resolved.hash,
|
|
||||||
"content_type" to resolved.contentType,
|
|
||||||
"size" to resolved.bytes.size,
|
|
||||||
"server" to resolved.server,
|
|
||||||
"manifest_event_id" to manifest.id,
|
|
||||||
"d" to identifier,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (outFile != null) {
|
|
||||||
File(outFile).writeBytes(resolved.bytes)
|
|
||||||
base["out"] = outFile
|
|
||||||
} else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) {
|
|
||||||
base["content"] = resolved.bytes.decodeToString()
|
|
||||||
} else {
|
|
||||||
base["note"] = "binary or large blob not inlined; pass --out FILE to save it"
|
|
||||||
}
|
|
||||||
|
|
||||||
Output.emit(base)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind
|
* Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind
|
||||||
* 35128) addressed by [identifier] when `--d` is given, otherwise the author's
|
* 35128) addressed by [identifier] when `--d` is given, otherwise the author's
|
||||||
@@ -223,37 +157,25 @@ object NsiteCommands {
|
|||||||
when (event) {
|
when (event) {
|
||||||
is NamedSiteEvent ->
|
is NamedSiteEvent ->
|
||||||
if (event.identifier() == identifier) {
|
if (event.identifier() == identifier) {
|
||||||
SiteManifest(event.id, event.createdAt, event.paths(), event.servers())
|
SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
is RootSiteEvent ->
|
is RootSiteEvent ->
|
||||||
if (identifier == null) {
|
if (identifier == null) {
|
||||||
SiteManifest(event.id, event.createdAt, event.paths(), event.servers())
|
SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun commaList(value: String?): List<String> =
|
|
||||||
value
|
|
||||||
?.split(',')
|
|
||||||
?.map { it.trim() }
|
|
||||||
?.filter { it.isNotEmpty() }
|
|
||||||
?: emptyList()
|
|
||||||
|
|
||||||
private fun isTextual(contentType: String): Boolean =
|
|
||||||
contentType.startsWith("text/") ||
|
|
||||||
contentType.startsWith("application/json") ||
|
|
||||||
contentType.startsWith("application/xml") ||
|
|
||||||
contentType.startsWith("image/svg")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flattened view of either manifest event type (`RootSiteEvent` /
|
* Flattened view of either manifest event type (`RootSiteEvent` /
|
||||||
* `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named.
|
* `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named.
|
||||||
*/
|
*/
|
||||||
private class SiteManifest(
|
private class SiteManifest(
|
||||||
|
val kind: Int,
|
||||||
val id: String,
|
val id: String,
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
val paths: List<PathTag>,
|
val paths: List<PathTag>,
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/*
|
||||||
|
* 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.Output
|
||||||
|
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
|
||||||
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution
|
||||||
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||||
|
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared Blossom resolve-and-emit used by both `amy nsite` (NIP-5A) and
|
||||||
|
* `amy napplet` (NIP-5D): given a manifest's `path` + `server` tags, download the
|
||||||
|
* requested path's blob, verify its sha256 against the pin, and print the result.
|
||||||
|
* All resolution + verification lives in quartz (`StaticSiteResolver`); this is
|
||||||
|
* thin glue around it.
|
||||||
|
*/
|
||||||
|
internal object StaticSiteFetch {
|
||||||
|
fun commaList(value: String?): List<String> =
|
||||||
|
value
|
||||||
|
?.split(',')
|
||||||
|
?.map { it.trim() }
|
||||||
|
?.filter { it.isNotEmpty() }
|
||||||
|
?: emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves [requestPath] against the manifest's [paths]/[servers], emitting the
|
||||||
|
* verified bytes (inlined for small text, or written to [outFile]) merged with
|
||||||
|
* the caller's [manifestFields] (kind, ids, requires, aggregate, …), or a
|
||||||
|
* structured `no_servers` / `path_not_found` / `unresolvable` error.
|
||||||
|
*/
|
||||||
|
suspend fun resolveAndEmit(
|
||||||
|
requestPath: String,
|
||||||
|
paths: List<PathTag>,
|
||||||
|
servers: List<String>,
|
||||||
|
manifestFields: Map<String, Any?>,
|
||||||
|
outFile: String?,
|
||||||
|
maxInlineBytes: Long,
|
||||||
|
): Int {
|
||||||
|
if (servers.isEmpty()) {
|
||||||
|
return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
val blossom = BlossomClient()
|
||||||
|
val resolution =
|
||||||
|
StaticSiteResolver.resolve(
|
||||||
|
requestPath = requestPath,
|
||||||
|
paths = paths,
|
||||||
|
servers = servers,
|
||||||
|
fetch = { url -> blossom.download(url) },
|
||||||
|
)
|
||||||
|
|
||||||
|
return when (resolution) {
|
||||||
|
is StaticSiteResolution.PathNotInManifest ->
|
||||||
|
Output.error(
|
||||||
|
"path_not_found",
|
||||||
|
"manifest declares no such path",
|
||||||
|
mapOf("path" to requestPath, "available_paths" to paths.map { it.path }),
|
||||||
|
)
|
||||||
|
|
||||||
|
is StaticSiteResolution.Unresolvable ->
|
||||||
|
Output.error(
|
||||||
|
"unresolvable",
|
||||||
|
"no server returned a blob matching the manifest hash",
|
||||||
|
mapOf("path" to requestPath, "sha256" to resolution.hash, "servers" to servers),
|
||||||
|
)
|
||||||
|
|
||||||
|
is StaticSiteResolution.Resolved -> {
|
||||||
|
emitResolved(resolution, requestPath, manifestFields, outFile, maxInlineBytes)
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitResolved(
|
||||||
|
resolved: StaticSiteResolution.Resolved,
|
||||||
|
requestPath: String,
|
||||||
|
manifestFields: Map<String, Any?>,
|
||||||
|
outFile: String?,
|
||||||
|
maxInlineBytes: Long,
|
||||||
|
) {
|
||||||
|
val base =
|
||||||
|
linkedMapOf<String, Any?>(
|
||||||
|
"found" to true,
|
||||||
|
"verified" to true,
|
||||||
|
"request_path" to requestPath,
|
||||||
|
"manifest_path" to resolved.path,
|
||||||
|
"sha256" to resolved.hash,
|
||||||
|
"content_type" to resolved.contentType,
|
||||||
|
"size" to resolved.bytes.size,
|
||||||
|
"server" to resolved.server,
|
||||||
|
)
|
||||||
|
base.putAll(manifestFields)
|
||||||
|
|
||||||
|
if (outFile != null) {
|
||||||
|
File(outFile).writeBytes(resolved.bytes)
|
||||||
|
base["out"] = outFile
|
||||||
|
} else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) {
|
||||||
|
base["content"] = resolved.bytes.decodeToString()
|
||||||
|
} else {
|
||||||
|
base["note"] = "binary or large blob not inlined; pass --out FILE to save it"
|
||||||
|
}
|
||||||
|
|
||||||
|
Output.emit(base)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isTextual(contentType: String): Boolean =
|
||||||
|
contentType.startsWith("text/") ||
|
||||||
|
contentType.startsWith("application/json") ||
|
||||||
|
contentType.startsWith("application/xml") ||
|
||||||
|
contentType.startsWith("image/svg")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user