Compare commits

..

2 Commits

Author SHA1 Message Date
mattn
0e6a6d7506 Merge pull request #97 from mattn/both-gofuse-cgofuse
Windows use cgofuse but others use go-fuse
2026-01-19 00:48:45 +09:00
Yasuhiro Matsumoto
cff60b2f9f Windows use cgofuse but others use go-fuse 2026-01-18 23:19:06 +09:00
25 changed files with 257 additions and 2710 deletions

View File

@@ -427,7 +427,6 @@ gitnostr.com... ok.
```shell
~> nak git clone
~> nak git init
~> nak git status
~> nak git sync
~> nak git fetch
~> nak git pull

View File

@@ -3,10 +3,15 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"fiatjaf.com/nostr/nipb0/blossom"
"github.com/urfave/cli/v3"
@@ -229,56 +234,47 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
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`,
Name: "mirror",
Usage: "mirrors blobs from source server to target server",
Description: `lists all blobs from the source server and mirrors them to the target server using BUD-04. requires --sec to sign the authorization event.`,
DisableSliceFlagSeparator: true,
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
targetClient, 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
}
// Create client for source server
sourceServer := c.Args().First()
keyer, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
sourceClient := blossom.NewClient(sourceServer, keyer)
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)
// Get list of blobs from source server
bds, err := sourceClient.List(ctx)
if err != nil {
return fmt.Errorf("failed to list blobs from source server: %w", err)
}
// Mirror each blob to target server
hasError := false
for _, bd := range bds {
mirrored, err := mirrorBlob(ctx, targetClient, bd.URL)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to mirror %s: %s\n", bd.SHA256, err)
hasError = true
continue
}
j, _ := json.Marshal(mirrored)
stdout(string(j))
}
if hasError {
os.Exit(3)
}
return nil
},
},
@@ -292,3 +288,82 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
}
return blossom.NewClient(c.String("server"), keyer), nil
}
// mirrorBlob mirrors a blob from a URL to the mediaserver using BUD-04
func mirrorBlob(ctx context.Context, client *blossom.Client, url string) (*blossom.BlobDescriptor, error) {
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to download blob from URL: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download blob: HTTP %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read blob content: %w", err)
}
hash := sha256.Sum256(data)
hashHex := hex.EncodeToString(hash[:])
signer := client.GetSigner()
pubkey, _ := signer.GetPublicKey(ctx)
evt := nostr.Event{
Kind: 24242,
CreatedAt: nostr.Now(),
Tags: nostr.Tags{
{"t", "upload"},
{"x", hashHex},
{"expiration", fmt.Sprintf("%d", nostr.Now()+60)},
},
Content: "blossom stuff",
PubKey: pubkey,
}
if err := signer.SignEvent(ctx, &evt); err != nil {
return nil, fmt.Errorf("failed to sign authorization event: %w", err)
}
evtj, err := json.Marshal(evt)
if err != nil {
return nil, fmt.Errorf("failed to marshal authorization event: %w", err)
}
auth := base64.StdEncoding.EncodeToString(evtj)
mediaserver := client.GetMediaServer()
mirrorURL := mediaserver + "mirror"
requestBody := map[string]string{"url": url}
requestJSON, _ := json.Marshal(requestBody)
req, err := http.NewRequestWithContext(ctx, "PUT", mirrorURL, bytes.NewReader(requestJSON))
if err != nil {
return nil, fmt.Errorf("failed to create mirror request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Nostr "+auth)
httpClient := &http.Client{}
mirrorResp, err := httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send mirror request: %w", err)
}
defer mirrorResp.Body.Close()
if mirrorResp.StatusCode < 200 || mirrorResp.StatusCode >= 300 {
body, _ := io.ReadAll(mirrorResp.Body)
return nil, fmt.Errorf("mirror request failed with HTTP %d: %s", mirrorResp.StatusCode, string(body))
}
var bd blossom.BlobDescriptor
if err := json.NewDecoder(mirrorResp.Body).Decode(&bd); err != nil {
return nil, fmt.Errorf("failed to decode blob descriptor: %w", err)
}
return &bd, nil
}

293
bunker.go
View File

