From b2d5aa9bc2ef0f6894566863f19e49431c90f14a Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Mon, 19 Jan 2026 00:50:25 +0900 Subject: [PATCH] add missing files --- nostrfs/asyncfile.go | 56 +++++ nostrfs/deterministicfile.go | 50 +++++ nostrfs/entitydir.go | 408 +++++++++++++++++++++++++++++++++++ nostrfs/eventdir.go | 241 +++++++++++++++++++++ nostrfs/helpers.go | 16 ++ nostrfs/npubdir.go | 261 ++++++++++++++++++++++ nostrfs/root.go | 130 +++++++++++ nostrfs/viewdir.go | 267 +++++++++++++++++++++++ nostrfs/writeablefile.go | 93 ++++++++ 9 files changed, 1522 insertions(+) create mode 100644 nostrfs/asyncfile.go create mode 100644 nostrfs/deterministicfile.go create mode 100644 nostrfs/entitydir.go create mode 100644 nostrfs/eventdir.go create mode 100644 nostrfs/helpers.go create mode 100644 nostrfs/npubdir.go create mode 100644 nostrfs/root.go create mode 100644 nostrfs/viewdir.go create mode 100644 nostrfs/writeablefile.go 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 +}