mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-25 20:08:52 +00:00
Compare commits
24 Commits
v0.17.4
...
7d782737c4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d782737c4 | ||
|
|
9160c68cb5 | ||
|
|
bf19f38996 | ||
|
|
4e2c136e45 | ||
|
|
8cef1ed0ea | ||
|
|
e05b455a05 | ||
|
|
9190c9d988 | ||
|
|
e64ad8f078 | ||
|
|
b36718caaa | ||
|
|
5c658c38f1 | ||
|
|
2a5ce3b249 | ||
|
|
c0b85af734 | ||
|
|
cb2247c9da | ||
|
|
686d960f62 | ||
|
|
af04838153 | ||
|
|
c6da13649d | ||
|
|
acd6227dd0 | ||
|
|
00fbda9af7 | ||
|
|
e838de9b72 | ||
|
|
6dfbed4413 | ||
|
|
0e283368ed | ||
|
|
38775e0d93 | ||
|
|
fabcad3f61 | ||
|
|
69e4895e48 |
5
.github/workflows/release-cli.yml
vendored
5
.github/workflows/release-cli.yml
vendored
@@ -47,6 +47,7 @@ jobs:
|
||||
md5sum: false
|
||||
sha256sum: false
|
||||
compress_assets: false
|
||||
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
# test NIP-49 key encryption/decryption
|
||||
echo "testing NIP-49 key encryption/decryption..."
|
||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||
echo "encrypted key: ${ENCRYPTED_KEY:0:20}..."
|
||||
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||
echo "nip-49 encryption/decryption test failed!"
|
||||
@@ -116,7 +117,7 @@ jobs:
|
||||
# test relay operations (with a public relay)
|
||||
echo "testing publishing..."
|
||||
# publish a simple event to a public relay
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol)
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
|
||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||
echo "published event ID: $EVENT_ID"
|
||||
|
||||
|
||||
@@ -427,6 +427,7 @@ gitnostr.com... ok.
|
||||
```shell
|
||||
~> nak git clone
|
||||
~> nak git init
|
||||
~> nak git status
|
||||
~> nak git sync
|
||||
~> nak git fetch
|
||||
~> nak git pull
|
||||
|
||||
52
blossom.go
52
blossom.go
@@ -229,12 +229,56 @@ if any of the files are not found the command will fail, otherwise it will succe
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "mirror",
|
||||
Usage: "",
|
||||
Description: ``,
|
||||
Name: "mirror",
|
||||
Usage: "mirrors a from a server to another",
|
||||
Description: `examples:
|
||||
mirroring a single blob:
|
||||
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
|
||||
|
||||
mirroring all blobs from a certain pubkey from one server to the other:
|
||||
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
client, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var bd blossom.BlobDescriptor
|
||||
if input := c.Args().First(); input != "" {
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(out)
|
||||
return nil
|
||||
} else {
|
||||
for input := range getJsonsOrBlank() {
|
||||
if input == "{}" {
|
||||
continue
|
||||
}
|
||||
|
||||
blobURL := input
|
||||
if err := json.Unmarshal([]byte(input), &bd); err == nil {
|
||||
blobURL = bd.URL
|
||||
}
|
||||
bd, err := client.MirrorBlob(ctx, blobURL)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "failed to mirror '%s': %w", blobURL, err)
|
||||
continue
|
||||
}
|
||||
out, _ := json.Marshal(bd)
|
||||
stdout(out)
|
||||
}
|
||||
|
||||
exitIfLineProcessingError(ctx)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
299
bunker.go
299
bunker.go
@@ -5,12 +5,12 @@ import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
@@ -73,13 +73,7 @@ var bunker = &cli.Command{
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// read config from file
|
||||
config := struct {
|
||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
|
||||
Secret plainOrEncryptedKey `json:"sec"`
|
||||
Relays []string `json:"relays"`
|
||||
}{
|
||||
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
|
||||
}
|
||||
config := BunkerConfig{}
|
||||
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||
for i, url := range baseRelaysUrls {
|
||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||
@@ -142,6 +136,15 @@ var bunker = &cli.Command{
|
||||
if err := json.Unmarshal(b, &config); err != nil {
|
||||
return err
|
||||
}
|
||||
// convert from deprecated field
|
||||
if len(config.AuthorizedKeys) > 0 {
|
||||
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
|
||||
for i := range config.AuthorizedKeys {
|
||||
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
|
||||
}
|
||||
config.AuthorizedKeys = nil
|
||||
persist()
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
@@ -150,7 +153,11 @@ var bunker = &cli.Command{
|
||||
config.Relays[i] = nostr.NormalizeURL(url)
|
||||
}
|
||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
||||
// we don't have any secret key stored, so just use whatever was given via flags
|
||||
@@ -167,7 +174,9 @@ var bunker = &cli.Command{
|
||||
} else {
|
||||
config.Secret = baseSecret
|
||||
config.Relays = baseRelaysUrls
|
||||
config.AuthorizedKeys = baseAuthorizedKeys
|
||||
for _, bak := range baseAuthorizedKeys {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||
}
|
||||
}
|
||||
|
||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||
@@ -205,8 +214,17 @@ var bunker = &cli.Command{
|
||||
|
||||
// try to connect to the relays here
|
||||
qs := url.Values{}
|
||||
relayURLs := make([]string, 0, len(config.Relays))
|
||||
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
|
||||
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||
copy(allRelays, config.Relays)
|
||||
for _, c := range config.Clients {
|
||||
for _, url := range c.CustomRelays {
|
||||
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
|
||||
allRelays = append(allRelays, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
relayURLs := make([]string, 0, len(allRelays))
|
||||
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
|
||||
if len(relays) == 0 {
|
||||
log("failed to connect to any of the given relays.\n")
|
||||
os.Exit(3)
|
||||
@@ -236,10 +254,22 @@ var bunker = &cli.Command{
|
||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||
|
||||
authorizedKeysStr := ""
|
||||
if len(config.AuthorizedKeys) != 0 {
|
||||
authorizedKeysStr = "\n authorized keys:"
|
||||
for _, pubkey := range config.AuthorizedKeys {
|
||||
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
|
||||
if len(config.Clients) != 0 {
|
||||
authorizedKeysStr = "\n authorized clients:"
|
||||
for _, c := range config.Clients {
|
||||
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
|
||||
name := ""
|
||||
if c.Name != "" {
|
||||
name = c.Name
|
||||
if c.URL != "" {
|
||||
name += " " + colors.underline(c.URL)
|
||||
}
|
||||
} else if c.URL != "" {
|
||||
name = colors.underline(c.URL)
|
||||
}
|
||||
if name != "" {
|
||||
authorizedKeysStr += " (" + name + ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,8 +279,8 @@ var bunker = &cli.Command{
|
||||
}
|
||||
|
||||
preauthorizedFlags := ""
|
||||
for _, k := range config.AuthorizedKeys {
|
||||
preauthorizedFlags += " -k " + k.Hex()
|
||||
for _, c := range config.Clients {
|
||||
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||
}
|
||||
for _, s := range authorizedSecrets {
|
||||
preauthorizedFlags += " -s " + s
|
||||
@@ -314,24 +344,84 @@ var bunker = &cli.Command{
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{
|
||||
Label: "nak-bunker",
|
||||
})
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||
|
||||
signer := nip46.NewStaticKeySigner(sec)
|
||||
handlerWg := sync.WaitGroup{}
|
||||
printLock := sync.Mutex{}
|
||||
|
||||
// unix socket nostrconnect:// handling
|
||||
go func() {
|
||||
for uri := range onSocketConnect(ctx, c) {
|
||||
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey), uri.String())
|
||||
|
||||
relays := uri.Query()["relay"]
|
||||
|
||||
// pre-authorize this client since the user has explicitly added it
|
||||
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
|
||||
return c.PubKey == clientPublicKey
|
||||
}) {
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{
|
||||
PubKey: clientPublicKey,
|
||||
Name: uri.Query().Get("name"),
|
||||
URL: uri.Query().Get("url"),
|
||||
Icon: uri.Query().Get("icon"),
|
||||
CustomRelays: relays,
|
||||
})
|
||||
}
|
||||
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
|
||||
if err != nil {
|
||||
log("* failed to handle: %s\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindNostrConnect},
|
||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||
Since: nostr.Now(),
|
||||
LimitZero: true,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
|
||||
events <- event
|
||||
}
|
||||
}()
|
||||
|
||||
time.Sleep(time.Millisecond * 25)
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// just a gimmick
|
||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||
_, cancel := context.WithCancel(ctx)
|
||||
cancelPreviousBunkerInfoPrint = cancel
|
||||
|
||||
// asking user for authorization
|
||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||
return true
|
||||
}
|
||||
if slices.Contains(authorizedSecrets, secret) {
|
||||
return true
|
||||
}
|
||||
|
||||
if secret == newSecret {
|
||||
// store this key
|
||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
||||
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||
// discard this and generate a new secret
|
||||
newSecret = randString(12)
|
||||
// print bunker info again after this
|
||||
@@ -343,43 +433,46 @@ var bunker = &cli.Command{
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
||||
return false
|
||||
}
|
||||
|
||||
for ie := range events {
|
||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||
|
||||
// handle the NIP-46 request event
|
||||
from := ie.Event.PubKey
|
||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||
if err != nil {
|
||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
||||
log("< failed to handle request from %s: %s\n", from, err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
|
||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||
log("~ responding with %s\n", string(jresp))
|
||||
|
||||
handlerWg.Add(len(relayURLs))
|
||||
for _, relayURL := range relayURLs {
|
||||
go func(relayURL string) {
|
||||
defer handlerWg.Done()
|
||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
||||
err := relay.Publish(ctx, eventResponse)
|
||||
printLock.Lock()
|
||||
if err == nil {
|
||||
log("* sent response through %s\n", relay.URL)
|
||||
} else {
|
||||
log("* failed to send response: %s\n", err)
|
||||
}
|
||||
printLock.Unlock()
|
||||
}
|
||||
}(relayURL)
|
||||
// use custom relays if they are defined for this client
|
||||
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||
relays := relayURLs
|
||||
for _, c := range config.Clients {
|
||||
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||
relays = c.CustomRelays
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||
if res.Error == nil {
|
||||
log("* sent response through %s\n", res.Relay.URL)
|
||||
} else {
|
||||
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||
}
|
||||
}
|
||||
handlerWg.Wait()
|
||||
|
||||
// just after handling one request we trigger this
|
||||
go func() {
|
||||
@@ -404,24 +497,44 @@ var bunker = &cli.Command{
|
||||
Name: "connect",
|
||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||
ArgsUsage: "<nostrconnect-uri>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "profile",
|
||||
Usage: "profile name of the bunker to connect to",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 1 {
|
||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||
}
|
||||
|
||||
uri, err := url.Parse(c.Args().First())
|
||||
if err != nil || uri.Scheme != "nostrconnect" {
|
||||
return fmt.Errorf("invalid uri")
|
||||
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||
}
|
||||
|
||||
// TODO
|
||||
|
||||
return fmt.Errorf("this is not implemented yet")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
type BunkerConfig struct {
|
||||
Clients []BunkerConfigClient `json:"clients"`
|
||||
Secret plainOrEncryptedKey `json:"sec"`
|
||||
Relays []string `json:"relays"`
|
||||
|
||||
// deprecated
|
||||
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
|
||||
}
|
||||
|
||||
type BunkerConfigClient struct {
|
||||
PubKey nostr.PubKey `json:"pubkey"`
|
||||
Name string `json:"name,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
CustomRelays []string `json:"custom_relays,omitempty"`
|
||||
}
|
||||
|
||||
type plainOrEncryptedKey struct {
|
||||
Plain *nostr.SecretKey
|
||||
Encrypted *string
|
||||
@@ -489,3 +602,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func getSocketPath(c *cli.Command) string {
|
||||
profile := "default"
|
||||
if c.IsSet("profile") {
|
||||
profile = c.String("profile")
|
||||
}
|
||||
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
|
||||
}
|
||||
|
||||
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
|
||||
res := make(chan *url.URL)
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
// ensure directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
|
||||
log(color.RedString("failed to create socket directory: %w\n", err))
|
||||
return res
|
||||
}
|
||||
|
||||
// delete existing socket file if it exists
|
||||
if _, err := os.Stat(socketPath); err == nil {
|
||||
if err := os.Remove(socketPath); err != nil {
|
||||
log(color.RedString("failed to remove existing socket file: %w\n", err))
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
log(color.RedString("failed to listen on unix socket %s: %w\n", socketPath, err))
|
||||
return res
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
defer os.Remove(socketPath) // cleanup socket file on exit
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
go func(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 4096)
|
||||
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
n, err := conn.Read(buf)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
uri, err := url.Parse(string(buf[:n]))
|
||||
if err == nil && uri.Scheme == "nostrconnect" {
|
||||
res <- uri
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func sendToSocket(c *cli.Command, value string) error {
|
||||
socketPath := getSocketPath(c)
|
||||
|
||||
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
_, err = conn.Write([]byte(value))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send uri to bunker: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
55
encode.go
55
encode.go
@@ -25,13 +25,6 @@ var encode = &cli.Command{
|
||||
"relays":["wss://nada.zero"],
|
||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||
} | nak encode`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to naddr code",
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 0 {
|
||||
@@ -126,7 +119,12 @@ var encode = &cli.Command{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to nprofile code",
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
@@ -139,6 +137,13 @@ var encode = &cli.Command{
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,6 +164,16 @@ var encode = &cli.Command{
|
||||
Aliases: []string{"a"},
|
||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
@@ -171,6 +186,13 @@ var encode = &cli.Command{
|
||||
|
||||
author := getPubKey(c, "author")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,6 +226,16 @@ var encode = &cli.Command{
|
||||
Usage: "kind of referred replaceable event",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
@@ -224,6 +256,13 @@ var encode = &cli.Command{
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
3
event.go
3
event.go
@@ -145,7 +145,7 @@ example:
|
||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
@@ -155,6 +155,7 @@ example:
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
56
flags.go
56
flags.go
@@ -11,6 +11,62 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type (
|
||||
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
|
||||
)
|
||||
|
||||
type boolIntValue struct {
|
||||
int int
|
||||
defaultWhenSet int
|
||||
hasDefault bool
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
|
||||
|
||||
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
|
||||
*p = val
|
||||
|
||||
return &boolIntValue{
|
||||
defaultWhenSet: val,
|
||||
hasDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t boolIntValue) IsBoolFlag() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t boolIntValue) ToString(b int) string { return "<<>>" }
|
||||
|
||||
func (t *boolIntValue) Set(value string) error {
|
||||
t.hasBeenSet = true
|
||||
if value == "true" {
|
||||
if t.hasDefault {
|
||||
t.int = t.defaultWhenSet
|
||||
} else {
|
||||
t.int = 1
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
var err error
|
||||
t.int, err = strconv.Atoi(value)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
|
||||
func (t *boolIntValue) Value() int { return t.int }
|
||||
func (t *boolIntValue) Get() any { return t.int }
|
||||
|
||||
func getBoolInt(cmd *cli.Command, name string) int {
|
||||
return cmd.Value(name).(int)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||
|
||||
type naturalTimeValue struct {
|
||||
|
||||
2
fs.go
2
fs.go
@@ -1,4 +1,4 @@
|
||||
//go:build !windows && !openbsd
|
||||
//go:build !windows && !openbsd && !cgofuse
|
||||
|
||||
package main
|
||||
|
||||
|
||||
118
fs_cgo.go
Normal file
118
fs_cgo.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build cgofuse && !windows && !openbsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: append(defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem
|
||||
mountArgs := []string{"-s", mountpoint}
|
||||
if isVerbose {
|
||||
mountArgs = append([]string{"-d"}, mountArgs...)
|
||||
}
|
||||
|
||||
go func() {
|
||||
host.Mount("", mountArgs)
|
||||
}()
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
// setup signal handling for clean unmount
|
||||
ch := make(chan os.Signal, 1)
|
||||
chErr := make(chan error)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-ch
|
||||
log("- unmounting... ")
|
||||
// cgofuse doesn't have explicit unmount, it unmounts on process exit
|
||||
log("ok\n")
|
||||
chErr <- nil
|
||||
}()
|
||||
|
||||
// wait for signals
|
||||
return <-chErr
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build windows || openbsd
|
||||
//go:build openbsd
|
||||
|
||||
package main
|
||||
|
||||
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
|
||||
Description: `doesn't work on Windows and OpenBSD.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return fmt.Errorf("this doesn't work on Windows and OpenBSD.")
|
||||
return fmt.Errorf("this doesn't work on OpenBSD.")
|
||||
},
|
||||
}
|
||||
|
||||
140
fs_windows.go
Normal file
140
fs_windows.go
Normal file
@@ -0,0 +1,140 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: append(defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := nostrfs.NewNostrRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
nostrfs.Options{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem - Windows/WinFsp version
|
||||
// based on rclone cmount implementation
|
||||
mountArgs := []string{
|
||||
"-o", "uid=-1",
|
||||
"-o", "gid=-1",
|
||||
"--FileSystemName=nak",
|
||||
}
|
||||
|
||||
// check if mountpoint is a drive letter or directory
|
||||
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
|
||||
|
||||
if !isDriveLetter {
|
||||
// winFsp primarily supports drive letters on Windows
|
||||
// directory mounting may not work reliably
|
||||
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
|
||||
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
|
||||
|
||||
// for directory mounts, follow rclone's approach:
|
||||
// 1. check that mountpoint doesn't already exist
|
||||
if _, err := os.Stat(mountpoint); err == nil {
|
||||
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to check mountpoint: %w", err)
|
||||
}
|
||||
|
||||
// 2. check that parent directory exists
|
||||
parent := filepath.Join(mountpoint, "..")
|
||||
if _, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
|
||||
}
|
||||
return fmt.Errorf("failed to check parent directory: %w", err)
|
||||
}
|
||||
|
||||
// 3. use network mode for directory mounts
|
||||
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
|
||||
}
|
||||
|
||||
if isVerbose {
|
||||
mountArgs = append(mountArgs, "-o", "debug")
|
||||
}
|
||||
mountArgs = append(mountArgs, mountpoint)
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
if !host.Mount("", mountArgs) {
|
||||
return fmt.Errorf("failed to mount filesystem")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
162
git.go
162
git.go
@@ -181,7 +181,7 @@ aside from those, there is also:
|
||||
var fetchedRepo *nip34.Repository
|
||||
if existingConfig.Identifier == "" {
|
||||
log(" searching for existing events... ")
|
||||
repo, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
||||
repo, _, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
|
||||
if err == nil && repo.Event.ID != nostr.ZeroID {
|
||||
fetchedRepo = &repo
|
||||
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
||||
@@ -371,7 +371,7 @@ aside from those, there is also:
|
||||
}
|
||||
|
||||
// fetch repository metadata and state
|
||||
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -782,6 +782,98 @@ aside from those, there is also:
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "status",
|
||||
Usage: "show repository status and synchronization information",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
// read local config
|
||||
localConfig, err := readNip34ConfigFile("")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
|
||||
}
|
||||
|
||||
// parse owner
|
||||
owner, err := parsePubKey(localConfig.Owner)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid owner public key: %w", err)
|
||||
}
|
||||
|
||||
repo := localConfig.ToRepository()
|
||||
stdout("\n" + color.CyanString("metadata:"))
|
||||
stdout(" identifier:", color.CyanString(repo.ID))
|
||||
stdout(" name:", color.CyanString(repo.Name))
|
||||
stdout(" owner:", color.CyanString(nip19.EncodeNpub(repo.Event.PubKey)))
|
||||
stdout(" description:", color.CyanString(repo.Description))
|
||||
stdout(" web urls:")
|
||||
for _, url := range repo.Web {
|
||||
stdout(" ", url)
|
||||
}
|
||||
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
|
||||
|
||||
// fetch repository announcement and state from relays
|
||||
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||
if err != nil {
|
||||
// create a local repo object for display purposes
|
||||
log("failed to fetch repository announcement from relays: %s\n", err)
|
||||
}
|
||||
|
||||
if state == nil {
|
||||
stdout(color.YellowString("\n repository state not published."))
|
||||
}
|
||||
|
||||
stateHEAD, _ := state.Branches[state.HEAD]
|
||||
|
||||
stdout("\n" + color.CyanString("grasp status:"))
|
||||
rows := make([][3]string, len(localConfig.GraspServers))
|
||||
for s, server := range localConfig.GraspServers {
|
||||
row := [3]string{}
|
||||
|
||||
url := graspServerHost(server)
|
||||
row[0] = url
|
||||
|
||||
upToDate := upToDateRelays != nil && slices.ContainsFunc(upToDateRelays, func(s string) bool { return graspServerHost(s) == url })
|
||||
if upToDate {
|
||||
row[1] = color.GreenString("announcement up-to-date")
|
||||
} else {
|
||||
row[1] = color.YellowString("announcement outdated")
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
remoteName := gitRemoteName(url)
|
||||
refSpec := fmt.Sprintf("refs/remotes/%s/HEAD", remoteName)
|
||||
lsRemoteCmd := exec.Command("git", "rev-parse", "--verify", refSpec)
|
||||
commitOutput, err := lsRemoteCmd.Output()
|
||||
if err != nil {
|
||||
row[2] = color.YellowString("repository not pushed")
|
||||
} else {
|
||||
commit := strings.TrimSpace(string(commitOutput))
|
||||
if commit == stateHEAD {
|
||||
row[2] = color.GreenString("repository synced with state")
|
||||
} else {
|
||||
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows[s] = row
|
||||
}
|
||||
|
||||
maxCol := [3]int{}
|
||||
for i := range maxCol {
|
||||
for _, row := range rows {
|
||||
if len(row[i]) > maxCol[i] {
|
||||
maxCol[i] = len(row[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
line := " " + row[0] + strings.Repeat(" ", maxCol[0]-len(row[0])) + " " + strings.Repeat(" ", maxCol[1]-len(row[1])) + row[1] + " " + strings.Repeat(" ", maxCol[2]-len(row[2])) + row[2]
|
||||
stdout(line)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -869,7 +961,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
||||
}
|
||||
|
||||
// fetch repository announcement and state from relays
|
||||
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||
notUpToDate := func(graspServer string) bool {
|
||||
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
||||
}
|
||||
@@ -889,33 +981,40 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
||||
}
|
||||
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
||||
}
|
||||
// create a local repository object from config and publish it
|
||||
localRepo := localConfig.ToRepository()
|
||||
|
||||
if signer != nil {
|
||||
signerPk, err := signer.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
|
||||
}
|
||||
if signerPk != owner {
|
||||
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
||||
} else {
|
||||
event := localRepo.ToEvent()
|
||||
if err := signer.SignEvent(ctx, &event); err != nil {
|
||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||
var event nostr.Event
|
||||
if upToDateAnnouncementEvent != nil {
|
||||
// publish the latest event to the other relays
|
||||
event = *upToDateAnnouncementEvent
|
||||
repo = nip34.ParseRepository(event)
|
||||
} else {
|
||||
// create a local repository object from config and publish it
|
||||
localRepo := localConfig.ToRepository()
|
||||
if signer != nil {
|
||||
signerPk, err := signer.GetPublicKey(ctx)
|
||||
if err != nil {
|
||||
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
|
||||
}
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
||||
if res.Error != nil {
|
||||
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
||||
} else {
|
||||
log("> published to %s\n", color.GreenString(res.RelayURL))
|
||||
if signerPk != owner {
|
||||
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
||||
} else {
|
||||
event = localRepo.ToEvent()
|
||||
if err := signer.SignEvent(ctx, &event); err != nil {
|
||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||
}
|
||||
}
|
||||
repo = localRepo
|
||||
} else {
|
||||
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
||||
}
|
||||
|
||||
repo = localRepo
|
||||
}
|
||||
|
||||
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
|
||||
if res.Error != nil {
|
||||
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
||||
} else {
|
||||
log("> published to %s\n", color.GreenString(res.RelayURL))
|
||||
}
|
||||
} else {
|
||||
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
@@ -951,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
||||
} else {
|
||||
log("local configuration is newer, publishing updated repository announcement...\n")
|
||||
announcementEvent := localRepo.ToEvent()
|
||||
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
|
||||
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||
}
|
||||
@@ -1155,7 +1255,7 @@ func fetchRepositoryAndState(
|
||||
pubkey nostr.PubKey,
|
||||
identifier string,
|
||||
relayHints []string,
|
||||
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||
// fetch repository announcement (30617)
|
||||
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||
@@ -1176,13 +1276,15 @@ func fetchRepositoryAndState(
|
||||
|
||||
// reset this list as the previous was for relays with the older version
|
||||
upToDateRelays = []string{ie.Relay.URL}
|
||||
|
||||
upToDateAnnouncementEvent = &ie.Event
|
||||
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
||||
// we discard this because it's the same, but this relay is up-to-date
|
||||
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
||||
}
|
||||
}
|
||||
if repo.Event.ID == nostr.ZeroID {
|
||||
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||
return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||
}
|
||||
|
||||
// fetch repository state (30618)
|
||||
@@ -1212,10 +1314,10 @@ func fetchRepositoryAndState(
|
||||
}
|
||||
}
|
||||
if stateErr != nil {
|
||||
return repo, upToDateRelays, state, stateErr
|
||||
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
|
||||
}
|
||||
|
||||
return repo, upToDateRelays, state, nil
|
||||
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
|
||||
}
|
||||
|
||||
type StateErr struct{ string }
|
||||
|
||||
11
go.mod
11
go.mod
@@ -3,8 +3,7 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.1
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
|
||||
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/bep/debounce v1.2.1
|
||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||
@@ -12,7 +11,6 @@ require (
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/hanwen/go-fuse/v2 v2.7.2
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/liamg/magic v0.0.1
|
||||
github.com/mailru/easyjson v0.9.1
|
||||
@@ -24,11 +22,17 @@ require (
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||
github.com/winfsp/cgofuse v1.6.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.2
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
@@ -69,7 +73,6 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
18
go.sum
18
go.sum
@@ -1,7 +1,7 @@
|
||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4 h1:DF/4NSbCvXqIIRrwYp7L3S0SqC7/IhQl8mHkmYA5uXM=
|
||||
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
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=
|
||||
@@ -144,8 +144,8 @@ github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnm
|
||||
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
||||
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
||||
github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
|
||||
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
|
||||
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||
@@ -200,8 +200,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -269,6 +269,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
|
||||
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
|
||||
586
group.go
Normal file
586
group.go
Normal file
@@ -0,0 +1,586 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip29"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
var group = &cli.Command{
|
||||
Name: "group",
|
||||
Aliases: []string{"nip29"},
|
||||
Usage: "group-related operations: info, chat, forum, members, admins, roles",
|
||||
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
|
||||
Flags: defaultKeyFlags,
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "info",
|
||||
Usage: "show group information",
|
||||
Description: "displays basic group metadata.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group := nip29.Group{}
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
|
||||
stdout("name:", color.HiBlueString(group.Name))
|
||||
stdout("picture:", color.HiBlueString(group.Picture))
|
||||
stdout("about:", color.HiBlueString(group.About))
|
||||
stdout("restricted:",
|
||||
color.HiBlueString("%s", cond(group.Restricted, "yes", "no"))+
|
||||
", "+
|
||||
cond(group.Restricted, "only explicit members can publish", "non-members can publish (restricted by relay policy)"),
|
||||
)
|
||||
stdout("closed:",
|
||||
color.HiBlueString("%s", cond(group.Closed, "yes", "no"))+
|
||||
", "+
|
||||
cond(group.Closed, "joining requires an invite", "anyone can join (restricted by relay policy)"),
|
||||
)
|
||||
stdout("hidden:",
|
||||
color.HiBlueString("%s", cond(group.Hidden, "yes", "no"))+
|
||||
", "+
|
||||
cond(group.Hidden, "group doesn't show up when listing relay groups", "group is visible to users browsing the relay"),
|
||||
)
|
||||
stdout("private:",
|
||||
color.HiBlueString("%s", cond(group.Private, "yes", "no"))+
|
||||
", "+
|
||||
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
|
||||
)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "members",
|
||||
Usage: "list and manage group members",
|
||||
Description: "view group membership information.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group := nip29.Group{
|
||||
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||
}
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupMembers},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInMembersEvent(&ie.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
lines := make(chan string)
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for member, roles := range group.Members {
|
||||
wg.Go(func() {
|
||||
line := member.Hex()
|
||||
|
||||
meta := sys.FetchProfileMetadata(ctx, member)
|
||||
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||
|
||||
for _, role := range roles {
|
||||
line += ", " + role.Name
|
||||
}
|
||||
|
||||
lines <- line
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(lines)
|
||||
}()
|
||||
|
||||
for line := range lines {
|
||||
stdout(line)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "admins",
|
||||
Usage: "manage group administrators",
|
||||
Description: "view and manage group admin permissions.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group := nip29.Group{
|
||||
Members: make(map[nostr.PubKey][]*nip29.Role),
|
||||
}
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupAdmins},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInAdminsEvent(&ie.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
lines := make(chan string)
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
for member, roles := range group.Members {
|
||||
wg.Go(func() {
|
||||
line := member.Hex()
|
||||
|
||||
meta := sys.FetchProfileMetadata(ctx, member)
|
||||
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
|
||||
|
||||
for _, role := range roles {
|
||||
line += ", " + role.Name
|
||||
}
|
||||
|
||||
lines <- line
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(lines)
|
||||
}()
|
||||
|
||||
for line := range lines {
|
||||
stdout(line)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "roles",
|
||||
Usage: "manage group roles and permissions",
|
||||
Description: "configure custom roles and permissions within the group.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
group := nip29.Group{
|
||||
Roles: make([]*nip29.Role, 0),
|
||||
}
|
||||
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{nostr.KindSimpleGroupRoles},
|
||||
Tags: nostr.TagMap{"d": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
if err := group.MergeInRolesEvent(&ie.Event); err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
for _, role := range group.Roles {
|
||||
stdout(color.HiBlueString(role.Name) + " " + role.Description)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "chat",
|
||||
Usage: "send and read group chat messages",
|
||||
Description: "interact with group chat functionality.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
r, err := sys.Pool.EnsureRelay(relay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sub, err := r.Subscribe(ctx, nostr.Filter{
|
||||
Kinds: []nostr.Kind{9},
|
||||
Tags: nostr.TagMap{"h": []string{identifier}},
|
||||
Limit: 200,
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sub.Close()
|
||||
|
||||
eosed := false
|
||||
messages := make([]struct {
|
||||
message string
|
||||
rendered bool
|
||||
}, 200)
|
||||
base := len(messages)
|
||||
|
||||
tryRender := func(i int) {
|
||||
// if all messages before these are loaded we can render this,
|
||||
// otherwise we render whatever we can and stop
|
||||
for m, msg := range messages[base:] {
|
||||
if msg.rendered {
|
||||
continue
|
||||
}
|
||||
if msg.message == "" {
|
||||
break
|
||||
}
|
||||
messages[base+m].rendered = true
|
||||
stdout(msg.message)
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case evt := <-sub.Events:
|
||||
var i int
|
||||
if eosed {
|
||||
i = len(messages)
|
||||
messages = append(messages, struct {
|
||||
message string
|
||||
rendered bool
|
||||
}{})
|
||||
} else {
|
||||
base--
|
||||
i = base
|
||||
}
|
||||
|
||||
go func() {
|
||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
|
||||
|
||||
if eosed {
|
||||
tryRender(i)
|
||||
}
|
||||
}()
|
||||
case reason := <-sub.ClosedReason:
|
||||
stdout("closed:" + color.YellowString(reason))
|
||||
case <-sub.EndOfStoredEvents:
|
||||
eosed = true
|
||||
tryRender(len(messages) - 1)
|
||||
case <-sub.Context.Done():
|
||||
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
|
||||
}
|
||||
}
|
||||
},
|
||||
Commands: []*cli.Command{
|
||||
{
|
||||
Name: "send",
|
||||
Usage: "sends a message to the chat",
|
||||
ArgsUsage: "<relay>'<identifier> <message>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
msg := nostr.Event{
|
||||
Kind: 9,
|
||||
CreatedAt: nostr.Now(),
|
||||
Content: strings.Join(c.Args().Tail(), " "),
|
||||
Tags: nostr.Tags{
|
||||
{"h", identifier},
|
||||
},
|
||||
}
|
||||
if err := kr.SignEvent(ctx, &msg); err != nil {
|
||||
return fmt.Errorf("failed to sign message: %w", err)
|
||||
}
|
||||
|
||||
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
|
||||
return err
|
||||
} else {
|
||||
return r.Publish(ctx, msg)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "forum",
|
||||
Usage: "read group forum posts",
|
||||
Description: "access group forum functionality.",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
|
||||
Kinds: []nostr.Kind{11},
|
||||
Tags: nostr.TagMap{"#h": []string{identifier}},
|
||||
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
|
||||
title := evt.Tags.Find("title")
|
||||
if title != nil {
|
||||
stdout(colors.bold(title[1]))
|
||||
} else {
|
||||
stdout(colors.bold("<untitled>"))
|
||||
}
|
||||
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
|
||||
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
|
||||
stdout(evt.Content)
|
||||
}
|
||||
// TODO: see what to do about this
|
||||
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "put-user",
|
||||
Usage: "add a user to the group with optional roles",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "role",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
tag := nostr.Tag{"p", pubkey.Hex()}
|
||||
tag = append(tag, c.StringSlice("role")...)
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove-user",
|
||||
Usage: "remove a user from the group",
|
||||
ArgsUsage: "<relay>'<identifier> <pubkey>",
|
||||
Flags: []cli.Flag{
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
|
||||
pubkey := getPubKey(c, "pubkey")
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "edit-metadata",
|
||||
Usage: "edits the group metadata",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "about",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "picture",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "restricted",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "unrestricted",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "closed",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "open",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "hidden",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "visible",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "private",
|
||||
},
|
||||
&cli.BoolFlag{
|
||||
Name: "public",
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
|
||||
if name := c.String("name"); name != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
|
||||
}
|
||||
if picture := c.String("picture"); picture != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
|
||||
}
|
||||
if about := c.String("about"); about != "" {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
|
||||
}
|
||||
if c.Bool("restricted") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
|
||||
} else if c.Bool("unrestricted") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
|
||||
}
|
||||
if c.Bool("closed") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
|
||||
} else if c.Bool("open") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
|
||||
}
|
||||
if c.Bool("hidden") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
|
||||
} else if c.Bool("visible") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
|
||||
}
|
||||
if c.Bool("private") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
|
||||
} else if c.Bool("public") {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-event",
|
||||
Usage: "delete an event from the group",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Flags: []cli.Flag{
|
||||
&IDFlag{
|
||||
Name: "event",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
|
||||
id := getID(c, "event")
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete-group",
|
||||
Usage: "deletes the group",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create-invite",
|
||||
Usage: "creates an invite code",
|
||||
ArgsUsage: "<relay>'<identifier>",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "code",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
|
||||
return nil
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
|
||||
args := c.Args().Slice()
|
||||
if len(args) < 1 {
|
||||
return fmt.Errorf("requires group identifier")
|
||||
}
|
||||
|
||||
relay, identifier, err := parseGroupIdentifier(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: kind,
|
||||
CreatedAt: nostr.Now(),
|
||||
Content: "",
|
||||
Tags: nostr.Tags{
|
||||
{"h", identifier},
|
||||
},
|
||||
}
|
||||
|
||||
if err := setupFunc(&evt, args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||
return fmt.Errorf("failed to sign event: %w", err)
|
||||
}
|
||||
|
||||
stdout(evt.String())
|
||||
|
||||
r, err := sys.Pool.EnsureRelay(relay)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.Publish(ctx, evt)
|
||||
}
|
||||
|
||||
func cond(b bool, ifYes string, ifNo string) string {
|
||||
if b {
|
||||
return ifYes
|
||||
}
|
||||
return ifNo
|
||||
}
|
||||
|
||||
func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err error) {
|
||||
groupArg := c.Args().First()
|
||||
if !strings.Contains(groupArg, "'") {
|
||||
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||
}
|
||||
|
||||
parts := strings.SplitN(groupArg, "'", 2)
|
||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
|
||||
}
|
||||
34
helpers.go
34
helpers.go
@@ -46,8 +46,14 @@ var (
|
||||
)
|
||||
|
||||
func isPiped() bool {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
return stat.Mode()&os.ModeCharDevice == 0
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mode := stat.Mode()
|
||||
is := mode&os.ModeCharDevice == 0
|
||||
return is
|
||||
}
|
||||
|
||||
func getJsonsOrBlank() iter.Seq[string] {
|
||||
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
return true
|
||||
})
|
||||
|
||||
if !hasStdin && !isPiped() {
|
||||
if !hasStdin {
|
||||
yield("{}")
|
||||
}
|
||||
|
||||
@@ -530,21 +536,25 @@ func decodeTagValue(value string) string {
|
||||
}
|
||||
|
||||
var colors = struct {
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
italicf func(string, ...any) string
|
||||
bold func(...any) string
|
||||
boldf func(string, ...any) string
|
||||
error func(...any) string
|
||||
errorf func(string, ...any) string
|
||||
success func(...any) string
|
||||
successf func(string, ...any) string
|
||||
reset func(...any) (int, error)
|
||||
italic func(...any) string
|
||||
italicf func(string, ...any) string
|
||||
bold func(...any) string
|
||||
boldf func(string, ...any) string
|
||||
underline func(...any) string
|
||||
underlinef func(string, ...any) string
|
||||
error func(...any) string
|
||||
errorf func(string, ...any) string
|
||||
success func(...any) string
|
||||
successf func(string, ...any) string
|
||||
}{
|
||||
color.New(color.Reset).Print,
|
||||
color.New(color.Italic).Sprint,
|
||||
color.New(color.Italic).Sprintf,
|
||||
color.New(color.Bold).Sprint,
|
||||
color.New(color.Bold).Sprintf,
|
||||
color.New(color.Underline).Sprint,
|
||||
color.New(color.Underline).Sprintf,
|
||||
color.New(color.Bold, color.FgHiRed).Sprint,
|
||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||
|
||||
13
key.go
13
key.go
@@ -279,12 +279,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
|
||||
continue
|
||||
}
|
||||
sk = data.(nostr.SecretKey)
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
} else {
|
||||
var err error
|
||||
sk, err = nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ch <- sk
|
||||
|
||||
1
main.go
1
main.go
@@ -50,6 +50,7 @@ var app = &cli.Command{
|
||||
fsCmd,
|
||||
publish,
|
||||
git,
|
||||
group,
|
||||
nip,
|
||||
syncCmd,
|
||||
spell,
|
||||
|
||||
1177
nostrfs_cgo/root.go
Normal file
1177
nostrfs_cgo/root.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -153,7 +153,7 @@ example:
|
||||
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
||||
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||
},
|
||||
},
|
||||
|
||||
2
req.go
2
req.go
@@ -138,7 +138,7 @@ example:
|
||||
relayUrls,
|
||||
forcePreAuthSigner,
|
||||
nostr.PoolOptions{
|
||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||
return authSigner(ctx, c, func(s string, args ...any) {
|
||||
if strings.HasPrefix(s, "authenticating as") {
|
||||
cleanUrl, _ := strings.CutPrefix(
|
||||
|
||||
Reference in New Issue
Block a user