@@ -5,12 +5,12 @@ import (
"context"
"encoding/hex"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -73,7 +73,13 @@ var bunker = &cli.Command{
},
Action: func(ctx context.Context, c *cli.Command) error {
// read config from file
config := BunkerConfig{}
config := struct {
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
@@ -136,15 +142,6 @@ 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
}
@@ -153,11 +150,7 @@ var bunker = &cli.Command{
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
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})
}
}
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
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
@@ -174,9 +167,7 @@ var bunker = &cli.Command{
} else {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
config.AuthorizedKeys = baseAuthorizedKeys
}
// if we got here without any keys set (no flags, first time using a profile), use the default
@@ -214,17 +205,8 @@ var bunker = &cli.Command{
// try to connect to the relays here
qs := url.Values{}
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{})
relayURLs := make([]string, 0, len(config.Relays))
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -254,22 +236,10 @@ var bunker = &cli.Command{
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := ""
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 + ")"
}
if len(config.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
}
}
@@ -279,8 +249,8 @@ var bunker = &cli.Command{
}
preauthorizedFlags := ""
for _, c := range config.Clients {
preauthorizedFlags += " -k " + c.PubKey.Hex()
for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -344,84 +314,28 @@ 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)
// 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)
}
}
}
}()
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
// 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) {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
return true
}
if secret == newSecret {
// store this key
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -444,35 +358,34 @@ var bunker = &cli.Command{
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", from, err.Error())
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// 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.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)
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
@@ -497,44 +410,24 @@ 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")
}
if err := sendToSocket(c, c.Args().First()); err != nil {
return fmt.Errorf("failed to connect to running bunker: %w", err)
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
}
return nil
// TODO
return fmt.Errorf("this is not implemented yet")
},
},
},
}
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
@@ -602,89 +495,3 @@ 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
}

View File

@@ -145,7 +145,7 @@ example:
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},

2
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows && !openbsd && !cgofuse
//go:build !windows && !openbsd
package main

118
fs_cgo.go
View File

@@ -1,118 +0,0 @@
//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
},
}

View File

