From 09b9939956faf15ef1727582629c1d02d6f0396e Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Thu, 23 Apr 2026 20:50:03 -0300 Subject: [PATCH] nsite command. --- blossom.go | 18 +++- go.mod | 4 +- go.sum | 6 +- main.go | 1 + nsite.go | 304 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 nsite.go diff --git a/blossom.go b/blossom.go index d8882b3..248bc5a 100644 --- a/blossom.go +++ b/blossom.go @@ -3,9 +3,11 @@ package main import ( "bytes" "context" + "encoding/hex" "fmt" "io" "os" + "unsafe" "fiatjaf.com/nostr/keyer" "fiatjaf.com/nostr/nipb0/blossom" @@ -149,12 +151,24 @@ var blossomCmd = &cli.Command{ outputs := c.StringSlice("output") hasError := false - for i, hash := range c.Args().Slice() { + var hash [32]byte + for i, hhash := range c.Args().Slice() { + if len(hhash) != 64 { + log("invalid blob hash '%s': %s\n", hhash, err) + hasError = true + continue + } + if _, err := hex.Decode(hash[:], unsafe.Slice(unsafe.StringData(hhash), 32)); err != nil { + log("invalid blob hash '%s': %s\n", hhash, err) + hasError = true + continue + } + if len(outputs)-1 >= i && outputs[i] != "--" { // save to this file err := client.DownloadToFile(ctx, hash, outputs[i]) if err != nil { - fmt.Fprintf(os.Stderr, "%s\n", err) + log("download failed for '%s': %s\n", hhash, err) hasError = true } } else { diff --git a/go.mod b/go.mod index cc3f319..c24350d 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( ) require ( - fiatjaf.com/lib v0.3.6 + fiatjaf.com/lib v0.3.7 github.com/hanwen/go-fuse/v2 v2.9.0 github.com/itchyny/gojq v0.12.19 ) @@ -111,3 +111,5 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/qr v0.2.0 // indirect ) + +replace fiatjaf.com/nostr => ../nostrlib diff --git a/go.sum b/go.sum index 5aa24cf..9942b91 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -fiatjaf.com/lib v0.3.6 h1:GRZNSxHI2EWdjSKVuzaT+c0aifLDtS16SzkeJaHyJfY= -fiatjaf.com/lib v0.3.6/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= -fiatjaf.com/nostr v0.0.0-20260419231400-94ea4328184f h1:Y0KRSQnEDBxv5rxBJLSzGPOn5kwBCPAe2lri3+LEsKw= -fiatjaf.com/nostr v0.0.0-20260419231400-94ea4328184f/go.mod h1:1cmygNC87Pw06/WjkZqDV+Xo6rV10kpTjzuayosIX4Y= +fiatjaf.com/lib v0.3.7 h1:mXZOn7NrUcjSdy4oNvwQyAmes7Ueb+Zr5hjqMIe2dxI= +fiatjaf.com/lib v0.3.7/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc= diff --git a/main.go b/main.go index decb367..c963a5e 100644 --- a/main.go +++ b/main.go @@ -49,6 +49,7 @@ var app = &cli.Command{ curl, fsCmd, publish, + nsite, git, group, nip, diff --git a/nsite.go b/nsite.go new file mode 100644 index 0000000..135afa2 --- /dev/null +++ b/nsite.go @@ -0,0 +1,304 @@ +package main + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/keyer" + "fiatjaf.com/nostr/nip19" + "fiatjaf.com/nostr/nip5a" + "fiatjaf.com/nostr/nipb0/blossom" + "github.com/urfave/cli/v3" +) + +var nsite = &cli.Command{ + Name: "nsite", + Suggest: true, + Usage: "publishes and downloads nip-5A static sites", + ArgsUsage: " [relay...]", + DisableSliceFlagSeparator: true, + Flags: defaultKeyFlags, + Commands: []*cli.Command{ + { + Name: "upload", + Usage: "uploads site files and publishes manifest event", + ArgsUsage: " [relay...]", + DisableSliceFlagSeparator: true, + Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "root", + Usage: "publish root site as kind 15128", + }, + &cli.StringFlag{ + Name: "identifier", + Aliases: []string{"d"}, + Usage: "publish named site as kind 35128 with this d tag", + }, + &cli.StringFlag{ + Name: "description", + Usage: "a human-readable description of the site", + }, + &cli.StringFlag{ + Name: "source", + Usage: "a link to the source code of the site", + }, + &cli.StringSliceFlag{ + Name: "server", + Aliases: []string{"s"}, + Usage: "blossom server hostname or URL, can be given multiple times", + DefaultText: "defaults to the publisher's list of preferred blossom servers", + }, + &cli.BoolFlag{ + Name: "yes", + Aliases: []string{"y"}, + Usage: "skip upload confirmation prompt", + }, + }, + Action: func(ctx context.Context, c *cli.Command) error { + dir := c.Args().First() + if dir == "" { + return fmt.Errorf("missing directory") + } + + st, err := os.Stat(dir) + if err != nil { + return fmt.Errorf("failed to stat %s: %w", dir, err) + } + if !st.IsDir() { + return fmt.Errorf("%s is not a directory", dir) + } + + root := c.Bool("root") + identifier := c.String("identifier") + if root == (identifier != "") { + return fmt.Errorf("pick exactly one of --root or --identifier/-d") + } + + kr, _, err := gatherKeyerFromArguments(ctx, c) + if err != nil { + return err + } + pk, err := kr.GetPublicKey(ctx) + if err != nil { + return fmt.Errorf("failed to get public key: %w", err) + } + + manifest := nip5a.SiteManifest{ + Pubkey: pk, + Root: root, + Identifier: identifier, + Paths: make(map[string][32]byte), + Description: c.String("description"), + Source: c.String("source"), + } + + blossomServers := c.StringSlice("server") + if len(blossomServers) != 0 { + manifest.Servers = blossomServers + } else { + servers := sys.FetchBlossomServerList(ctx, pk) + if len(servers.Items) == 0 { + return fmt.Errorf("no blossom servers advertised in manifest or kind:10063") + } + blossomServers = make([]string, len(servers.Items)) + for i, s := range servers.Items { + blossomServers[i] = s.Value() + } + } + + if !c.Bool("yes") { + log("files:\n") + + if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + log(" /%s\n", filepath.ToSlash(relPath)) + return nil + }); err != nil { + return err + } + + log("blossom servers:\n") + for _, server := range blossomServers { + log(" %s\n", server) + } + if !askConfirmation("upload nsite and publish manifest? [y/n] ") { + return fmt.Errorf("aborted") + } + } + + if err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !d.Type().IsRegular() { + return nil + } + + relPath, err := filepath.Rel(dir, path) + if err != nil { + return fmt.Errorf("failed to get relative path for %s: %w", path, err) + } + + var hhash string + for _, server := range blossomServers { + client := blossom.NewClient(server, kr) + bd, err := client.UploadFilePath(ctx, path) + if err != nil { + return fmt.Errorf("failed to upload %s to %s: %w", path, server, err) + } + hhash = bd.SHA256 + log("uploaded %s to %s as %s\n", path, server, hhash) + } + + var hash [32]byte + if _, err := hex.Decode(hash[:], []byte(hhash)); err != nil { + return fmt.Errorf("invalid blob hash '%s': %w", hhash, err) + } + manifest.Paths["/"+filepath.ToSlash(relPath)] = hash + + return nil + }); err != nil { + return err + } + + evt := manifest.ToEvent() + if err := kr.SignEvent(ctx, &evt); err != nil { + return fmt.Errorf("error signing manifest event: %w", err) + } + + relayURLs := nostr.AppendUnique(sys.FetchWriteRelays(ctx, pk), c.Args().Slice()[1:]...) + if len(relayURLs) == 0 { + return fmt.Errorf("no relays to publish this nsite to") + } + + sys.Pool.AuthRequiredHandler = func(ctx context.Context, authEvent *nostr.Event) error { + return authSigner(ctx, c, func(string, ...any) {}, authEvent) + } + relays := connectToAllRelays(ctx, c, relayURLs, nil) + if len(relays) == 0 { + return fmt.Errorf("failed to connect to any of [ %v ]", relayURLs) + } + + stdout(evt.String()) + if identifier == "" { + stdout(nip19.EncodeNpub(pk)) + } else { + stdout(nip5a.PubKeyToBase36(pk) + identifier) + } + + return publishFlow(ctx, c, kr, evt, relays) + }, + }, + { + Name: "download", + Usage: "downloads all files from a published nsite", + ArgsUsage: " [directory]", + DisableSliceFlagSeparator: true, + Action: func(ctx context.Context, c *cli.Command) error { + input := c.Args().First() + if input == "" { + return fmt.Errorf("missing site") + } + + outputDir := c.Args().Get(1) + if outputDir == "" { + return fmt.Errorf("missing write directory") + } + if st, err := os.Stat(outputDir); err == nil { + if st.IsDir() { + return fmt.Errorf("output directory %s already exists", outputDir) + } + return fmt.Errorf("output path %s already exists and is not a directory", outputDir) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to stat output directory %s: %w", outputDir, err) + } + + pk, identifier, isRoot, err := nip5a.DecodeSiteURL(input) + if err != nil { + return err + } + + filter := nostr.Filter{ + Authors: []nostr.PubKey{pk}, + Limit: 1, + } + if isRoot { + filter.Kinds = []nostr.Kind{nostr.KindNsiteRoot} + } else { + filter.Kinds = []nostr.Kind{nostr.KindNsiteNamed} + filter.Tags = nostr.TagMap{"d": []string{identifier}} + } + + res := sys.Pool.QuerySingle(ctx, sys.FetchWriteRelays(ctx, pk), filter, nostr.SubscriptionOptions{ + Label: "nak-nsite", + }) + if res == nil { + return fmt.Errorf("failed to fetch nsite with filter %v", filter) + } + + mnf, err := nip5a.ParseSiteManifest(&res.Event) + if err != nil { + return fmt.Errorf("invalid nsite %s: %w", res.Event, err) + } + + blossomServers := mnf.Servers + if len(blossomServers) == 0 { + servers := sys.FetchBlossomServerList(ctx, res.Event.PubKey) + if len(servers.Items) == 0 { + return fmt.Errorf("no blossom servers advertised in manifest or kind:10063") + } + blossomServers = make([]string, len(servers.Items)) + for i, s := range servers.Items { + blossomServers[i] = s.Value() + } + } + + signer := keyer.NewReadOnlySigner(pk) + + for path, hash := range mnf.Paths { + fullPath := filepath.Join(outputDir, filepath.FromSlash(strings.TrimPrefix(path, "/"))) + if err := os.MkdirAll(filepath.Dir(fullPath), 0o755); err != nil { + return fmt.Errorf("failed to create %s: %w", filepath.Dir(fullPath), err) + } + + var downloadErr error + for _, server := range blossomServers { + client := blossom.NewClient(server, signer) + data, err := client.Download(ctx, hash) + if err != nil { + downloadErr = err + continue + } + if err := os.WriteFile(fullPath, data, 0o644); err != nil { + return fmt.Errorf("failed to write %s: %w", fullPath, err) + } + stdout(path) + downloadErr = nil + break + } + if downloadErr != nil { + return fmt.Errorf("failed to download '%s': %w", path, downloadErr) + } + } + + return nil + }, + }, + }, +}