mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +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)
|
||||
}
|
||||
|
||||
"napplet" -> {
|
||||
Commands.napplet(dataDir, tail)
|
||||
}
|
||||
|
||||
"store" -> {
|
||||
Commands.store(dataDir, tail)
|
||||
}
|
||||
@@ -362,13 +366,21 @@ private fun printUsage() {
|
||||
| [--since TS] [--until TS]
|
||||
| [--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
|
||||
| [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin
|
||||
| [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain;
|
||||
| [--max-inline-bytes N] --d selects a kind:35128 named site, else the
|
||||
| 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):
|
||||
| follow USER [--timeout SECS] add USER to your contact list
|
||||
| unfollow USER [--timeout SECS] remove USER from your contact list
|
||||
|
||||
@@ -96,6 +96,11 @@ object Commands {
|
||||
tail: Array<String>,
|
||||
): Int = NsiteCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun napplet(
|
||||
dataDir: DataDir,
|
||||
tail: Array<String>,
|
||||
): Int = NappletCommands.dispatch(dataDir, tail)
|
||||
|
||||
suspend fun store(
|
||||
dataDir: DataDir,
|
||||
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.DataDir
|
||||
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.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
|
||||
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 java.io.File
|
||||
|
||||
/**
|
||||
* `amy nsite fetch` — resolve a single path of a NIP-5A static website / napplet
|
||||
* over Nostr + Blossom, **verifying** the content against the signed manifest.
|
||||
* `amy nsite fetch` — resolve a single path of a NIP-5A static website over
|
||||
* Nostr + Blossom, **verifying** the content against the signed manifest.
|
||||
*
|
||||
* The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with
|
||||
* `--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
|
||||
* 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
|
||||
* same trust boundary a napplet shell relies on, exercised end-to-end so the
|
||||
* resolver can be checked against real-world manifests (interop / agents).
|
||||
* untrusted server that substitutes or corrupts the blob is skipped.
|
||||
*
|
||||
* Thin-assembly only: all resolution + verification lives in quartz
|
||||
* (`StaticSiteResolver`) and the byte fetch in commons (`BlossomClient.download`).
|
||||
* For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability
|
||||
* 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 {
|
||||
suspend fun dispatch(
|
||||
@@ -75,8 +72,8 @@ object NsiteCommands {
|
||||
val outFile = args.flag("out")
|
||||
val timeoutSecs = args.longFlag("timeout", 8L)
|
||||
val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L)
|
||||
val extraServers = commaList(args.flag("server"))
|
||||
val extraRelays = commaList(args.flag("relay"))
|
||||
val extraServers = StaticSiteFetch.commaList(args.flag("server"))
|
||||
val extraRelays = StaticSiteFetch.commaList(args.flag("relay"))
|
||||
|
||||
val ctx = Context.open(dataDir)
|
||||
try {
|
||||
@@ -102,88 +99,25 @@ object NsiteCommands {
|
||||
)
|
||||
}
|
||||
|
||||
val paths = manifest.paths
|
||||
// Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe.
|
||||
val servers = (manifest.servers + extraServers).distinct()
|
||||
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, manifest, identifier, outFile, maxInlineBytes)
|
||||
0
|
||||
}
|
||||
}
|
||||
return StaticSiteFetch.resolveAndEmit(
|
||||
requestPath = requestPath,
|
||||
paths = manifest.paths,
|
||||
servers = (manifest.servers + extraServers).distinct(),
|
||||
manifestFields =
|
||||
mapOf(
|
||||
"kind" to manifest.kind,
|
||||
"manifest_event_id" to manifest.id,
|
||||
"d" to identifier,
|
||||
),
|
||||
outFile = outFile,
|
||||
maxInlineBytes = maxInlineBytes,
|
||||
)
|
||||
} finally {
|
||||
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
|
||||
* 35128) addressed by [identifier] when `--d` is given, otherwise the author's
|
||||
@@ -223,37 +157,25 @@ object NsiteCommands {
|
||||
when (event) {
|
||||
is NamedSiteEvent ->
|
||||
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 {
|
||||
null
|
||||
}
|
||||
is RootSiteEvent ->
|
||||
if (identifier == null) {
|
||||
SiteManifest(event.id, event.createdAt, event.paths(), event.servers())
|
||||
SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers())
|
||||
} 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` /
|
||||
* `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named.
|
||||
*/
|
||||
private class SiteManifest(
|
||||
val kind: Int,
|
||||
val id: String,
|
||||
val createdAt: Long,
|
||||
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