@@ -1,4 +1,6 @@
package nostrfs
//go:build !openbsd
package main
import (
"context"
@@ -17,18 +19,18 @@ import (
"github.com/winfsp/cgofuse/fuse"
)
type Options struct {
type FSOptions struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type NostrRoot struct {
type FSRoot struct {
fuse.FileSystemBase
ctx context.Context
sys *sdk.System
rootPubKey nostr.PubKey
signer nostr.Signer
opts Options
opts FSOptions
mountpoint string
mu sync.RWMutex
@@ -51,9 +53,9 @@ type FSNode struct {
loaded bool
}
var _ fuse.FileSystemInterface = (*NostrRoot)(nil)
var _ fuse.FileSystemInterface = (*FSRoot)(nil)
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot {
func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
var system *sdk.System
if sys != nil {
system = sys.(*sdk.System)
@@ -71,7 +73,7 @@ func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountp
abs, _ := filepath.Abs(mountpoint)
root := &NostrRoot{
root := &FSRoot{
ctx: ctx,
sys: system,
rootPubKey: pubkey,
@@ -101,7 +103,7 @@ func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountp
return root
}
func (r *NostrRoot) initialize() {
func (r *FSRoot) initialize() {
if r.rootPubKey == nostr.ZeroPK {
return
}
@@ -146,7 +148,7 @@ func (r *NostrRoot) initialize() {
r.nodes["/"].children["@me"] = meNode
}
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil {
return
@@ -175,7 +177,7 @@ func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil || pm.Picture == "" {
return
@@ -256,7 +258,7 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel()
@@ -355,7 +357,7 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
}
}
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
// use event ID first 8 chars + extension based on kind
ext := kindToExtension(evt.Kind)
@@ -391,14 +393,14 @@ func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
return fmt.Sprintf("%s.%s", idHex, ext)
}
func (r *NostrRoot) getLog() func(string, ...interface{}) {
func (r *FSRoot) getLog() func(string, ...interface{}) {
if log := r.ctx.Value("log"); log != nil {
return log.(func(string, ...interface{}))
}
return func(string, ...interface{}) {}
}
func (r *NostrRoot) getNode(path string) *FSNode {
func (r *FSRoot) getNode(path string) *FSNode {
originalPath := path
// normalize path
@@ -451,7 +453,7 @@ func (r *NostrRoot) getNode(path string) *FSNode {
return node
}
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
node := r.getNode(path)
// if node doesn't exist, try dynamic lookup
@@ -480,7 +482,7 @@ func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
}
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
func (r *NostrRoot) dynamicLookup(path string) bool {
func (r *FSRoot) dynamicLookup(path string) bool {
// normalize path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -535,7 +537,7 @@ func (r *NostrRoot) dynamicLookup(path string) bool {
}
}
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
dirPath := "/" + npub
// check if already exists
@@ -628,7 +630,7 @@ func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer
go r.fetchProfilePicture(dirPath, pubkey)
}
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
dirPath := parentPath + "/" + name
// check if already exists
@@ -656,7 +658,7 @@ func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Fi
go r.fetchEvents(dirPath, filter)
}
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
dirPath := "/" + name
// fetch the event
@@ -737,7 +739,7 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
return true
}
func (r *NostrRoot) Readdir(path string,
func (r *FSRoot) Readdir(path string,
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
ofst int64,
fh uint64,
@@ -768,7 +770,7 @@ func (r *NostrRoot) Readdir(path string,
return 0
}
func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
func (r *FSRoot) Open(path string, flags int) (int, uint64) {
// log the open attempt
if r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
@@ -799,7 +801,7 @@ func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
return 0, node.ino
}
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil || node.isDir {
return -fuse.ENOENT
@@ -818,7 +820,7 @@ func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *NostrRoot) Opendir(path string) (int, uint64) {
func (r *FSRoot) Opendir(path string) (int, uint64) {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT, ^uint64(0)
@@ -829,16 +831,16 @@ func (r *NostrRoot) Opendir(path string) (int, uint64) {
return 0, node.ino
}
func (r *NostrRoot) Release(path string, fh uint64) int {
func (r *FSRoot) Release(path string, fh uint64) int {
return 0
}
func (r *NostrRoot) Releasedir(path string, fh uint64) int {
func (r *FSRoot) Releasedir(path string, fh uint64) int {
return 0
}
// Create creates a new file
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
// parse path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -882,7 +884,7 @@ func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
}
// Truncate truncates a file
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -911,7 +913,7 @@ func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
}
// Write writes data to a file
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -955,7 +957,7 @@ func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *NostrRoot) publishNote(path string) {
func (r *FSRoot) publishNote(path string) {
r.mu.Lock()
node, ok := r.nodes[path]
if !ok {
@@ -1032,7 +1034,7 @@ func (r *NostrRoot) publishNote(path string) {
}
// Unlink deletes a file
func (r *NostrRoot) Unlink(path string) int {
func (r *FSRoot) Unlink(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1065,7 +1067,7 @@ func (r *NostrRoot) Unlink(path string) int {
}
// Mkdir creates a new directory
func (r *NostrRoot) Mkdir(path string, mode uint32) int {
func (r *FSRoot) Mkdir(path string, mode uint32) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1107,7 +1109,7 @@ func (r *NostrRoot) Mkdir(path string, mode uint32) int {
}
// Rmdir removes a directory
func (r *NostrRoot) Rmdir(path string) int {
func (r *FSRoot) Rmdir(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1149,7 +1151,7 @@ func (r *NostrRoot) Rmdir(path string) int {
}
// Utimens updates file timestamps
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT

View File

@@ -12,7 +12,6 @@ import (
"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"
)
@@ -62,7 +61,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
root := NewFSRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -73,7 +72,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
nostrfs.Options{
FSOptions{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},

162
git.go
View File

@@ -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,98 +782,6 @@ 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
},
},
},
}
@@ -961,7 +869,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
}
// fetch repository announcement and state from relays
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
notUpToDate := func(graspServer string) bool {
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
}
@@ -981,40 +889,33 @@ 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)
}
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)
// 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)
}
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)
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))
}
}
} 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))
repo = localRepo
}
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
} else {
if err != nil {
@@ -1050,7 +951,6 @@ 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)
}
@@ -1255,7 +1155,7 @@ func fetchRepositoryAndState(
pubkey nostr.PubKey,
identifier string,
relayHints []string,
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
) (repo nip34.Repository, 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{
@@ -1276,15 +1176,13 @@ 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, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
}
// fetch repository state (30618)
@@ -1314,10 +1212,10 @@ func fetchRepositoryAndState(
}
}
if stateErr != nil {
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
return repo, upToDateRelays, state, stateErr
}
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
return repo, upToDateRelays, state, nil
}
type StateErr struct{ string }

