mirror of
https://github.com/fiatjaf/nak.git
synced 2025-05-04 13:39:55 -04:00
401 lines
10 KiB
Go
401 lines
10 KiB
Go
package nostrfs
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"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"
|
|
"github.com/nbd-wtf/go-nostr/nip19"
|
|
"github.com/nbd-wtf/go-nostr/nip27"
|
|
"github.com/nbd-wtf/go-nostr/nip92"
|
|
sdk "github.com/nbd-wtf/go-nostr/sdk"
|
|
)
|
|
|
|
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))
|
|
|
|
npub, _ := nip19.EncodePublicKey(e.event.PubKey)
|
|
e.AddChild("@author", e.NewPersistentInode(
|
|
e.root.ctx,
|
|
&fs.MemSymlink{
|
|
Data: []byte(e.root.wd + "/" + npub),
|
|
},
|
|
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.ParseReferences(*e.event) {
|
|
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.ParseReferences(evt) {
|
|
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, 8)
|
|
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},
|
|
)
|
|
}
|