diff --git a/fs.go b/fs.go index 029afdf..1f7f30c 100644 --- a/fs.go +++ b/fs.go @@ -14,9 +14,8 @@ import ( "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{ @@ -83,21 +82,22 @@ var fsCmd = &cli.Command{ // create the server log("- mounting at %s... ", color.HiCyanString(mountpoint)) - 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) + + // 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 @@ -107,17 +107,12 @@ var fsCmd = &cli.Command{ go func() { <-ch log("- unmounting... ") - err := server.Unmount() - if err != nil { - chErr <- fmt.Errorf("unmount failed: %w", err) - } else { - log("ok\n") - chErr <- nil - } + // cgofuse doesn't have explicit unmount, it unmounts on process exit + log("ok\n") + chErr <- nil }() - // serve the filesystem until unmounted - server.Wait() + // wait for signals return <-chErr }, } diff --git a/fs_other.go b/fs_other.go index fba75fc..ccc2894 100644 --- a/fs_other.go +++ b/fs_other.go @@ -1,4 +1,4 @@ -//go:build windows || openbsd +//go:build openbsd package main diff --git a/fs_windows.go b/fs_windows.go new file mode 100644 index 0000000..8dfcc15 --- /dev/null +++ b/fs_windows.go @@ -0,0 +1,141 @@ +//go:build windows + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "fiatjaf.com/nostr" + "fiatjaf.com/nostr/keyer" + "github.com/fatih/color" + "github.com/fiatjaf/nak/nostrfs" + "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 - Windows/WinFsp version + // Based on rclone cmount implementation + mountArgs := []string{ + "-o", "uid=-1", + "-o", "gid=-1", + "--FileSystemName=nak", + } + + // Check if mountpoint is a drive letter or directory + isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':' + + if !isDriveLetter { + // WinFsp primarily supports drive letters on Windows + // Directory mounting may not work reliably + log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n") + log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n") + + // For directory mounts, follow rclone's approach: + // 1. Check that mountpoint doesn't already exist + if _, err := os.Stat(mountpoint); err == nil { + return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint) + } else if !os.IsNotExist(err) { + return fmt.Errorf("failed to check mountpoint: %w", err) + } + + // 2. Check that parent directory exists + parent := filepath.Join(mountpoint, "..") + if _, err := os.Stat(parent); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent) + } + return fmt.Errorf("failed to check parent directory: %w", err) + } + + // 3. Use network mode for directory mounts + mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint)) + } + + if isVerbose { + mountArgs = append(mountArgs, "-o", "debug") + } + mountArgs = append(mountArgs, mountpoint) + + log("ok.\n") + + // Mount in main thread like hellofs + if !host.Mount("", mountArgs) { + return fmt.Errorf("failed to mount filesystem") + } + return nil + }, +} diff --git a/go.mod b/go.mod index 4920459..e1eb7b2 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,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 @@ -24,6 +24,7 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v3 v3.0.0-beta1 + github.com/winfsp/cgofuse v1.6.0 golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 golang.org/x/sync v0.18.0 golang.org/x/term v0.32.0 @@ -69,7 +70,6 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/kylelemons/godebug v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/magefile/mage v1.14.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/go.sum b/go.sum index 934b351..a94ebcc 100644 --- a/go.sum +++ b/go.sum @@ -144,8 +144,8 @@ github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnm github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ= github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0= github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc= -github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw= -github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48= +github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58= +github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= @@ -200,8 +200,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78= -github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= +github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= +github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -269,6 +269,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg= github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ= github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo= +github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0= +github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= diff --git a/nostrfs/asyncfile.go b/nostrfs/asyncfile.go deleted file mode 100644 index 14d5b16..0000000 --- a/nostrfs/asyncfile.go +++ /dev/null @@ -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 -} diff --git a/nostrfs/deterministicfile.go b/nostrfs/deterministicfile.go deleted file mode 100644 index 95fe030..0000000 --- a/nostrfs/deterministicfile.go +++ /dev/null @@ -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 -} diff --git a/nostrfs/entitydir.go b/nostrfs/entitydir.go deleted file mode 100644 index 2b8a9c8..0000000 --- a/nostrfs/entitydir.go +++ /dev/null @@ -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}, - ) -} diff --git a/nostrfs/eventdir.go b/nostrfs/eventdir.go deleted file mode 100644 index 9cf875b..0000000 --- a/nostrfs/eventdir.go +++ /dev/null @@ -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(``), - }, - 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/npubdir.go b/nostrfs/npubdir.go deleted file mode 100644 index afce05c..0000000 --- a/nostrfs/npubdir.go +++ /dev/null @@ -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, - ) - } -} diff --git a/nostrfs/root.go b/nostrfs/root.go index 6c9fc2f..c59d401 100644 --- a/nostrfs/root.go +++ b/nostrfs/root.go @@ -2,16 +2,19 @@ package nostrfs import ( "context" + "encoding/json" + "fmt" + "net/http" "path/filepath" - "syscall" + "strings" + "sync" "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" + "github.com/winfsp/cgofuse/fuse" ) type Options struct { @@ -20,111 +23,1144 @@ type Options struct { } type NostrRoot struct { - fs.Inode - + fuse.FileSystemBase ctx context.Context - wd string sys *sdk.System rootPubKey nostr.PubKey signer nostr.Signer + opts Options + mountpoint string - opts Options + mu sync.RWMutex + nodes map[string]*Node // path -> node + nextIno uint64 + pendingNotes map[string]*time.Timer // path -> auto-publish timer } -var _ = (fs.NodeOnAdder)((*NostrRoot)(nil)) +type Node struct { + ino uint64 + path string + name string + isDir bool + size int64 + mode uint32 + mtime time.Time + data []byte + children map[string]*Node + loadFunc func() ([]byte, error) // for lazy loading + loaded bool +} + +var _ fuse.FileSystemInterface = (*NostrRoot)(nil) + +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) + } + + var pubkey nostr.PubKey + var signer nostr.Signer + + if user != nil { + if u, ok := user.(nostr.User); ok { + pubkey, _ = u.GetPublicKey(ctx) + signer, _ = user.(nostr.Signer) + } + } -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) + root := &NostrRoot{ + ctx: ctx, + sys: system, + rootPubKey: pubkey, + signer: signer, + opts: o, + mountpoint: abs, + nodes: make(map[string]*Node), + nextIno: 2, // 1 is reserved for root + pendingNotes: make(map[string]*time.Timer), } - return &NostrRoot{ - ctx: ctx, - sys: sys, - rootPubKey: pubkey, - signer: signer, - wd: abs, - - opts: o, + // Initialize root directory + rootNode := &Node{ + ino: 1, + path: "/", + name: "", + isDir: true, + mode: fuse.S_IFDIR | 0755, + mtime: time.Now(), + children: make(map[string]*Node), } + root.nodes["/"] = rootNode + + // Start async initialization + go root.initialize() + + return root } -func (r *NostrRoot) OnAdd(_ context.Context) { +func (r *NostrRoot) initialize() { if r.rootPubKey == nostr.ZeroPK { return } - go func() { - time.Sleep(time.Millisecond * 100) + log := r.getLog() + 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, - ) + // Fetch follow list + fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey) + log("- fetched %d contacts\n", len(fl.Items)) + + r.mu.Lock() + defer r.mu.Unlock() + + // Add our contacts + for _, f := range fl.Items { + npub := nip19.EncodeNpub(f.Pubkey) + if _, exists := r.nodes["/"+npub]; !exists { + r.createNpubDirLocked(npub, f.Pubkey, nil) } + } - // add ourselves - npub := nip19.EncodeNpub(r.rootPubKey) - if r.GetChild(npub) == nil { - pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey} + // Add ourselves + npub := nip19.EncodeNpub(r.rootPubKey) + if _, exists := r.nodes["/"+npub]; !exists { + r.createNpubDirLocked(npub, r.rootPubKey, r.signer) + } - 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) - }() + // Add @me symlink (for now, just create a text file pointing to our npub) + meNode := &Node{ + ino: r.nextIno, + path: "/@me", + name: "@me", + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Now(), + data: []byte(npub + "\n"), + size: int64(len(npub) + 1), + } + r.nextIno++ + r.nodes["/@me"] = meNode + r.nodes["/"].children["@me"] = meNode } -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 +func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) { + pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) + if pm.Event == nil { + return } - 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 + // Use the content field which contains the actual profile JSON + metadataJ := []byte(pm.Event.Content) + + r.mu.Lock() + defer r.mu.Unlock() + + metadataNode := &Node{ + ino: r.nextIno, + path: dirPath + "/metadata.json", + name: "metadata.json", + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Unix(int64(pm.Event.CreatedAt), 0), + data: metadataJ, + size: int64(len(metadataJ)), + } + r.nextIno++ + r.nodes[dirPath+"/metadata.json"] = metadataNode + if dir, ok := r.nodes[dirPath]; ok { + dir.children["metadata.json"] = metadataNode + } +} + +func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) { + pm := r.sys.FetchProfileMetadata(r.ctx, pubkey) + if pm.Event == nil || pm.Picture == "" { + return } + // Download picture + ctx, cancel := context.WithTimeout(r.ctx, time.Second*20) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil) + if err != nil { + return + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + return + } + + // Read image data + imageData := make([]byte, 0, 1024*1024) // 1MB initial capacity + buf := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + imageData = append(imageData, buf[:n]...) + } + if err != nil { + break + } + if len(imageData) > 10*1024*1024 { // 10MB max + break + } + } + + if len(imageData) == 0 { + return + } + + // Detect file extension from content-type or URL + ext := "png" + if ct := resp.Header.Get("Content-Type"); ct != "" { + switch ct { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + case "image/gif": + ext = "gif" + case "image/webp": + ext = "webp" + } + } + + r.mu.Lock() + defer r.mu.Unlock() + + picturePath := dirPath + "/picture." + ext + pictureNode := &Node{ + ino: r.nextIno, + path: picturePath, + name: "picture." + ext, + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Unix(int64(pm.Event.CreatedAt), 0), + data: imageData, + size: int64(len(imageData)), + } + r.nextIno++ + r.nodes[picturePath] = pictureNode + if dir, ok := r.nodes[dirPath]; ok { + dir.children["picture."+ext] = pictureNode + } +} + +func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) { + ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) + defer cancel() + + // Get relays for authors + var relays []string + if len(filter.Authors) > 0 { + relays = r.sys.FetchOutboxRelays(ctx, filter.Authors[0], 3) + } + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + log := r.getLog() + log("- fetching events for %s from %v\n", dirPath, relays) + + // Fetch events + events := make([]*nostr.Event, 0) + for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{ + Label: "nak-fs", + }) { + // Make a copy to avoid pointer issues with loop variable + evt := ie.Event + events = append(events, &evt) + if len(events) >= int(filter.Limit) { + break + } + } + + log("- fetched %d events for %s\n", len(events), dirPath) + + r.mu.Lock() + defer r.mu.Unlock() + + dir, ok := r.nodes[dirPath] + if !ok { + return + } + + // Track oldest timestamp for pagination + var oldestTimestamp nostr.Timestamp + if len(events) > 0 { + oldestTimestamp = events[len(events)-1].CreatedAt + } + + for _, evt := range events { + // Create filename based on event + filename := r.eventToFilename(evt) + filePath := dirPath + "/" + filename + + if _, exists := r.nodes[filePath]; exists { + continue + } + + content := evt.Content + if len(content) == 0 { + content = "(empty)" + } + + fileNode := &Node{ + ino: r.nextIno, + path: filePath, + name: filename, + isDir: false, + mode: fuse.S_IFREG | 0644, + mtime: time.Unix(int64(evt.CreatedAt), 0), + data: []byte(content), + size: int64(len(content)), + } + r.nextIno++ + r.nodes[filePath] = fileNode + dir.children[filename] = fileNode + } + + // Add "more" file for pagination if we got a full page + if len(events) >= int(filter.Limit) { + moreFile := &Node{ + ino: r.nextIno, + path: dirPath + "/.more", + name: ".more", + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Now(), + data: []byte(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp)), + size: int64(len(fmt.Sprintf("Read this file to load more events (until: %d)\n", oldestTimestamp))), + loadFunc: func() ([]byte, error) { + // When .more is read, fetch next page + newFilter := filter + newFilter.Until = oldestTimestamp + go r.fetchEvents(dirPath, newFilter) + return []byte("Loading more events...\n"), nil + }, + } + r.nextIno++ + r.nodes[dirPath+"/.more"] = moreFile + dir.children[".more"] = moreFile + } +} + +func (r *NostrRoot) eventToFilename(evt *nostr.Event) string { + // Use event ID first 8 chars + extension based on kind + ext := kindToExtension(evt.Kind) + + // Get hex representation of event ID + // evt.ID.String() may return format like ":1234abcd" so use Hex() or remove colons + idHex := evt.ID.Hex() + if len(idHex) > 8 { + idHex = idHex[:8] + } + + // For articles, try to use title + if evt.Kind == 30023 || evt.Kind == 30818 { + for _, tag := range evt.Tags { + if len(tag) >= 2 && tag[0] == "title" { + titleStr := tag[1] + if titleStr != "" { + // Sanitize title for filename + name := strings.Map(func(r rune) rune { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + return '-' + } + return r + }, titleStr) + if len(name) > 50 { + name = name[:50] + } + return fmt.Sprintf("%s-%s.%s", name, idHex, ext) + } + } + } + } + + return fmt.Sprintf("%s.%s", idHex, ext) +} + +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 *NostrRoot) getNode(path string) *Node { + originalPath := path + + // Normalize path + if path == "" { + path = "/" + } + + // Convert Windows backslashes to forward slashes + path = strings.ReplaceAll(path, "\\", "/") + + // Ensure path starts with / + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + // Remove trailing slash except for root + if path != "/" && strings.HasSuffix(path, "/") { + path = strings.TrimSuffix(path, "/") + } + + // Debug logging + if r.ctx.Value("logverbose") != nil { + logv := r.ctx.Value("logverbose").(func(string, ...interface{})) + logv("getNode: original='%s' normalized='%s'\n", originalPath, path) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + node := r.nodes[path] + + // Debug: if not found, show similar paths + if node == nil && r.ctx.Value("logverbose") != nil { + logv := r.ctx.Value("logverbose").(func(string, ...interface{})) + logv("getNode: NOT FOUND '%s'\n", path) + basename := filepath.Base(path) + logv("getNode: searching for similar (basename='%s'):\n", basename) + count := 0 + for p := range r.nodes { + if strings.Contains(p, basename) { + logv(" - '%s'\n", p) + count++ + if count >= 5 { + break + } + } + } + } + + return node +} + +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 + // But skip for special files starting with @ or . + if node == nil { + basename := filepath.Base(path) + if !strings.HasPrefix(basename, "@") && !strings.HasPrefix(basename, ".") { + if r.dynamicLookup(path) { + node = r.getNode(path) + } + } + } + + if node == nil { + return -fuse.ENOENT + } + + stat.Ino = node.ino + stat.Mode = node.mode + stat.Size = node.size + stat.Mtim = fuse.NewTimespec(node.mtime) + stat.Atim = stat.Mtim + stat.Ctim = stat.Mtim + + return 0 +} + +// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths +func (r *NostrRoot) dynamicLookup(path string) bool { + // Normalize path + path = strings.ReplaceAll(path, "\\", "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + // Get the first component after root + parts := strings.Split(strings.TrimPrefix(path, "/"), "/") + if len(parts) == 0 { + return false + } + + name := parts[0] + + // Try to decode as nostr pointer pointer, err := nip19.ToPointer(name) if err != nil { - return nil, syscall.ENOENT + // Try NIP-05 + if strings.Contains(name, "@") && !strings.HasPrefix(name, "@") { + ctx, cancel := context.WithTimeout(r.ctx, time.Second*5) + defer cancel() + if pp, err := nip05.QueryIdentifier(ctx, name); err == nil { + pointer = pp + } else { + return false + } + } else { + return false + } + } + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if already exists + if _, exists := r.nodes["/"+name]; exists { + return true } switch p := pointer.(type) { case nostr.ProfilePointer: - npubdir := r.CreateNpubDir(r, p, nil) - return npubdir, fs.OK + // Create npub directory dynamically + r.createNpubDirLocked(name, p.PublicKey, nil) + return true + case nostr.EventPointer: - eventdir, err := r.FetchAndCreateEventDir(r, p) - if err != nil { - return nil, syscall.ENOENT - } - return eventdir, fs.OK + // Create event directory dynamically + return r.createEventDirLocked(name, p) + default: - return nil, syscall.ENOENT + return false } } + +func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) { + dirPath := "/" + npub + + // Check if already exists + if _, exists := r.nodes[dirPath]; exists { + return + } + + dirNode := &Node{ + ino: r.nextIno, + path: dirPath, + name: npub, + isDir: true, + mode: fuse.S_IFDIR | 0755, + mtime: time.Now(), + children: make(map[string]*Node), + } + r.nextIno++ + r.nodes[dirPath] = dirNode + r.nodes["/"].children[npub] = dirNode + + // Add pubkey file + pubkeyData := []byte(pubkey.Hex() + "\n") + pubkeyNode := &Node{ + ino: r.nextIno, + path: dirPath + "/pubkey", + name: "pubkey", + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Now(), + data: pubkeyData, + size: int64(len(pubkeyData)), + } + r.nextIno++ + r.nodes[dirPath+"/pubkey"] = pubkeyNode + dirNode.children["pubkey"] = pubkeyNode + + // Fetch metadata asynchronously + go r.fetchMetadata(dirPath, pubkey) + + // Add notes directory + r.createViewDirLocked(dirPath, "notes", nostr.Filter{ + Kinds: []nostr.Kind{1}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add articles directory + r.createViewDirLocked(dirPath, "articles", nostr.Filter{ + Kinds: []nostr.Kind{30023}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add comments directory + r.createViewDirLocked(dirPath, "comments", nostr.Filter{ + Kinds: []nostr.Kind{1111}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add highlights directory + r.createViewDirLocked(dirPath, "highlights", nostr.Filter{ + Kinds: []nostr.Kind{9802}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add photos directory + r.createViewDirLocked(dirPath, "photos", nostr.Filter{ + Kinds: []nostr.Kind{20}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add videos directory + r.createViewDirLocked(dirPath, "videos", nostr.Filter{ + Kinds: []nostr.Kind{21, 22}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Add wikis directory + r.createViewDirLocked(dirPath, "wikis", nostr.Filter{ + Kinds: []nostr.Kind{30818}, + Authors: []nostr.PubKey{pubkey}, + Limit: 50, + }) + + // Fetch profile picture asynchronously + go r.fetchProfilePicture(dirPath, pubkey) +} + +func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) { + dirPath := parentPath + "/" + name + + // Check if already exists + if _, exists := r.nodes[dirPath]; exists { + return + } + + dirNode := &Node{ + ino: r.nextIno, + path: dirPath, + name: name, + isDir: true, + mode: fuse.S_IFDIR | 0755, + mtime: time.Now(), + children: make(map[string]*Node), + } + r.nextIno++ + + r.nodes[dirPath] = dirNode + if parent, ok := r.nodes[parentPath]; ok { + parent.children[name] = dirNode + } + + // Fetch events asynchronously + go r.fetchEvents(dirPath, filter) +} + +func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool { + dirPath := "/" + name + + // Fetch the event + ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) + defer cancel() + + var relays []string + if len(pointer.Relays) > 0 { + relays = pointer.Relays + } else { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + filter := nostr.Filter{IDs: []nostr.ID{pointer.ID}} + + var evt *nostr.Event + for ie := range r.sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{ + Label: "nak-fs-event", + }) { + // Make a copy to avoid pointer issues + evtCopy := ie.Event + evt = &evtCopy + break + } + + if evt == nil { + return false + } + + // Create event directory + dirNode := &Node{ + ino: r.nextIno, + path: dirPath, + name: name, + isDir: true, + mode: fuse.S_IFDIR | 0755, + mtime: time.Unix(int64(evt.CreatedAt), 0), + children: make(map[string]*Node), + } + r.nextIno++ + r.nodes[dirPath] = dirNode + r.nodes["/"].children[name] = dirNode + + // Add content file + ext := kindToExtension(evt.Kind) + contentPath := dirPath + "/content." + ext + contentNode := &Node{ + ino: r.nextIno, + path: contentPath, + name: "content." + ext, + isDir: false, + mode: fuse.S_IFREG | 0644, + mtime: time.Unix(int64(evt.CreatedAt), 0), + data: []byte(evt.Content), + size: int64(len(evt.Content)), + } + r.nextIno++ + r.nodes[contentPath] = contentNode + dirNode.children["content."+ext] = contentNode + + // Add event.json + eventJSON, _ := json.MarshalIndent(evt, "", " ") + eventJSONPath := dirPath + "/event.json" + eventJSONNode := &Node{ + ino: r.nextIno, + path: eventJSONPath, + name: "event.json", + isDir: false, + mode: fuse.S_IFREG | 0444, + mtime: time.Unix(int64(evt.CreatedAt), 0), + data: eventJSON, + size: int64(len(eventJSON)), + } + r.nextIno++ + r.nodes[eventJSONPath] = eventJSONNode + dirNode.children["event.json"] = eventJSONNode + + return true +} + +func (r *NostrRoot) Readdir(path string, + fill func(name string, stat *fuse.Stat_t, ofst int64) bool, + ofst int64, + fh uint64) int { + + node := r.getNode(path) + if node == nil || !node.isDir { + return -fuse.ENOENT + } + + fill(".", nil, 0) + fill("..", nil, 0) + + r.mu.RLock() + defer r.mu.RUnlock() + + for name, child := range node.children { + stat := &fuse.Stat_t{ + Ino: child.ino, + Mode: child.mode, + Size: child.size, + Mtim: fuse.NewTimespec(child.mtime), + } + if !fill(name, stat, 0) { + break + } + } + + return 0 +} + +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{})) + logv("Open: path='%s' flags=%d\n", path, flags) + } + + node := r.getNode(path) + if node == nil { + return -fuse.ENOENT, ^uint64(0) + } + if node.isDir { + return -fuse.EISDIR, ^uint64(0) + } + + // Load data if needed + if node.loadFunc != nil && !node.loaded { + r.mu.Lock() + if !node.loaded { + if data, err := node.loadFunc(); err == nil { + node.data = data + node.size = int64(len(data)) + node.loaded = true + } + } + r.mu.Unlock() + } + + return 0, node.ino +} + +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 + } + + if ofst >= node.size { + return 0 + } + + endofst := ofst + int64(len(buff)) + if endofst > node.size { + endofst = node.size + } + + n := copy(buff, node.data[ofst:endofst]) + return n +} + +func (r *NostrRoot) Opendir(path string) (int, uint64) { + node := r.getNode(path) + if node == nil { + return -fuse.ENOENT, ^uint64(0) + } + if !node.isDir { + return -fuse.ENOTDIR, ^uint64(0) + } + return 0, node.ino +} + +func (r *NostrRoot) Release(path string, fh uint64) int { + return 0 +} + +func (r *NostrRoot) 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) { + // Parse path + path = strings.ReplaceAll(path, "\\", "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + dir := filepath.Dir(path) + name := filepath.Base(path) + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if parent directory exists + parent, ok := r.nodes[dir] + if !ok || !parent.isDir { + return -fuse.ENOENT, ^uint64(0) + } + + // Check if file already exists + if _, exists := r.nodes[path]; exists { + return -fuse.EEXIST, ^uint64(0) + } + + // Create new file node + fileNode := &Node{ + ino: r.nextIno, + path: path, + name: name, + isDir: false, + mode: fuse.S_IFREG | 0644, + mtime: time.Now(), + data: []byte{}, + size: 0, + } + r.nextIno++ + + r.nodes[path] = fileNode + parent.children[name] = fileNode + + return 0, fileNode.ino +} + +// Truncate truncates a file +func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int { + node := r.getNode(path) + if node == nil { + return -fuse.ENOENT + } + if node.isDir { + return -fuse.EISDIR + } + + r.mu.Lock() + defer r.mu.Unlock() + + if size == 0 { + node.data = []byte{} + } else if size < int64(len(node.data)) { + node.data = node.data[:size] + } else { + // Extend with zeros + newData := make([]byte, size) + copy(newData, node.data) + node.data = newData + } + node.size = size + node.mtime = time.Now() + + return 0 +} + +// Write writes data to a file +func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int { + node := r.getNode(path) + if node == nil { + return -fuse.ENOENT + } + if node.isDir { + return -fuse.EISDIR + } + + r.mu.Lock() + defer r.mu.Unlock() + + endofst := ofst + int64(len(buff)) + + // Extend data if necessary + if endofst > int64(len(node.data)) { + newData := make([]byte, endofst) + copy(newData, node.data) + node.data = newData + } + + n := copy(node.data[ofst:], buff) + node.size = int64(len(node.data)) + node.mtime = time.Now() + + // Check if this is a note that should be auto-published + if r.signer != nil && strings.Contains(path, "/notes/") && !strings.HasPrefix(filepath.Base(path), ".") { + // Cancel existing timer if any + if timer, exists := r.pendingNotes[path]; exists { + timer.Stop() + } + + // Schedule auto-publish + timeout := r.opts.AutoPublishNotesTimeout + if timeout > 0 && timeout < time.Hour*24*365 { + r.pendingNotes[path] = time.AfterFunc(timeout, func() { + r.publishNote(path) + }) + } + } + + return n +} + +func (r *NostrRoot) publishNote(path string) { + r.mu.Lock() + node, ok := r.nodes[path] + if !ok { + r.mu.Unlock() + return + } + + content := string(node.data) + r.mu.Unlock() + + if r.signer == nil { + return + } + + log := r.getLog() + log("- auto-publishing note from %s\n", path) + + // Create and sign event + evt := &nostr.Event{ + CreatedAt: nostr.Now(), + Kind: 1, + Tags: nostr.Tags{}, + Content: content, + } + + if err := r.signer.SignEvent(r.ctx, evt); err != nil { + log("- failed to sign note: %v\n", err) + return + } + + // Publish to relays + ctx, cancel := context.WithTimeout(r.ctx, time.Second*10) + defer cancel() + + relays := r.sys.FetchOutboxRelays(ctx, r.rootPubKey, 3) + if len(relays) == 0 { + relays = []string{"wss://relay.damus.io", "wss://nos.lol"} + } + + for _, url := range relays { + relay, err := r.sys.Pool.EnsureRelay(url) + if err != nil { + continue + } + relay.Publish(ctx, *evt) + } + + log("- published note %s to %d relays\n", evt.ID.Hex()[:8], len(relays)) + + // Update filename to include event ID + r.mu.Lock() + defer r.mu.Unlock() + + dir := filepath.Dir(path) + oldName := filepath.Base(path) + ext := filepath.Ext(oldName) + newName := evt.ID.Hex()[:8] + ext + newPath := dir + "/" + newName + + // Rename node + if _, exists := r.nodes[newPath]; !exists { + node.path = newPath + node.name = newName + r.nodes[newPath] = node + delete(r.nodes, path) + + if parent, ok := r.nodes[dir]; ok { + delete(parent.children, oldName) + parent.children[newName] = node + } + } + + delete(r.pendingNotes, path) +} + +// Unlink deletes a file +func (r *NostrRoot) Unlink(path string) int { + path = strings.ReplaceAll(path, "\\", "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + dir := filepath.Dir(path) + name := filepath.Base(path) + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if file exists + node, ok := r.nodes[path] + if !ok { + return -fuse.ENOENT + } + if node.isDir { + return -fuse.EISDIR + } + + // Remove from parent + if parent, ok := r.nodes[dir]; ok { + delete(parent.children, name) + } + + // Remove from nodes map + delete(r.nodes, path) + + return 0 +} + +// Mkdir creates a new directory +func (r *NostrRoot) Mkdir(path string, mode uint32) int { + path = strings.ReplaceAll(path, "\\", "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + dir := filepath.Dir(path) + name := filepath.Base(path) + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if parent directory exists + parent, ok := r.nodes[dir] + if !ok || !parent.isDir { + return -fuse.ENOENT + } + + // Check if directory already exists + if _, exists := r.nodes[path]; exists { + return -fuse.EEXIST + } + + // Create new directory node + dirNode := &Node{ + ino: r.nextIno, + path: path, + name: name, + isDir: true, + mode: fuse.S_IFDIR | 0755, + mtime: time.Now(), + children: make(map[string]*Node), + } + r.nextIno++ + + r.nodes[path] = dirNode + parent.children[name] = dirNode + + return 0 +} + +// Rmdir removes a directory +func (r *NostrRoot) Rmdir(path string) int { + path = strings.ReplaceAll(path, "\\", "/") + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + if path == "/" { + return -fuse.EACCES + } + + dir := filepath.Dir(path) + name := filepath.Base(path) + + r.mu.Lock() + defer r.mu.Unlock() + + // Check if directory exists + node, ok := r.nodes[path] + if !ok { + return -fuse.ENOENT + } + if !node.isDir { + return -fuse.ENOTDIR + } + + // Check if directory is empty + if len(node.children) > 0 { + return -fuse.ENOTEMPTY + } + + // Remove from parent + if parent, ok := r.nodes[dir]; ok { + delete(parent.children, name) + } + + // Remove from nodes map + delete(r.nodes, path) + + return 0 +} + +// Utimens updates file timestamps +func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int { + node := r.getNode(path) + if node == nil { + return -fuse.ENOENT + } + + r.mu.Lock() + defer r.mu.Unlock() + + if len(tmsp) > 1 { + node.mtime = time.Unix(tmsp[1].Sec, int64(tmsp[1].Nsec)) + } + + return 0 +} diff --git a/nostrfs/viewdir.go b/nostrfs/viewdir.go deleted file mode 100644 index de3afba..0000000 --- a/nostrfs/viewdir.go +++ /dev/null @@ -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 -} diff --git a/nostrfs/writeablefile.go b/nostrfs/writeablefile.go deleted file mode 100644 index b6ca0a9..0000000 --- a/nostrfs/writeablefile.go +++ /dev/null @@ -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 -}