8
go.mod
View File

@@ -3,7 +3,7 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260121154330-061cf7f68fd4
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/bep/debounce v1.2.1
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -11,6 +11,7 @@ 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
@@ -28,10 +29,7 @@ require (
golang.org/x/term v0.32.0
)
require (
fiatjaf.com/lib v0.3.2
github.com/hanwen/go-fuse/v2 v2.9.0
)
require fiatjaf.com/lib v0.3.2
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect

16
go.sum
View File

@@ -1,7 +1,7 @@
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=
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=
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.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
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/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=
@@ -169,8 +169,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
@@ -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.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
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/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=

586
group.go
View File

@@ -1,586 +0,0 @@
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
}

View File

@@ -536,25 +536,21 @@ 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
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
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
}{
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,

View File

@@ -50,7 +50,6 @@ var app = &cli.Command{
fsCmd,
publish,
git,
group,
nip,
syncCmd,
spell,

View File

@@ -1,56 +0,0 @@
package nostrfs
import (
"context"
"sync/atomic"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"fiatjaf.com/nostr"
)
type AsyncFile struct {
fs.Inode
ctx context.Context
fetched atomic.Bool
data []byte
ts nostr.Timestamp
load func() ([]byte, nostr.Timestamp)
}
var (
_ = (fs.NodeOpener)((*AsyncFile)(nil))
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
)
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
out.Size = uint64(len(af.data))
out.Mtime = uint64(af.ts)
return fs.OK
}
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
if af.fetched.CompareAndSwap(false, true) {
af.data, af.ts = af.load()
}
return nil, fuse.FOPEN_KEEP_CACHE, 0
}
func (af *AsyncFile) Read(
ctx context.Context,
f fs.FileHandle,
dest []byte,
off int64,
) (fuse.ReadResult, syscall.Errno) {
end := int(off) + len(dest)
if end > len(af.data) {
end = len(af.data)
}
return fuse.ReadResultData(af.data[off:end]), 0
}

View File

@@ -1,50 +0,0 @@
package nostrfs
import (
"context"
"syscall"
"unsafe"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type DeterministicFile struct {
fs.Inode
get func() (ctime, mtime uint64, data string)
}
var (
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
_ = (fs.NodeReader)((*DeterministicFile)(nil))
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
)
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
return &DeterministicFile{
get: get,
}
}
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
var content string
out.Mode = 0444
out.Ctime, out.Mtime, content = f.get()
out.Size = uint64(len(content))
return fs.OK
}
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
_, _, content := f.get()
data := unsafe.Slice(unsafe.StringData(content), len(content))
end := int(off) + len(dest)
if end > len(data) {
end = len(data)
}
return fuse.ReadResultData(data[off:end]), fs.OK
}

View File

