nsite command.

This commit is contained in:
fiatjaf
2026-04-23 20:50:03 -03:00
parent 2a8cd898c2
commit 09b9939956
5 changed files with 326 additions and 7 deletions

View File

@@ -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 {

4
go.mod
View File

@@ -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

6
go.sum
View File

@@ -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=

View File

@@ -49,6 +49,7 @@ var app = &cli.Command{
curl,
fsCmd,
publish,
nsite,
git,
group,
nip,

304
nsite.go Normal file
View File

@@ -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: "<directory> [relay...]",
DisableSliceFlagSeparator: true,
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "upload",
Usage: "uploads site files and publishes manifest event",
ArgsUsage: "<directory> [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: "<site> [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
},
},
},
}