fs: editable articles and wiki.

This commit is contained in:
fiatjaf
2025-03-12 08:03:10 -03:00
parent c87371208e
commit 931da4b0ae
7 changed files with 466 additions and 182 deletions

View File

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

View File

@@ -9,9 +9,13 @@ import (
"net/http"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"unsafe"
"fiatjaf.com/lib/debouncer"
"github.com/fatih/color"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/nbd-wtf/go-nostr"
@@ -23,18 +27,29 @@ import (
type EntityDir struct {
fs.Inode
ctx context.Context
wd string
evt *nostr.Event
root *NostrRoot
publisher *debouncer.Debouncer
extension string
event *nostr.Event
updating struct {
title string
content string
}
}
var _ = (fs.NodeGetattrer)((*EntityDir)(nil))
var (
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
_ = (fs.NodeCreater)((*EntityDir)(nil))
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
)
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
publishedAt := uint64(e.evt.CreatedAt)
publishedAt := uint64(e.event.CreatedAt)
out.Ctime = publishedAt
if tag := e.evt.Tags.Find("published_at"); tag != nil {
if tag := e.event.Tags.Find("published_at"); tag != nil {
publishedAt, _ = strconv.ParseUint(tag[1], 10, 64)
}
out.Mtime = publishedAt
@@ -42,119 +57,147 @@ func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOu
return fs.OK
}
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)
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" {
// this causes the publish process to be triggered faster
e.publisher.Flush()
return nil, nil, 0, syscall.ENOTDIR
}
return r.CreateEntityDir(parent, extension, event), nil
return nil, nil, 0, syscall.ENOTSUP
}
func (r *NostrRoot) CreateEntityDir(
parent fs.InodeEmbedder,
extension string,
event *nostr.Event,
) *fs.Inode {
log := r.ctx.Value("log").(func(msg string, args ...any))
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
switch name {
case "content" + e.extension:
e.updating.content = e.event.Content
return syscall.ENOTDIR
case "title":
e.updating.title = ""
if titleTag := e.event.Tags.Find("title"); titleTag != nil {
e.updating.title = titleTag[1]
}
return syscall.ENOTDIR
default:
return syscall.EINTR
}
}
h := parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EntityDir{ctx: r.ctx, wd: r.wd, evt: event},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(event.ID)},
)
var publishedAt uint64
if tag := event.Tags.Find("published_at"); tag != nil {
func (e *EntityDir) OnAdd(_ context.Context) {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
publishedAt := uint64(e.event.CreatedAt)
if tag := e.event.Tags.Find("published_at"); tag != nil {
publishedAt, _ = strconv.ParseUint(tag[1], 10, 64)
}
npub, _ := nip19.EncodePublicKey(event.PubKey)
h.AddChild("@author", h.NewPersistentInode(
r.ctx,
npub, _ := nip19.EncodePublicKey(e.event.PubKey)
e.AddChild("@author", e.NewPersistentInode(
e.root.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + npub),
Data: []byte(e.root.wd + "/" + npub),
},
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(publishedAt),
Size: uint64(len(event.Content)),
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)
h.AddChild("identifier", h.NewPersistentInode(
r.ctx,
e.AddChild("identifier", e.NewPersistentInode(
e.root.ctx,
&fs.MemRegularFile{
Data: []byte(event.Tags.GetD()),
Data: []byte(e.event.Tags.GetD()),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(event.Tags.GetD())),
Ctime: uint64(e.event.CreatedAt),
Mtime: uint64(e.event.CreatedAt),
Size: uint64(len(e.event.Tags.GetD())),
},
},
fs.StableAttr{},
), true)
if tag := event.Tags.Find("title"); tag != nil {
h.AddChild("title", h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(tag[1]),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(tag[1])),
if e.root.signer == nil {
// read-only
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
var title string
if tag := e.event.Tags.Find("title"); tag != nil {
title = tag[1]
} else {
title = e.event.Tags.GetD()
}
return uint64(e.event.CreatedAt), publishedAt, title
},
},
fs.StableAttr{},
), true)
}
h.AddChild("content"+extension, h.NewPersistentInode(
r.ctx,
&fs.MemRegularFile{
Data: []byte(event.Content),
Attr: fuse.Attr{
Mode: 0444,
Ctime: uint64(event.CreatedAt),
Mtime: uint64(publishedAt),
Size: uint64(len(event.Content)),
e.AddChild("content."+e.extension, e.NewPersistentInode(
e.root.ctx,
&DeterministicFile{
get: func() (ctime uint64, mtime uint64, data string) {
return uint64(e.event.CreatedAt), publishedAt, e.event.Content
},
},
},
fs.StableAttr{},
), true)
fs.StableAttr{},
), true)
} else {
// writeable
if tag := e.event.Tags.Find("title"); tag != nil {
e.updating.title = tag[1]
}
e.updating.content = e.event.Content
e.AddChild("title", e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), publishedAt, func(s string) {
log("title updated")
e.updating.title = strings.TrimSpace(s)
e.handleWrite()
}),
fs.StableAttr{},
), true)
e.AddChild("content."+e.extension, e.NewPersistentInode(
e.root.ctx,
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), 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.ParseReferences(*event) {
for ref := range nip27.ParseReferences(*e.event) {
i++
if refsdir == nil {
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("references", refsdir, true)
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(
r.ctx,
e.root.ctx,
&fs.MemSymlink{
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
},
fs.StableAttr{Mode: syscall.S_IFLNK},
), true)
@@ -164,15 +207,15 @@ func (r *NostrRoot) CreateEntityDir(
addImage := func(url string) {
if imagesdir == nil {
in := &fs.Inode{}
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
h.AddChild("images", imagesdir, true)
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(
r.ctx,
e.root.ctx,
&AsyncFile{
ctx: r.ctx,
ctx: e.root.ctx,
load: func() ([]byte, nostr.Timestamp) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
defer cancel()
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
@@ -198,7 +241,7 @@ func (r *NostrRoot) CreateEntityDir(
), true)
}
images := nip92.ParseTags(event.Tags)
images := nip92.ParseTags(e.event.Tags)
for _, imeta := range images {
if imeta.URL == "" {
continue
@@ -206,9 +249,116 @@ func (r *NostrRoot) CreateEntityDir(
addImage(imeta.URL)
}
if tag := event.Tags.Find("image"); tag != nil {
if tag := e.event.Tags.Find("image"); tag != nil {
addImage(tag[1])
}
return h
}
func (e *EntityDir) handleWrite() {
log := e.root.ctx.Value("log").(func(msg string, args ...any))
if e.publisher.IsRunning() {
log(", timer reset")
}
log(", will publish the updated event in 30 seconds...\n")
if !e.publisher.IsRunning() {
log("- `touch publish` to publish immediately\n")
log("- `rm title content." + e.extension + "` to erase and cancel the edits\n")
}
e.publisher.Call(func() {
if currentTitle := e.event.Tags.Find("title"); (currentTitle != nil && currentTitle[1] == e.updating.title) || (currentTitle == nil && e.updating.title == "") && e.updating.content == e.event.Content {
log("back into the previous state, not publishing.\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)
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})
}
}
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag == nil {
evt.Tags = append(evt.Tags, nostr.Tag{
"published_at",
strconv.FormatInt(int64(e.event.CreatedAt), 10),
})
}
for ref := range nip27.ParseReferences(evt) {
tag := ref.Pointer.AsTag()
if existing := evt.Tags.FindWithValue(tag[0], tag[1]); existing == nil {
evt.Tags = append(evt.Tags, tag)
}
}
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
log("failed to sign: '%s'.\n", err)
return
}
relays := e.root.sys.FetchWriteRelays(e.root.ctx, evt.PubKey, 8)
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, ok := strings.CutPrefix(res.RelayURL, "wss://")
if !ok {
cleanUrl = res.RelayURL
}
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")
} 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, extension, event), nil
}
func (r *NostrRoot) CreateEntityDir(
parent fs.InodeEmbedder,
extension string,
event *nostr.Event,
) *fs.Inode {
return parent.EmbeddedInode().NewPersistentInode(
r.ctx,
&EntityDir{root: r, event: event, publisher: debouncer.New(time.Second * 30), extension: extension},
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: hexToUint64(event.ID)},
)
}

View File

@@ -108,11 +108,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{1},
Authors: []string{pointer.PublicKey},
},
paginate: true,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, r.CreateEventDir(n, event)
},
paginate: true,
relays: relays,
replaceable: false,
extension: "txt",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -129,11 +128,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{1111},
Authors: []string{pointer.PublicKey},
},
paginate: true,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, r.CreateEventDir(n, event)
},
paginate: true,
relays: relays,
replaceable: false,
extension: "txt",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -150,11 +148,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{20},
Authors: []string{pointer.PublicKey},
},
paginate: true,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, r.CreateEventDir(n, event)
},
paginate: true,
relays: relays,
replaceable: false,
extension: "txt",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -171,11 +168,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{21, 22},
Authors: []string{pointer.PublicKey},
},
paginate: false,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, r.CreateEventDir(n, event)
},
paginate: false,
relays: relays,
replaceable: false,
extension: "txt",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -192,11 +188,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{9802},
Authors: []string{pointer.PublicKey},
},
paginate: false,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
return event.ID, r.CreateEventDir(n, event)
},
paginate: false,
relays: relays,
replaceable: false,
extension: "txt",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -213,15 +208,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{30023},
Authors: []string{pointer.PublicKey},
},
paginate: false,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
d := event.Tags.GetD()
if d == "" {
d = "_"
}
return d, r.CreateEntityDir(n, ".md", event)
},
paginate: false,
relays: relays,
replaceable: true,
extension: "md",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),
@@ -238,15 +228,10 @@ func (r *NostrRoot) CreateNpubDir(
Kinds: []int{30818},
Authors: []string{pointer.PublicKey},
},
paginate: false,
relays: relays,
create: func(n *ViewDir, event *nostr.Event) (string, *fs.Inode) {
d := event.Tags.GetD()
if d == "" {
d = "_"
}
return d, r.CreateEntityDir(n, ".adoc", event)
},
paginate: false,
relays: relays,
replaceable: true,
extension: "adoc",
},
fs.StableAttr{Mode: syscall.S_IFDIR},
),