@@ -1,408 +0,0 @@
package nostrfs
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type EntityDir struct {
fs.Inode
root *NostrRoot
publisher *debouncer.Debouncer
event *nostr.Event
updating struct {
title string
content string
publishedAt uint64
}
}
var (
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
_ = (fs.NodeCreater)((*EntityDir)(nil))
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
)
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Ctime = uint64(e.event.CreatedAt)
if e.updating.publishedAt != 0 {
out.Mtime = e.updating.publishedAt
} else {
out.Mtime = e.PublishedAt()
}
return fs.OK
}
func (e *EntityDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if name == "publish" && e.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := e.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
e.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
return nil, nil, 0, syscall.ENOTSUP
}
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
switch name {
case "content" + kindToExtension(e.event.Kind):
e.updating.content = e.event.Content
return syscall.ENOTDIR
case "title":
e.updating.title = e.Title()
return syscall.ENOTDIR
default:
return syscall.EINTR
}
}
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
e.updating.publishedAt = in.Mtime
return fs.OK
}
func (e *EntityDir) OnAdd(_ context.Context) {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
e.AddChild("@author", e.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
e.AddChild("event.json", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
eventj, _ := json.MarshalIndent(e.event, "", " ")
return uint64(e.event.CreatedAt),
uint64(e.event.CreatedAt),
unsafe.String(unsafe.SliceData(eventj), len(eventj))
},
},
fs.StableAttr{},
), true)
e.AddChild("identifier", e.NewPersistentInode(
e.root.ctx,
&fs.MemRegularFile{
Data: []byte(e.event.Tags.GetD()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(e.event.CreatedAt),
Mtime: uint64(e.event.CreatedAt),
Size: uint64(len(e.event.Tags.GetD())),
},
},
fs.StableAttr{},
), true)
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
// read-only
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
},
},
fs.StableAttr{},
), true)
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
},
},
fs.StableAttr{},
), true)
} else {
// writeable
e.updating.title = e.Title()
e.updating.publishedAt = e.PublishedAt()
e.updating.content = e.event.Content
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("title updated")
e.updating.title = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
log("content updated")
e.updating.content = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
}
var refsdir *fs.Inode
i := 0
for ref := range nip27.Parse(e.event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
e.root.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
var imagesdir *fs.Inode
addImage := func(url string) {
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
e.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
e.root.ctx,
&AsyncFile{
ctx: e.root.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
log("failed to load image %s: %s\n", url, err)
return nil, 0
}
w := &bytes.Buffer{}
io.Copy(w, resp.Body)
return w.Bytes(), 0
},
},
fs.StableAttr{},
), true)
}
images := nip92.ParseTags(e.event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
}
addImage(imeta.URL)
}
if tag := e.event.Tags.Find("image"); tag != nil {
addImage(tag[1])
}
}
func (e *EntityDir) IsNew() bool {
return e.event.CreatedAt == 0
}
func (e *EntityDir) PublishedAt() uint64 {
if tag := e.event.Tags.Find("published_at"); tag != nil {
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
return publishedAt
}
return uint64(e.event.CreatedAt)
}
func (e *EntityDir) Title() string {
if tag := e.event.Tags.Find("title"); tag != nil {
return tag[1]
}
return ""
}
func (e *EntityDir) handleWrite() {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
if e.publisher.IsRunning() {
log(", timer reset")
}
log(", publishing the ")
if e.IsNew() {
log("new")
} else {
log("updated")
}
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
} else {
log(".\n")
}
if !e.publisher.IsRunning() {
log("- `touch publish` to publish immediately\n")
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
}
e.publisher.Call(func() {
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
log("not modified, publish canceled.\n")
return
}
evt := nostr.Event{
Kind: e.event.Kind,
Content: e.updating.content,
Tags: make(nostr.Tags, len(e.event.Tags)),
CreatedAt: nostr.Now(),
}
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
if e.updating.title != "" {
if titleTag := evt.Tags.Find("title"); titleTag != nil {
titleTag[1] = e.updating.title
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
}
}
// "published_at" tag
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
if publishedAtStr != "0" {
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
publishedAtTag[1] = publishedAtStr
} else {
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
}
}
// add "p" tags from people mentioned and "q" tags from events mentioned
for ref := range nip27.Parse(evt.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
tag := ref.Pointer.AsTag()
key := tag[0]
val := tag[1]
if key == "e" || key == "a" {
key = "q"
}
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
evt.Tags = append(evt.Tags, tag)
}
}
// sign and publish
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
log("failed to sign: '%s'.\n", err)
return
}
logverbose("%s\n", evt)
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
if len(relays) == 0 {
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
}
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
e.event = &evt
log("event updated locally.\n")
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
} else {
log("failed.\n")
}
})
}
func (r *NostrRoot) FetchAndCreateEntityDir(
parent fs.InodeEmbedder,
extension string,
pointer nostr.EntityPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEntityDir(parent, event), nil
}
func (r *NostrRoot) CreateEntityDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
fs.StableAttr{Mode: syscall.S_IFDIR},
)
}

View File

