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
+}