diff --git a/fs.go b/fs.go index ba80089..c9890da 100644 --- a/fs.go +++ b/fs.go @@ -1,4 +1,4 @@ -//go:build !windows && !openbsd +//go:build !windows && !openbsd && !cgofuse package main @@ -13,8 +13,10 @@ import ( "fiatjaf.com/nostr" "fiatjaf.com/nostr/keyer" "github.com/fatih/color" + "github.com/fiatjaf/nak/nostrfs" + "github.com/hanwen/go-fuse/v2/fs" + "github.com/hanwen/go-fuse/v2/fuse" "github.com/urfave/cli/v3" - "github.com/winfsp/cgofuse/fuse" ) var fsCmd = &cli.Command{ @@ -62,7 +64,7 @@ var fsCmd = &cli.Command{ apat = time.Hour * 24 * 365 * 3 } - root := NewFSRoot( + root := nostrfs.NewNostrRoot( context.WithValue( context.WithValue( ctx, @@ -73,7 +75,7 @@ var fsCmd = &cli.Command{ sys, kr, mountpoint, - FSOptions{ + nostrfs.Options{ AutoPublishNotesTimeout: apnt, AutoPublishArticlesTimeout: apat, }, @@ -81,22 +83,21 @@ var fsCmd = &cli.Command{ // 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...) + timeout := time.Second * 120 + server, err := fs.Mount(mountpoint, root, &fs.Options{ + MountOptions: fuse.MountOptions{ + Debug: isVerbose, + Name: "nak", + FsName: "nak", + RememberInodes: true, + }, + AttrTimeout: &timeout, + EntryTimeout: &timeout, + Logger: nostr.DebugLogger, + }) + if err != nil { + return fmt.Errorf("mount failed: %w", err) } - - go func() { - host.Mount("", mountArgs) - }() - log("ok.\n") // setup signal handling for clean unmount @@ -106,12 +107,17 @@ var fsCmd = &cli.Command{ go func() { <-ch log("- unmounting... ") - // cgofuse doesn't have explicit unmount, it unmounts on process exit - log("ok\n") - chErr <- nil + err := server.Unmount() + if err != nil { + chErr <- fmt.Errorf("unmount failed: %w", err) + } else { + log("ok\n") + chErr <- nil + } }() - // wait for signals + // serve the filesystem until unmounted + server.Wait() return <-chErr }, } diff --git a/fs_cgo.go b/fs_cgo.go new file mode 100644 index 0000000..ff14b8f --- /dev/null +++ b/fs_cgo.go @@ -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: "", + 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 + }, +} diff --git a/fs_windows.go b/fs_windows.go index d1dc1a3..0fd91c1 100644 --- a/fs_windows.go +++ b/fs_windows.go @@ -12,6 +12,7 @@ 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" ) @@ -61,7 +62,7 @@ var fsCmd = &cli.Command{ apat = time.Hour * 24 * 365 * 3 } - root := NewFSRoot( + root := nostrfs.NewNostrRoot( context.WithValue( context.WithValue( ctx, @@ -72,7 +73,7 @@ var fsCmd = &cli.Command{ sys, kr, mountpoint, - FSOptions{ + nostrfs.Options{ AutoPublishNotesTimeout: apnt, AutoPublishArticlesTimeout: apat, }, diff --git a/go.mod b/go.mod index 287831b..a85d675 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 github.com/fatih/color v1.16.0 github.com/json-iterator/go v1.1.12 - github.com/liamg/magic v0.0.1 // indirect + github.com/liamg/magic v0.0.1 github.com/mailru/easyjson v0.9.1 github.com/mark3labs/mcp-go v0.8.3 github.com/markusmobius/go-dateparser v1.2.3 @@ -28,6 +28,11 @@ 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 ( github.com/FastFilter/xorfilter v0.2.1 // indirect github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect diff --git a/go.sum b/go.sum index 0c00d4a..0f00285 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +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-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= @@ -142,6 +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/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= @@ -165,6 +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/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= @@ -194,6 +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/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= diff --git a/nostrfs/asyncfile.go b/nostrfs/asyncfile.go new file mode 100644 index 0000000..14d5b16 --- /dev/null +++ b/nostrfs/asyncfile.go @@ -0,0 +1,56 @@ +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 +} diff --git a/nostrfs/deterministicfile.go b/nostrfs/deterministicfile.go new file mode 100644 index 0000000..95fe030 --- /dev/null +++ b/nostrfs/deterministicfile.go @@ -0,0 +1,50 @@ +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 +} diff --git a/nostrfs/entitydir.go b/nostrfs/entitydir.go new file mode 100644 index 0000000..2b8a9c8 --- /dev/null +++ b/nostrfs/entitydir.go @@ -0,0 +1,408 @@ +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}, + ) +} diff --git a/nostrfs/eventdir.go b/nostrfs/eventdir.go new file mode 100644 index 0000000..9cf875b --- /dev/null +++ b/nostrfs/eventdir.go @@ -0,0 +1,241 @@ +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(``), + }, + 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(``), + }, + 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 +} diff --git a/nostrfs/helpers.go b/nostrfs/helpers.go new file mode 100644 index 0000000..79562b2 --- /dev/null +++ b/nostrfs/helpers.go @@ -0,0 +1,16 @@ +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" + } +} diff --git a/nostrfs/npubdir.go b/nostrfs/npubdir.go new file mode 100644 index 0000000..afce05c --- /dev/null +++ b/nostrfs/npubdir.go @@ -0,0 +1,261 @@ +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, + ) + } +} diff --git a/nostrfs/root.go b/nostrfs/root.go new file mode 100644 index 0000000..6c9fc2f --- /dev/null +++ b/nostrfs/root.go @@ -0,0 +1,130 @@ +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 + } +} diff --git a/nostrfs/viewdir.go b/nostrfs/viewdir.go new file mode 100644 index 0000000..de3afba --- /dev/null +++ b/nostrfs/viewdir.go @@ -0,0 +1,267 @@ +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 +} diff --git a/nostrfs/writeablefile.go b/nostrfs/writeablefile.go new file mode 100644 index 0000000..b6ca0a9 --- /dev/null +++ b/nostrfs/writeablefile.go @@ -0,0 +1,93 @@ +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 +} diff --git a/fs_root.go b/nostrfs_cgo/root.go similarity index 92% rename from fs_root.go rename to nostrfs_cgo/root.go index 6149cb9..fed3bae 100644 --- a/fs_root.go +++ b/nostrfs_cgo/root.go @@ -1,6 +1,4 @@ -//go:build !openbsd - -package main +package nostrfs import ( "context" @@ -19,18 +17,18 @@ import ( "github.com/winfsp/cgofuse/fuse" ) -type FSOptions struct { +type Options struct { AutoPublishNotesTimeout time.Duration AutoPublishArticlesTimeout time.Duration } -type FSRoot struct { +type NostrRoot struct { fuse.FileSystemBase ctx context.Context sys *sdk.System rootPubKey nostr.PubKey signer nostr.Signer - opts FSOptions + opts Options mountpoint string mu sync.RWMutex @@ -53,9 +51,9 @@ type FSNode struct { loaded bool } -var _ fuse.FileSystemInterface = (*FSRoot)(nil) +var _ fuse.FileSystemInterface = (*NostrRoot)(nil) -func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot { +func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot { var system *sdk.System if sys != nil { system = sys.(*sdk.System) @@ -73,7 +71,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin abs, _ := filepath.Abs(mountpoint) - root := &FSRoot{ + root := &NostrRoot{ ctx: ctx, sys: system, rootPubKey: pubkey, @@ -103,7 +101,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin return root } -func (r *FSRoot) initialize() { +func (r *NostrRoot) initialize() { if r.rootPubKey == nostr.ZeroPK { return } @@ -148,7 +146,7 @@ func (r *FSRoot) initialize() { r.nodes["/"].children["@me"] = meNode } -func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) { +func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) { pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) if pm.Event == nil { return @@ -177,7 +175,7 @@ func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) { } } -func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) { +func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) { pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) if pm.Event == nil || pm.Picture == "" { return @@ -258,7 +256,7 @@ func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) { } } -func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) { +func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) { ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) defer cancel() @@ -357,7 +355,7 @@ func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) { } } -func (r *FSRoot) eventToFilename(evt *nostr.Event) string { +func (r *NostrRoot) eventToFilename(evt *nostr.Event) string { // use event ID first 8 chars + extension based on kind ext := kindToExtension(evt.Kind) @@ -393,14 +391,14 @@ func (r *FSRoot) eventToFilename(evt *nostr.Event) string { return fmt.Sprintf("%s.%s", idHex, ext) } -func (r *FSRoot) getLog() func(string, ...interface{}) { +func (r *NostrRoot) getLog() func(string, ...interface{}) { if log := r.ctx.Value("log"); log != nil { return log.(func(string, ...interface{})) } return func(string, ...interface{}) {} } -func (r *FSRoot) getNode(path string) *FSNode { +func (r *NostrRoot) getNode(path string) *FSNode { originalPath := path // normalize path @@ -453,7 +451,7 @@ func (r *FSRoot) getNode(path string) *FSNode { return node } -func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int { +func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int { node := r.getNode(path) // if node doesn't exist, try dynamic lookup @@ -482,7 +480,7 @@ func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int { } // dynamicLookup tries to create nodes on-demand for npub/note/nevent paths -func (r *FSRoot) dynamicLookup(path string) bool { +func (r *NostrRoot) dynamicLookup(path string) bool { // normalize path path = strings.ReplaceAll(path, "\\", "/") if !strings.HasPrefix(path, "/") { @@ -537,7 +535,7 @@ func (r *FSRoot) dynamicLookup(path string) bool { } } -func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) { +func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) { dirPath := "/" + npub // check if already exists @@ -630,7 +628,7 @@ func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer no go r.fetchProfilePicture(dirPath, pubkey) } -func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) { +func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) { dirPath := parentPath + "/" + name // check if already exists @@ -658,7 +656,7 @@ func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filte go r.fetchEvents(dirPath, filter) } -func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool { +func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool { dirPath := "/" + name // fetch the event @@ -739,7 +737,7 @@ func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) b return true } -func (r *FSRoot) Readdir(path string, +func (r *NostrRoot) Readdir(path string, fill func(name string, stat *fuse.Stat_t, ofst int64) bool, ofst int64, fh uint64, @@ -770,7 +768,7 @@ func (r *FSRoot) Readdir(path string, return 0 } -func (r *FSRoot) Open(path string, flags int) (int, uint64) { +func (r *NostrRoot) 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{})) @@ -801,7 +799,7 @@ func (r *FSRoot) Open(path string, flags int) (int, uint64) { return 0, node.ino } -func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int { +func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int { node := r.getNode(path) if node == nil || node.isDir { return -fuse.ENOENT @@ -820,7 +818,7 @@ func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int { return n } -func (r *FSRoot) Opendir(path string) (int, uint64) { +func (r *NostrRoot) Opendir(path string) (int, uint64) { node := r.getNode(path) if node == nil { return -fuse.ENOENT, ^uint64(0) @@ -831,16 +829,16 @@ func (r *FSRoot) Opendir(path string) (int, uint64) { return 0, node.ino } -func (r *FSRoot) Release(path string, fh uint64) int { +func (r *NostrRoot) Release(path string, fh uint64) int { return 0 } -func (r *FSRoot) Releasedir(path string, fh uint64) int { +func (r *NostrRoot) Releasedir(path string, fh uint64) int { return 0 } // Create creates a new file -func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) { +func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) { // parse path path = strings.ReplaceAll(path, "\\", "/") if !strings.HasPrefix(path, "/") { @@ -884,7 +882,7 @@ func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) { } // Truncate truncates a file -func (r *FSRoot) Truncate(path string, size int64, fh uint64) int { +func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int { node := r.getNode(path) if node == nil { return -fuse.ENOENT @@ -913,7 +911,7 @@ func (r *FSRoot) Truncate(path string, size int64, fh uint64) int { } // Write writes data to a file -func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int { +func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int { node := r.getNode(path) if node == nil { return -fuse.ENOENT @@ -957,7 +955,7 @@ func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int { return n } -func (r *FSRoot) publishNote(path string) { +func (r *NostrRoot) publishNote(path string) { r.mu.Lock() node, ok := r.nodes[path] if !ok { @@ -1034,7 +1032,7 @@ func (r *FSRoot) publishNote(path string) { } // Unlink deletes a file -func (r *FSRoot) Unlink(path string) int { +func (r *NostrRoot) Unlink(path string) int { path = strings.ReplaceAll(path, "\\", "/") if !strings.HasPrefix(path, "/") { path = "/" + path @@ -1067,7 +1065,7 @@ func (r *FSRoot) Unlink(path string) int { } // Mkdir creates a new directory -func (r *FSRoot) Mkdir(path string, mode uint32) int { +func (r *NostrRoot) Mkdir(path string, mode uint32) int { path = strings.ReplaceAll(path, "\\", "/") if !strings.HasPrefix(path, "/") { path = "/" + path @@ -1109,7 +1107,7 @@ func (r *FSRoot) Mkdir(path string, mode uint32) int { } // Rmdir removes a directory -func (r *FSRoot) Rmdir(path string) int { +func (r *NostrRoot) Rmdir(path string) int { path = strings.ReplaceAll(path, "\\", "/") if !strings.HasPrefix(path, "/") { path = "/" + path @@ -1151,7 +1149,7 @@ func (r *FSRoot) Rmdir(path string) int { } // Utimens updates file timestamps -func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int { +func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int { node := r.getNode(path) if node == nil { return -fuse.ENOENT