@@ -1,241 +0,0 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip10"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/nip22"
"fiatjaf.com/nostr/nip27"
"fiatjaf.com/nostr/nip73"
"fiatjaf.com/nostr/nip92"
sdk "fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type EventDir struct {
fs.Inode
ctx context.Context
wd string
evt *nostr.Event
}
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
out.Mtime = uint64(e.evt.CreatedAt)
return fs.OK
}
func (r *NostrRoot) FetchAndCreateEventDir(
parent fs.InodeEmbedder,
pointer nostr.EventPointer,
) (*fs.Inode, error) {
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
WithRelays: false,
})
if err != nil {
return nil, fmt.Errorf("failed to fetch: %w", err)
}
return r.CreateEventDir(parent, event), nil
}
func (r *NostrRoot) CreateEventDir(
parent fs.InodeEmbedder,
event *nostr.Event,
) *fs.Inode {
h := parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
)
h.AddChild("@author", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
eventj, _ := json.MarshalIndent(event, "", " ")
h.AddChild("event.json", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: eventj,
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
h.AddChild("id", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.ID.Hex()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(64),
},
},
fs.StableAttr{},
), true)
h.AddChild("content.txt", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(event.CreatedAt),
Size: uint64(len(event.Content)),
},
},
fs.StableAttr{},
), true)
var refsdir *fs.Inode
i := 0
for ref := range nip27.Parse(event.Content) {
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
continue
}
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
}
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
var imagesdir *fs.Inode
images := nip92.ParseTags(event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
}
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
}
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
r.ctx,
&AsyncFile{
ctx: r.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
if err != nil {
return nil, 0
}
resp, err := http.DefaultClient.Do(r)
if err != nil {
return nil, 0
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, 0
}
w := &bytes.Buffer{}
io.Copy(w, resp.Body)
return w.Bytes(), 0
},
},
fs.StableAttr{},
), true)
}
if event.Kind == 1 {
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
} else if event.Kind == 1111 {
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@root", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
if xp, ok := pointer.(nip73.ExternalPointer); ok {
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
},
fs.StableAttr{},
), true)
} else {
nevent := nip19.EncodePointer(pointer)
h.AddChild("@parent", h.NewPersistentInode(
r.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nevent),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}
}
}
return h
}

View File

@@ -1,16 +0,0 @@
package nostrfs
import (
"fiatjaf.com/nostr"
)
func kindToExtension(kind nostr.Kind) string {
switch kind {
case 30023:
return "md"
case 30818:
return "adoc"
default:
return "txt"
}
}

View File