View File

@@ -12,12 +12,13 @@ import (
type ViewDir struct {
fs.Inode
root *NostrRoot
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
create func(*ViewDir, *nostr.Event) (string, *fs.Inode)
root *NostrRoot
fetched atomic.Bool
filter nostr.Filter
paginate bool
relays []string
replaceable bool
extension string
}
var (
@@ -49,27 +50,37 @@ func (n *ViewDir) Opendir(_ context.Context) syscall.Errno {
aMonthAgo := now - 30*24*60*60
n.filter.Since = &aMonthAgo
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter, nostr.WithLabel("nakfs")) {
basename, inode := n.create(n, ie.Event)
n.AddChild(basename, inode, true)
}
filter := n.filter
filter.Until = &aMonthAgo
n.AddChild("@previous", n.NewPersistentInode(
n.root.ctx,
&ViewDir{
root: n.root,
filter: filter,
relays: n.relays,
root: n.root,
filter: filter,
relays: n.relays,
extension: n.extension,
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.WithLabel("nakfs"),
).Range {
name := rkey.D
if name == "" {
name = "_"
}
n.AddChild(name, n.root.CreateEntityDir(n, n.extension, evt), true)
}
} else {
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter, nostr.WithLabel("nakfs")) {
basename, inode := n.create(n, ie.Event)
n.AddChild(basename, inode, true)
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
nostr.WithLabel("nakfs"),
) {
n.AddChild(ie.Event.ID, n.root.CreateEventDir(n, ie.Event), true)
}
}

88
nostrfs/writeablefile.go Normal file
View File

@@ -0,0 +1,88 @@
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()
end := int64(len(data)) + off
if int64(len(f.data)) < end {
n := make([]byte, end)
copy(n, f.data)
f.data = n
}
copy(f.data[off:off+int64(len(data))], data)
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(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
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
}