@@ -1,261 +0,0 @@
package nostrfs
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"sync/atomic"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip19"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/liamg/magic"
)
type NpubDir struct {
fs.Inode
root *NostrRoot
pointer nostr.ProfilePointer
fetched atomic.Bool
}
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
func (r *NostrRoot) CreateNpubDir(
parent fs.InodeEmbedder,
pointer nostr.ProfilePointer,
signer nostr.Signer,
) *fs.Inode {
npubdir := &NpubDir{root: r, pointer: pointer}
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
npubdir,
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
)
}
func (h *NpubDir) OnAdd(_ context.Context) {
log := h.root.ctx.Value("log").(func(msg string, args ...any))
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
log("- adding folder for %s with relays %s\n",
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
h.AddChild("pubkey", h.NewPersistentInode(
h.root.ctx,
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
fs.StableAttr{},
), true)
go func() {
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
if pm.Event == nil {
return
}
metadataj, _ := json.MarshalIndent(pm, "", " ")
h.AddChild(
"metadata.json",
h.NewPersistentInode(
h.root.ctx,
&fs.MemRegularFile{
Data: metadataj,
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
),
true,
)
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
if err == nil {
resp, err := http.DefaultClient.Do(req)
if err == nil {
defer resp.Body.Close()
if resp.StatusCode < 300 {
b := &bytes.Buffer{}
io.Copy(b, resp.Body)
ext := "png"
if ft, err := magic.Lookup(b.Bytes()); err == nil {
ext = ft.Extension
}
h.AddChild("picture."+ext, h.NewPersistentInode(
ctx,
&fs.MemRegularFile{
Data: b.Bytes(),
Attr: fuse.Attr{
Mtime: uint64(pm.Event.CreatedAt),
Mode: 0444,
},
},
fs.StableAttr{},
), true)
}
}
}
}()
if h.GetChild("notes") == nil {
h.AddChild(
"notes",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("comments") == nil {
h.AddChild(
"comments",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{1111},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("photos") == nil {
h.AddChild(
"photos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{20},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: true,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("videos") == nil {
h.AddChild(
"videos",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{21, 22},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("highlights") == nil {
h.AddChild(
"highlights",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{9802},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: false,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("articles") == nil {
h.AddChild(
"articles",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30023},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
if h.GetChild("wiki") == nil {
h.AddChild(
"wiki",
h.NewPersistentInode(
h.root.ctx,
&ViewDir{
root: h.root,
filter: nostr.Filter{
Kinds: []nostr.Kind{30818},
Authors: []nostr.PubKey{h.pointer.PublicKey},
},
paginate: false,
relays: relays,
replaceable: true,
createable: true,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
true,
)
}
}

View File

@@ -1,130 +0,0 @@
package nostrfs
import (
"context"
"path/filepath"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip05"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type Options struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type NostrRoot struct {
fs.Inode
ctx context.Context
wd string
sys *sdk.System
rootPubKey nostr.PubKey
signer nostr.Signer
opts Options
}
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
pubkey, _ := user.GetPublicKey(ctx)
abs, _ := filepath.Abs(mountpoint)
var signer nostr.Signer
if user != nil {
signer, _ = user.(nostr.Signer)
}
return &NostrRoot{
ctx: ctx,
sys: sys,
rootPubKey: pubkey,
signer: signer,
wd: abs,
opts: o,
}
}
func (r *NostrRoot) OnAdd(_ context.Context) {
if r.rootPubKey == nostr.ZeroPK {
return
}
go func() {
time.Sleep(time.Millisecond * 100)
// add our contacts
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
for _, f := range fl.Items {
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
r.AddChild(
nip19.EncodeNpub(f.Pubkey),
r.CreateNpubDir(r, pointer, nil),
true,
)
}
// add ourselves
npub := nip19.EncodeNpub(r.rootPubKey)
if r.GetChild(npub) == nil {
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
r.AddChild(
npub,
r.CreateNpubDir(r, pointer, r.signer),
true,
)
}
// add a link to ourselves
r.AddChild("@me", r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
}()
}
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
out.SetEntryTimeout(time.Minute * 5)
child := r.GetChild(name)
if child != nil {
return child, fs.OK
}
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
return r.NewPersistentInode(
r.ctx,
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
fs.StableAttr{Mode: syscall.S_IFLNK},
), fs.OK
}
pointer, err := nip19.ToPointer(name)
if err != nil {
return nil, syscall.ENOENT
}
switch p := pointer.(type) {
case nostr.ProfilePointer:
npubdir := r.CreateNpubDir(r, p, nil)
return npubdir, fs.OK
case nostr.EventPointer:
eventdir, err := r.FetchAndCreateEventDir(r, p)
if err != nil {
return nil, syscall.ENOENT
}
return eventdir, fs.OK
default:
return nil, syscall.ENOENT
}
}

View File

@@ -1,267 +0,0 @@
package nostrfs
import (
"context"
"strings"
"sync/atomic"
"syscall"
"fiatjaf.com/lib/debouncer"
"fiatjaf.com/nostr"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type ViewDir struct {
fs.Inode
root *NostrRoot
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
replaceable bool
createable bool
publisher *debouncer.Debouncer
publishing struct {
note string
}
}
var (
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
_ = (fs.NodeCreater)((*ViewDir)(nil))
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
)
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
return fs.OK
}
func (n *ViewDir) Create(
_ context.Context,
name string,
flags uint32,
mode uint32,
out *fuse.EntryOut,
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return nil, nil, 0, syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return nil, nil, 0, syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
if n.publisher.IsRunning() {
log("pending note updated, timer reset.")
} else {
log("new note detected")
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
} else {
log(".\n")
}
log("- `touch publish` to publish immediately\n")
log("- `rm new` to erase and cancel the publication.\n")
}
n.publisher.Call(n.publishNote)
first := true
return n.NewPersistentInode(
n.root.ctx,
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
if !first {
log("pending note updated, timer reset.\n")
}
first = false
n.publishing.note = strings.TrimSpace(s)
n.publisher.Call(n.publishNote)
}),
fs.StableAttr{},
), nil, 0, fs.OK
case "publish":
if n.publisher.IsRunning() {
// this causes the publish process to be triggered faster
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing now!\n")
n.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
}
return nil, nil, 0, syscall.ENOTSUP
}
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
return syscall.EPERM
}
if n.publisher == nil {
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
}
if n.filter.Kinds[0] != 1 {
return syscall.ENOTSUP
}
switch name {
case "new":
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing canceled.\n")
n.publisher.Stop()
n.publishing.note = ""
return fs.OK
}
return syscall.ENOTSUP
}
func (n *ViewDir) publishNote() {
log := n.root.ctx.Value("log").(func(msg string, args ...any))
log("publishing note...\n")
evt := nostr.Event{
Kind: 1,
CreatedAt: nostr.Now(),
Content: n.publishing.note,
Tags: make(nostr.Tags, 0, 2),
}
// our write relays
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
if len(relays) == 0 {
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
}
// massage and extract tags from raw text
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
relays = nostr.AppendUnique(relays, targetRelays...)
// sign and publish
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
log("failed to sign: %s\n", err)
return
}
log(evt.String() + "\n")
log("publishing to %d relays... ", len(relays))
success := false
first := true
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
if !first {
log(", ")
}
first = false
if res.Error != nil {
log("%s: %s", color.RedString(cleanUrl), res.Error)
} else {
success = true
log("%s: ok", color.GreenString(cleanUrl))
}
}
log("\n")
if success {
n.RmChild("new")
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
}
}
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
now := nostr.Now()
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
out.Mtime = uint64(aMonthAgo)
return fs.OK
}
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
if n.fetched.CompareAndSwap(true, true) {
return fs.OK
}
if n.paginate {
now := nostr.Now()
if n.filter.Until != 0 {
now = n.filter.Until
}
aMonthAgo := now - 30*24*60*60
n.filter.Since = aMonthAgo
filter := n.filter
filter.Until = aMonthAgo
n.AddChild("@previous", n.NewPersistentInode(
n.root.ctx,
&ViewDir{
root: n.root,
filter: filter,
relays: n.relays,
replaceable: n.replaceable,
},
fs.StableAttr{Mode: syscall.S_IFDIR},
), true)
}
if n.replaceable {
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
Label: "nakfs",
}).Range {
name := rkey.D
if name == "" {
name = "_"
}
if n.GetChild(name) == nil {
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
}
}
} else {
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
nostr.SubscriptionOptions{
Label: "nakfs",
}) {
if n.GetChild(ie.Event.ID.Hex()) == nil {
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
}
}
}
return fs.OK
}
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
return nil, syscall.ENOTSUP
}
if n.replaceable {
// create a template event that can later be modified and published as new
return n.root.CreateEntityDir(n, &nostr.Event{
PubKey: n.root.rootPubKey,
CreatedAt: 0,
Kind: n.filter.Kinds[0],
Tags: nostr.Tags{
nostr.Tag{"d", name},
},
}), fs.OK
}
return nil, syscall.ENOTSUP
}

View File

@@ -1,93 +0,0 @@
package nostrfs
import (
"context"
"sync"
"syscall"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
)
type WriteableFile struct {
fs.Inode
root *NostrRoot
mu sync.Mutex
data []byte
attr fuse.Attr
onWrite func(string)
}
var (
_ = (fs.NodeOpener)((*WriteableFile)(nil))
_ = (fs.NodeReader)((*WriteableFile)(nil))
_ = (fs.NodeWriter)((*WriteableFile)(nil))
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
)
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
return &WriteableFile{
root: r,
data: []byte(data),
attr: fuse.Attr{
Mode: 0666,
Ctime: ctime,
Mtime: mtime,
Size: uint64(len(data)),
},
onWrite: onWrite,
}
}
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
}
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
offset := int(off)
end := offset + len(data)
if len(f.data) < end {
newData := make([]byte, offset+len(data))
copy(newData, f.data)
f.data = newData
}
copy(f.data[offset:], data)
f.data = f.data[0:end]
f.onWrite(string(f.data))
return uint32(len(data)), fs.OK
}
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.attr
out.Attr.Size = uint64(len(f.data))
return fs.OK
}
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
f.attr.Mtime = in.Mtime
f.attr.Atime = in.Atime
f.attr.Ctime = in.Ctime
return fs.OK
}
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
return fs.OK
}
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
f.mu.Lock()
defer f.mu.Unlock()
end := int(off) + len(dest)
if end > len(f.data) {
end = len(f.data)
}
return fuse.ReadResultData(f.data[off:end]), fs.OK
}

View File

@@ -153,7 +153,7 @@ example:
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},

2
req.go
View File

@@ -138,7 +138,7 @@ example:
relayUrls,
forcePreAuthSigner,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: 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(