mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-25 03:48:52 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6a6d7506 | ||
|
|
cff60b2f9f | ||
|
|
cb2247c9da | ||
|
|
686d960f62 | ||
|
|
af04838153 | ||
|
|
c6da13649d | ||
|
|
acd6227dd0 | ||
|
|
00fbda9af7 | ||
|
|
e838de9b72 | ||
|
|
6dfbed4413 | ||
|
|
0e283368ed | ||
|
|
38775e0d93 | ||
|
|
fabcad3f61 | ||
|
|
69e4895e48 |
5
.github/workflows/release-cli.yml
vendored
5
.github/workflows/release-cli.yml
vendored
@@ -47,6 +47,7 @@ jobs:
|
||||
md5sum: false
|
||||
sha256sum: false
|
||||
compress_assets: false
|
||||
|
||||
smoke-test-linux-amd64:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
# test NIP-49 key encryption/decryption
|
||||
echo "testing NIP-49 key encryption/decryption..."
|
||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||
echo "encrypted key: ${ENCRYPTED_KEY:0:20}..."
|
||||
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||
echo "nip-49 encryption/decryption test failed!"
|
||||
@@ -116,7 +117,7 @@ jobs:
|
||||
# test relay operations (with a public relay)
|
||||
echo "testing publishing..."
|
||||
# publish a simple event to a public relay
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol)
|
||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
|
||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||
echo "published event ID: $EVENT_ID"
|
||||
|
||||
|
||||
125
blossom.go
125
blossom.go
@@ -3,10 +3,15 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"fiatjaf.com/nostr/nipb0/blossom"
|
||||
"github.com/urfave/cli/v3"
|
||||
@@ -230,11 +235,46 @@ if any of the files are not found the command will fail, otherwise it will succe
|
||||
},
|
||||
{
|
||||
Name: "mirror",
|
||||
Usage: "",
|
||||
Description: ``,
|
||||
Usage: "mirrors blobs from source server to target server",
|
||||
Description: `lists all blobs from the source server and mirrors them to the target server using BUD-04. requires --sec to sign the authorization event.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
ArgsUsage: "",
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
targetClient, err := getBlossomClient(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create client for source server
|
||||
sourceServer := c.Args().First()
|
||||
keyer, _, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sourceClient := blossom.NewClient(sourceServer, keyer)
|
||||
|
||||
// Get list of blobs from source server
|
||||
bds, err := sourceClient.List(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list blobs from source server: %w", err)
|
||||
}
|
||||
|
||||
// Mirror each blob to target server
|
||||
hasError := false
|
||||
for _, bd := range bds {
|
||||
mirrored, err := mirrorBlob(ctx, targetClient, bd.URL)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to mirror %s: %s\n", bd.SHA256, err)
|
||||
hasError = true
|
||||
continue
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(mirrored)
|
||||
stdout(string(j))
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(3)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -248,3 +288,82 @@ func getBlossomClient(ctx context.Context, c *cli.Command) (*blossom.Client, err
|
||||
}
|
||||
return blossom.NewClient(c.String("server"), keyer), nil
|
||||
}
|
||||
|
||||
// mirrorBlob mirrors a blob from a URL to the mediaserver using BUD-04
|
||||
func mirrorBlob(ctx context.Context, client *blossom.Client, url string) (*blossom.BlobDescriptor, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to download blob from URL: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to download blob: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read blob content: %w", err)
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(data)
|
||||
hashHex := hex.EncodeToString(hash[:])
|
||||
|
||||
signer := client.GetSigner()
|
||||
pubkey, _ := signer.GetPublicKey(ctx)
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: 24242,
|
||||
CreatedAt: nostr.Now(),
|
||||
Tags: nostr.Tags{
|
||||
{"t", "upload"},
|
||||
{"x", hashHex},
|
||||
{"expiration", fmt.Sprintf("%d", nostr.Now()+60)},
|
||||
},
|
||||
Content: "blossom stuff",
|
||||
PubKey: pubkey,
|
||||
}
|
||||
|
||||
if err := signer.SignEvent(ctx, &evt); err != nil {
|
||||
return nil, fmt.Errorf("failed to sign authorization event: %w", err)
|
||||
}
|
||||
|
||||
evtj, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal authorization event: %w", err)
|
||||
}
|
||||
auth := base64.StdEncoding.EncodeToString(evtj)
|
||||
|
||||
mediaserver := client.GetMediaServer()
|
||||
mirrorURL := mediaserver + "mirror"
|
||||
|
||||
requestBody := map[string]string{"url": url}
|
||||
requestJSON, _ := json.Marshal(requestBody)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", mirrorURL, bytes.NewReader(requestJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create mirror request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Nostr "+auth)
|
||||
|
||||
httpClient := &http.Client{}
|
||||
mirrorResp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send mirror request: %w", err)
|
||||
}
|
||||
defer mirrorResp.Body.Close()
|
||||
|
||||
if mirrorResp.StatusCode < 200 || mirrorResp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(mirrorResp.Body)
|
||||
return nil, fmt.Errorf("mirror request failed with HTTP %d: %s", mirrorResp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var bd blossom.BlobDescriptor
|
||||
if err := json.NewDecoder(mirrorResp.Body).Decode(&bd); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode blob descriptor: %w", err)
|
||||
}
|
||||
|
||||
return &bd, nil
|
||||
}
|
||||
|
||||
@@ -329,6 +329,10 @@ var bunker = &cli.Command{
|
||||
|
||||
// asking user for authorization
|
||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
|
||||
return true
|
||||
}
|
||||
|
||||
if secret == newSecret {
|
||||
// store this key
|
||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
||||
@@ -343,9 +347,11 @@ var bunker = &cli.Command{
|
||||
if persist != nil {
|
||||
persist()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
||||
return false
|
||||
}
|
||||
|
||||
for ie := range events {
|
||||
|
||||
55
encode.go
55
encode.go
@@ -25,13 +25,6 @@ var encode = &cli.Command{
|
||||
"relays":["wss://nada.zero"],
|
||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||
} | nak encode`,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to naddr code",
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
if c.Args().Len() != 0 {
|
||||
@@ -126,7 +119,12 @@ var encode = &cli.Command{
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to nprofile code",
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
@@ -139,6 +137,13 @@ var encode = &cli.Command{
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,6 +164,16 @@ var encode = &cli.Command{
|
||||
Aliases: []string{"a"},
|
||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
@@ -171,6 +186,13 @@ var encode = &cli.Command{
|
||||
|
||||
author := getPubKey(c, "author")
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -204,6 +226,16 @@ var encode = &cli.Command{
|
||||
Usage: "kind of referred replaceable event",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "relay",
|
||||
Aliases: []string{"r"},
|
||||
Usage: "attach relay hints to the code",
|
||||
},
|
||||
&BoolIntFlag{
|
||||
Name: "outbox",
|
||||
Usage: "automatically appends outbox relays to the code",
|
||||
Value: 3,
|
||||
},
|
||||
},
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
@@ -224,6 +256,13 @@ var encode = &cli.Command{
|
||||
}
|
||||
|
||||
relays := c.StringSlice("relay")
|
||||
|
||||
if getBoolInt(c, "outbox") > 0 {
|
||||
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||
relays = appendUnique(relays, r)
|
||||
}
|
||||
}
|
||||
|
||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
1
event.go
1
event.go
@@ -155,6 +155,7 @@ example:
|
||||
os.Exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
56
flags.go
56
flags.go
@@ -11,6 +11,62 @@ import (
|
||||
"github.com/urfave/cli/v3"
|
||||
)
|
||||
|
||||
type (
|
||||
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
|
||||
)
|
||||
|
||||
type boolIntValue struct {
|
||||
int int
|
||||
defaultWhenSet int
|
||||
hasDefault bool
|
||||
hasBeenSet bool
|
||||
}
|
||||
|
||||
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
|
||||
|
||||
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
|
||||
*p = val
|
||||
|
||||
return &boolIntValue{
|
||||
defaultWhenSet: val,
|
||||
hasDefault: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (t boolIntValue) IsBoolFlag() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (t boolIntValue) ToString(b int) string { return "<<>>" }
|
||||
|
||||
func (t *boolIntValue) Set(value string) error {
|
||||
t.hasBeenSet = true
|
||||
if value == "true" {
|
||||
if t.hasDefault {
|
||||
t.int = t.defaultWhenSet
|
||||
} else {
|
||||
t.int = 1
|
||||
}
|
||||
return nil
|
||||
} else {
|
||||
var err error
|
||||
t.int, err = strconv.Atoi(value)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
|
||||
func (t *boolIntValue) Value() int { return t.int }
|
||||
func (t *boolIntValue) Get() any { return t.int }
|
||||
|
||||
func getBoolInt(cmd *cli.Command, name string) int {
|
||||
return cmd.Value(name).(int)
|
||||
}
|
||||
|
||||
//
|
||||
//
|
||||
//
|
||||
|
||||
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||
|
||||
type naturalTimeValue struct {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build windows || openbsd
|
||||
//go:build openbsd
|
||||
|
||||
package main
|
||||
|
||||
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
|
||||
Description: `doesn't work on Windows and OpenBSD.`,
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
return fmt.Errorf("this doesn't work on Windows and OpenBSD.")
|
||||
return fmt.Errorf("this doesn't work on OpenBSD.")
|
||||
},
|
||||
}
|
||||
|
||||
1179
fs_root.go
Normal file
1179
fs_root.go
Normal file
File diff suppressed because it is too large
Load Diff
139
fs_windows.go
Normal file
139
fs_windows.go
Normal file
@@ -0,0 +1,139 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/keyer"
|
||||
"github.com/fatih/color"
|
||||
"github.com/urfave/cli/v3"
|
||||
"github.com/winfsp/cgofuse/fuse"
|
||||
)
|
||||
|
||||
var fsCmd = &cli.Command{
|
||||
Name: "fs",
|
||||
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||
Description: `(experimental)`,
|
||||
ArgsUsage: "<mountpoint>",
|
||||
Flags: append(defaultKeyFlags,
|
||||
&PubKeyFlag{
|
||||
Name: "pubkey",
|
||||
Usage: "public key from where to to prepopulate directories",
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-notes",
|
||||
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||
Value: time.Second * 30,
|
||||
},
|
||||
&cli.DurationFlag{
|
||||
Name: "auto-publish-articles",
|
||||
Usage: "delay after which edited articles will be auto-published.",
|
||||
Value: time.Hour * 24 * 365 * 2,
|
||||
DefaultText: "basically infinite",
|
||||
},
|
||||
),
|
||||
DisableSliceFlagSeparator: true,
|
||||
Action: func(ctx context.Context, c *cli.Command) error {
|
||||
mountpoint := c.Args().First()
|
||||
if mountpoint == "" {
|
||||
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||
}
|
||||
|
||||
var kr nostr.User
|
||||
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||
kr = signer
|
||||
} else {
|
||||
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||
}
|
||||
|
||||
apnt := c.Duration("auto-publish-notes")
|
||||
if apnt < 0 {
|
||||
apnt = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
apat := c.Duration("auto-publish-articles")
|
||||
if apat < 0 {
|
||||
apat = time.Hour * 24 * 365 * 3
|
||||
}
|
||||
|
||||
root := NewFSRoot(
|
||||
context.WithValue(
|
||||
context.WithValue(
|
||||
ctx,
|
||||
"log", log,
|
||||
),
|
||||
"logverbose", logverbose,
|
||||
),
|
||||
sys,
|
||||
kr,
|
||||
mountpoint,
|
||||
FSOptions{
|
||||
AutoPublishNotesTimeout: apnt,
|
||||
AutoPublishArticlesTimeout: apat,
|
||||
},
|
||||
)
|
||||
|
||||
// create the server
|
||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||
|
||||
// create cgofuse host
|
||||
host := fuse.NewFileSystemHost(root)
|
||||
host.SetCapReaddirPlus(true)
|
||||
host.SetUseIno(true)
|
||||
|
||||
// mount the filesystem - Windows/WinFsp version
|
||||
// based on rclone cmount implementation
|
||||
mountArgs := []string{
|
||||
"-o", "uid=-1",
|
||||
"-o", "gid=-1",
|
||||
"--FileSystemName=nak",
|
||||
}
|
||||
|
||||
// check if mountpoint is a drive letter or directory
|
||||
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
|
||||
|
||||
if !isDriveLetter {
|
||||
// winFsp primarily supports drive letters on Windows
|
||||
// directory mounting may not work reliably
|
||||
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
|
||||
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
|
||||
|
||||
// for directory mounts, follow rclone's approach:
|
||||
// 1. check that mountpoint doesn't already exist
|
||||
if _, err := os.Stat(mountpoint); err == nil {
|
||||
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("failed to check mountpoint: %w", err)
|
||||
}
|
||||
|
||||
// 2. check that parent directory exists
|
||||
parent := filepath.Join(mountpoint, "..")
|
||||
if _, err := os.Stat(parent); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
|
||||
}
|
||||
return fmt.Errorf("failed to check parent directory: %w", err)
|
||||
}
|
||||
|
||||
// 3. use network mode for directory mounts
|
||||
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
|
||||
}
|
||||
|
||||
if isVerbose {
|
||||
mountArgs = append(mountArgs, "-o", "debug")
|
||||
}
|
||||
mountArgs = append(mountArgs, mountpoint)
|
||||
|
||||
log("ok.\n")
|
||||
|
||||
if !host.Mount("", mountArgs) {
|
||||
return fmt.Errorf("failed to mount filesystem")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
5
go.mod
5
go.mod
@@ -3,7 +3,6 @@ module github.com/fiatjaf/nak
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
fiatjaf.com/lib v0.3.1
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/bep/debounce v1.2.1
|
||||
@@ -24,11 +23,14 @@ require (
|
||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||
github.com/winfsp/cgofuse v1.6.0
|
||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/term v0.32.0
|
||||
)
|
||||
|
||||
require fiatjaf.com/lib v0.3.2
|
||||
|
||||
require (
|
||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||
@@ -69,7 +71,6 @@ require (
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/magefile/mage v1.14.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
|
||||
10
go.sum
10
go.sum
@@ -1,5 +1,5 @@
|
||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||
@@ -169,8 +169,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
|
||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
|
||||
github.com/liamg/magic v0.0.1 h1:Ru22ElY+sCh6RvRTWjQzKKCxsEco8hE0co8n1qe7TBM=
|
||||
github.com/liamg/magic v0.0.1/go.mod h1:yQkOmZZI52EA+SQ2xyHpVw8fNvTBruF873Y+Vt6S+fk=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
@@ -269,6 +269,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
|
||||
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
|
||||
12
helpers.go
12
helpers.go
@@ -46,8 +46,14 @@ var (
|
||||
)
|
||||
|
||||
func isPiped() bool {
|
||||
stat, _ := os.Stdin.Stat()
|
||||
return stat.Mode()&os.ModeCharDevice == 0
|
||||
stat, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mode := stat.Mode()
|
||||
is := mode&os.ModeCharDevice == 0
|
||||
return is
|
||||
}
|
||||
|
||||
func getJsonsOrBlank() iter.Seq[string] {
|
||||
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
||||
return true
|
||||
})
|
||||
|
||||
if !hasStdin && !isPiped() {
|
||||
if !hasStdin {
|
||||
yield("{}")
|
||||
}
|
||||
|
||||
|
||||
13
key.go
13
key.go
@@ -279,12 +279,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
|
||||
continue
|
||||
}
|
||||
sk = data.(nostr.SecretKey)
|
||||
}
|
||||
|
||||
sk, err := nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
} else {
|
||||
var err error
|
||||
sk, err = nostr.SecretKeyFromHex(sec)
|
||||
if err != nil {
|
||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ch <- sk
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"fiatjaf.com/nostr"
|
||||
)
|
||||
|
||||
type AsyncFile struct {
|
||||
fs.Inode
|
||||
ctx context.Context
|
||||
fetched atomic.Bool
|
||||
data []byte
|
||||
ts nostr.Timestamp
|
||||
load func() ([]byte, nostr.Timestamp)
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpener)((*AsyncFile)(nil))
|
||||
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
|
||||
)
|
||||
|
||||
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
if af.fetched.CompareAndSwap(false, true) {
|
||||
af.data, af.ts = af.load()
|
||||
}
|
||||
|
||||
out.Size = uint64(len(af.data))
|
||||
out.Mtime = uint64(af.ts)
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
||||
if af.fetched.CompareAndSwap(false, true) {
|
||||
af.data, af.ts = af.load()
|
||||
}
|
||||
|
||||
return nil, fuse.FOPEN_KEEP_CACHE, 0
|
||||
}
|
||||
|
||||
func (af *AsyncFile) Read(
|
||||
ctx context.Context,
|
||||
f fs.FileHandle,
|
||||
dest []byte,
|
||||
off int64,
|
||||
) (fuse.ReadResult, syscall.Errno) {
|
||||
end := int(off) + len(dest)
|
||||
if end > len(af.data) {
|
||||
end = len(af.data)
|
||||
}
|
||||
return fuse.ReadResultData(af.data[off:end]), 0
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type DeterministicFile struct {
|
||||
fs.Inode
|
||||
get func() (ctime, mtime uint64, data string)
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
|
||||
_ = (fs.NodeReader)((*DeterministicFile)(nil))
|
||||
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
|
||||
)
|
||||
|
||||
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
|
||||
return &DeterministicFile{
|
||||
get: get,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||
}
|
||||
|
||||
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
var content string
|
||||
out.Mode = 0444
|
||||
out.Ctime, out.Mtime, content = f.get()
|
||||
out.Size = uint64(len(content))
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||
_, _, content := f.get()
|
||||
data := unsafe.Slice(unsafe.StringData(content), len(content))
|
||||
|
||||
end := int(off) + len(dest)
|
||||
if end > len(data) {
|
||||
end = len(data)
|
||||
}
|
||||
return fuse.ReadResultData(data[off:end]), fs.OK
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"fiatjaf.com/lib/debouncer"
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip27"
|
||||
"fiatjaf.com/nostr/nip73"
|
||||
"fiatjaf.com/nostr/nip92"
|
||||
sdk "fiatjaf.com/nostr/sdk"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type EntityDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
|
||||
publisher *debouncer.Debouncer
|
||||
event *nostr.Event
|
||||
updating struct {
|
||||
title string
|
||||
content string
|
||||
publishedAt uint64
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
|
||||
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
|
||||
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
|
||||
_ = (fs.NodeCreater)((*EntityDir)(nil))
|
||||
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
|
||||
)
|
||||
|
||||
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Ctime = uint64(e.event.CreatedAt)
|
||||
if e.updating.publishedAt != 0 {
|
||||
out.Mtime = e.updating.publishedAt
|
||||
} else {
|
||||
out.Mtime = e.PublishedAt()
|
||||
}
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (e *EntityDir) Create(
|
||||
_ context.Context,
|
||||
name string,
|
||||
flags uint32,
|
||||
mode uint32,
|
||||
out *fuse.EntryOut,
|
||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if name == "publish" && e.publisher.IsRunning() {
|
||||
// this causes the publish process to be triggered faster
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing now!\n")
|
||||
e.publisher.Flush()
|
||||
return nil, nil, 0, syscall.ENOTDIR
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
switch name {
|
||||
case "content" + kindToExtension(e.event.Kind):
|
||||
e.updating.content = e.event.Content
|
||||
return syscall.ENOTDIR
|
||||
case "title":
|
||||
e.updating.title = e.Title()
|
||||
return syscall.ENOTDIR
|
||||
default:
|
||||
return syscall.EINTR
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||
e.updating.publishedAt = in.Mtime
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (e *EntityDir) OnAdd(_ context.Context) {
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
e.AddChild("@author", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
|
||||
e.AddChild("event.json", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
eventj, _ := json.MarshalIndent(e.event, "", " ")
|
||||
return uint64(e.event.CreatedAt),
|
||||
uint64(e.event.CreatedAt),
|
||||
unsafe.String(unsafe.SliceData(eventj), len(eventj))
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
e.AddChild("identifier", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(e.event.Tags.GetD()),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(e.event.CreatedAt),
|
||||
Mtime: uint64(e.event.CreatedAt),
|
||||
Size: uint64(len(e.event.Tags.GetD())),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
|
||||
// read-only
|
||||
e.AddChild("title", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&DeterministicFile{
|
||||
get: func() (ctime uint64, mtime uint64, data string) {
|
||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
} else {
|
||||
// writeable
|
||||
e.updating.title = e.Title()
|
||||
e.updating.publishedAt = e.PublishedAt()
|
||||
e.updating.content = e.event.Content
|
||||
|
||||
e.AddChild("title", e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||
log("title updated")
|
||||
e.updating.title = strings.TrimSpace(s)
|
||||
e.handleWrite()
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||
log("content updated")
|
||||
e.updating.content = strings.TrimSpace(s)
|
||||
e.handleWrite()
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
var refsdir *fs.Inode
|
||||
i := 0
|
||||
for ref := range nip27.Parse(e.event.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
if refsdir == nil {
|
||||
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
e.root.AddChild("references", refsdir, true)
|
||||
}
|
||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
|
||||
var imagesdir *fs.Inode
|
||||
addImage := func(url string) {
|
||||
if imagesdir == nil {
|
||||
in := &fs.Inode{}
|
||||
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
e.AddChild("images", imagesdir, true)
|
||||
}
|
||||
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
|
||||
e.root.ctx,
|
||||
&AsyncFile{
|
||||
ctx: e.root.ctx,
|
||||
load: func() ([]byte, nostr.Timestamp) {
|
||||
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
log("failed to load image %s: %s\n", url, err)
|
||||
return nil, 0
|
||||
}
|
||||
w := &bytes.Buffer{}
|
||||
io.Copy(w, resp.Body)
|
||||
return w.Bytes(), 0
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
images := nip92.ParseTags(e.event.Tags)
|
||||
for _, imeta := range images {
|
||||
if imeta.URL == "" {
|
||||
continue
|
||||
}
|
||||
addImage(imeta.URL)
|
||||
}
|
||||
|
||||
if tag := e.event.Tags.Find("image"); tag != nil {
|
||||
addImage(tag[1])
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EntityDir) IsNew() bool {
|
||||
return e.event.CreatedAt == 0
|
||||
}
|
||||
|
||||
func (e *EntityDir) PublishedAt() uint64 {
|
||||
if tag := e.event.Tags.Find("published_at"); tag != nil {
|
||||
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
|
||||
return publishedAt
|
||||
}
|
||||
return uint64(e.event.CreatedAt)
|
||||
}
|
||||
|
||||
func (e *EntityDir) Title() string {
|
||||
if tag := e.event.Tags.Find("title"); tag != nil {
|
||||
return tag[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *EntityDir) handleWrite() {
|
||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
|
||||
|
||||
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
|
||||
if e.publisher.IsRunning() {
|
||||
log(", timer reset")
|
||||
}
|
||||
log(", publishing the ")
|
||||
if e.IsNew() {
|
||||
log("new")
|
||||
} else {
|
||||
log("updated")
|
||||
}
|
||||
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
|
||||
} else {
|
||||
log(".\n")
|
||||
}
|
||||
if !e.publisher.IsRunning() {
|
||||
log("- `touch publish` to publish immediately\n")
|
||||
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
|
||||
}
|
||||
|
||||
e.publisher.Call(func() {
|
||||
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
|
||||
log("not modified, publish canceled.\n")
|
||||
return
|
||||
}
|
||||
|
||||
evt := nostr.Event{
|
||||
Kind: e.event.Kind,
|
||||
Content: e.updating.content,
|
||||
Tags: make(nostr.Tags, len(e.event.Tags)),
|
||||
CreatedAt: nostr.Now(),
|
||||
}
|
||||
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
|
||||
if e.updating.title != "" {
|
||||
if titleTag := evt.Tags.Find("title"); titleTag != nil {
|
||||
titleTag[1] = e.updating.title
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
|
||||
}
|
||||
}
|
||||
|
||||
// "published_at" tag
|
||||
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
|
||||
if publishedAtStr != "0" {
|
||||
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
|
||||
publishedAtTag[1] = publishedAtStr
|
||||
} else {
|
||||
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
|
||||
}
|
||||
}
|
||||
|
||||
// add "p" tags from people mentioned and "q" tags from events mentioned
|
||||
for ref := range nip27.Parse(evt.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
|
||||
tag := ref.Pointer.AsTag()
|
||||
key := tag[0]
|
||||
val := tag[1]
|
||||
if key == "e" || key == "a" {
|
||||
key = "q"
|
||||
}
|
||||
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
||||
evt.Tags = append(evt.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// sign and publish
|
||||
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
|
||||
log("failed to sign: '%s'.\n", err)
|
||||
return
|
||||
}
|
||||
logverbose("%s\n", evt)
|
||||
|
||||
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
|
||||
if len(relays) == 0 {
|
||||
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
|
||||
}
|
||||
|
||||
log("publishing to %d relays... ", len(relays))
|
||||
success := false
|
||||
first := true
|
||||
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||
} else {
|
||||
success = true
|
||||
log("%s: ok", color.GreenString(cleanUrl))
|
||||
}
|
||||
}
|
||||
log("\n")
|
||||
|
||||
if success {
|
||||
e.event = &evt
|
||||
log("event updated locally.\n")
|
||||
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
|
||||
} else {
|
||||
log("failed.\n")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (r *NostrRoot) FetchAndCreateEntityDir(
|
||||
parent fs.InodeEmbedder,
|
||||
extension string,
|
||||
pointer nostr.EntityPointer,
|
||||
) (*fs.Inode, error) {
|
||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
|
||||
return r.CreateEntityDir(parent, event), nil
|
||||
}
|
||||
|
||||
func (r *NostrRoot) CreateEntityDir(
|
||||
parent fs.InodeEmbedder,
|
||||
event *nostr.Event,
|
||||
) *fs.Inode {
|
||||
return parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
)
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip10"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"fiatjaf.com/nostr/nip22"
|
||||
"fiatjaf.com/nostr/nip27"
|
||||
"fiatjaf.com/nostr/nip73"
|
||||
"fiatjaf.com/nostr/nip92"
|
||||
sdk "fiatjaf.com/nostr/sdk"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type EventDir struct {
|
||||
fs.Inode
|
||||
ctx context.Context
|
||||
wd string
|
||||
evt *nostr.Event
|
||||
}
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
|
||||
|
||||
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
out.Mtime = uint64(e.evt.CreatedAt)
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (r *NostrRoot) FetchAndCreateEventDir(
|
||||
parent fs.InodeEmbedder,
|
||||
pointer nostr.EventPointer,
|
||||
) (*fs.Inode, error) {
|
||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||
WithRelays: false,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||
}
|
||||
|
||||
return r.CreateEventDir(parent, event), nil
|
||||
}
|
||||
|
||||
func (r *NostrRoot) CreateEventDir(
|
||||
parent fs.InodeEmbedder,
|
||||
event *nostr.Event,
|
||||
) *fs.Inode {
|
||||
h := parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
|
||||
)
|
||||
|
||||
h.AddChild("@author", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
|
||||
eventj, _ := json.MarshalIndent(event, "", " ")
|
||||
h.AddChild("event.json", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: eventj,
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(len(event.Content)),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
h.AddChild("id", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(event.ID.Hex()),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(64),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
h.AddChild("content.txt", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(event.Content),
|
||||
Attr: fuse.Attr{
|
||||
Mode: 0444,
|
||||
Ctime: uint64(event.CreatedAt),
|
||||
Mtime: uint64(event.CreatedAt),
|
||||
Size: uint64(len(event.Content)),
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
var refsdir *fs.Inode
|
||||
i := 0
|
||||
for ref := range nip27.Parse(event.Content) {
|
||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
|
||||
if refsdir == nil {
|
||||
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
h.AddChild("references", refsdir, true)
|
||||
}
|
||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
|
||||
var imagesdir *fs.Inode
|
||||
images := nip92.ParseTags(event.Tags)
|
||||
for _, imeta := range images {
|
||||
if imeta.URL == "" {
|
||||
continue
|
||||
}
|
||||
if imagesdir == nil {
|
||||
in := &fs.Inode{}
|
||||
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||
h.AddChild("images", imagesdir, true)
|
||||
}
|
||||
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
|
||||
r.ctx,
|
||||
&AsyncFile{
|
||||
ctx: r.ctx,
|
||||
load: func() ([]byte, nostr.Timestamp) {
|
||||
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, 0
|
||||
}
|
||||
w := &bytes.Buffer{}
|
||||
io.Copy(w, resp.Body)
|
||||
return w.Bytes(), 0
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
|
||||
if event.Kind == 1 {
|
||||
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@root", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
|
||||
nevent := nip19.EncodePointer(pointer)
|
||||
h.AddChild("@parent", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemSymlink{
|
||||
Data: []byte(r.wd + "/" + nevent),
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||
), true)
|
||||
}
|
||||
} else if event.Kind == 1111 {
|
||||
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
|
||||
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||
h.AddChild("@root", h.NewPersistentInode(
|
||||
r.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||
},
|
||||
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(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
||||
},
|
||||
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
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"fiatjaf.com/nostr"
|
||||
"fiatjaf.com/nostr/nip19"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
"github.com/liamg/magic"
|
||||
)
|
||||
|
||||
type NpubDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
pointer nostr.ProfilePointer
|
||||
fetched atomic.Bool
|
||||
}
|
||||
|
||||
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
|
||||
|
||||
func (r *NostrRoot) CreateNpubDir(
|
||||
parent fs.InodeEmbedder,
|
||||
pointer nostr.ProfilePointer,
|
||||
signer nostr.Signer,
|
||||
) *fs.Inode {
|
||||
npubdir := &NpubDir{root: r, pointer: pointer}
|
||||
return parent.EmbeddedInode().NewPersistentInode(
|
||||
r.ctx,
|
||||
npubdir,
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
|
||||
)
|
||||
}
|
||||
|
||||
func (h *NpubDir) OnAdd(_ context.Context) {
|
||||
log := h.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
|
||||
log("- adding folder for %s with relays %s\n",
|
||||
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
|
||||
|
||||
h.AddChild("pubkey", h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
|
||||
go func() {
|
||||
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
|
||||
if pm.Event == nil {
|
||||
return
|
||||
}
|
||||
|
||||
metadataj, _ := json.MarshalIndent(pm, "", " ")
|
||||
h.AddChild(
|
||||
"metadata.json",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: metadataj,
|
||||
Attr: fuse.Attr{
|
||||
Mtime: uint64(pm.Event.CreatedAt),
|
||||
Mode: 0444,
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
),
|
||||
true,
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
|
||||
if err == nil {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 300 {
|
||||
b := &bytes.Buffer{}
|
||||
io.Copy(b, resp.Body)
|
||||
|
||||
ext := "png"
|
||||
if ft, err := magic.Lookup(b.Bytes()); err == nil {
|
||||
ext = ft.Extension
|
||||
}
|
||||
|
||||
h.AddChild("picture."+ext, h.NewPersistentInode(
|
||||
ctx,
|
||||
&fs.MemRegularFile{
|
||||
Data: b.Bytes(),
|
||||
Attr: fuse.Attr{
|
||||
Mtime: uint64(pm.Event.CreatedAt),
|
||||
Mode: 0444,
|
||||
},
|
||||
},
|
||||
fs.StableAttr{},
|
||||
), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if h.GetChild("notes") == nil {
|
||||
h.AddChild(
|
||||
"notes",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{1},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("comments") == nil {
|
||||
h.AddChild(
|
||||
"comments",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{1111},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("photos") == nil {
|
||||
h.AddChild(
|
||||
"photos",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{20},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: true,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("videos") == nil {
|
||||
h.AddChild(
|
||||
"videos",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{21, 22},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("highlights") == nil {
|
||||
h.AddChild(
|
||||
"highlights",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{9802},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: false,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("articles") == nil {
|
||||
h.AddChild(
|
||||
"articles",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{30023},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: true,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if h.GetChild("wiki") == nil {
|
||||
h.AddChild(
|
||||
"wiki",
|
||||
h.NewPersistentInode(
|
||||
h.root.ctx,
|
||||
&ViewDir{
|
||||
root: h.root,
|
||||
filter: nostr.Filter{
|
||||
Kinds: []nostr.Kind{30818},
|
||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||
},
|
||||
paginate: false,
|
||||
relays: relays,
|
||||
replaceable: true,
|
||||
createable: true,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
130
nostrfs/root.go
130
nostrfs/root.go
@@ -1,130 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
|
||||
"fiatjaf.com/lib/debouncer"
|
||||
"fiatjaf.com/nostr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type ViewDir struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
fetched atomic.Bool
|
||||
filter nostr.Filter
|
||||
paginate bool
|
||||
relays []string
|
||||
replaceable bool
|
||||
createable bool
|
||||
publisher *debouncer.Debouncer
|
||||
publishing struct {
|
||||
note string
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
|
||||
_ = (fs.NodeCreater)((*ViewDir)(nil))
|
||||
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
|
||||
)
|
||||
|
||||
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Create(
|
||||
_ context.Context,
|
||||
name string,
|
||||
flags uint32,
|
||||
mode uint32,
|
||||
out *fuse.EntryOut,
|
||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return nil, nil, 0, syscall.EPERM
|
||||
}
|
||||
if n.publisher == nil {
|
||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||
}
|
||||
if n.filter.Kinds[0] != 1 {
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "new":
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
if n.publisher.IsRunning() {
|
||||
log("pending note updated, timer reset.")
|
||||
} else {
|
||||
log("new note detected")
|
||||
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
|
||||
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
|
||||
} else {
|
||||
log(".\n")
|
||||
}
|
||||
log("- `touch publish` to publish immediately\n")
|
||||
log("- `rm new` to erase and cancel the publication.\n")
|
||||
}
|
||||
|
||||
n.publisher.Call(n.publishNote)
|
||||
|
||||
first := true
|
||||
|
||||
return n.NewPersistentInode(
|
||||
n.root.ctx,
|
||||
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
|
||||
if !first {
|
||||
log("pending note updated, timer reset.\n")
|
||||
}
|
||||
first = false
|
||||
n.publishing.note = strings.TrimSpace(s)
|
||||
n.publisher.Call(n.publishNote)
|
||||
}),
|
||||
fs.StableAttr{},
|
||||
), nil, 0, fs.OK
|
||||
case "publish":
|
||||
if n.publisher.IsRunning() {
|
||||
// this causes the publish process to be triggered faster
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing now!\n")
|
||||
n.publisher.Flush()
|
||||
return nil, nil, 0, syscall.ENOTDIR
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, 0, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return syscall.EPERM
|
||||
}
|
||||
if n.publisher == nil {
|
||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||
}
|
||||
if n.filter.Kinds[0] != 1 {
|
||||
return syscall.ENOTSUP
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "new":
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
log("publishing canceled.\n")
|
||||
n.publisher.Stop()
|
||||
n.publishing.note = ""
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
return syscall.ENOTSUP
|
||||
}
|
||||
|
||||
func (n *ViewDir) publishNote() {
|
||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||
|
||||
log("publishing note...\n")
|
||||
evt := nostr.Event{
|
||||
Kind: 1,
|
||||
CreatedAt: nostr.Now(),
|
||||
Content: n.publishing.note,
|
||||
Tags: make(nostr.Tags, 0, 2),
|
||||
}
|
||||
|
||||
// our write relays
|
||||
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
|
||||
if len(relays) == 0 {
|
||||
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
|
||||
}
|
||||
|
||||
// massage and extract tags from raw text
|
||||
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
|
||||
relays = nostr.AppendUnique(relays, targetRelays...)
|
||||
|
||||
// sign and publish
|
||||
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
|
||||
log("failed to sign: %s\n", err)
|
||||
return
|
||||
}
|
||||
log(evt.String() + "\n")
|
||||
|
||||
log("publishing to %d relays... ", len(relays))
|
||||
success := false
|
||||
first := true
|
||||
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
|
||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||
if !first {
|
||||
log(", ")
|
||||
}
|
||||
first = false
|
||||
|
||||
if res.Error != nil {
|
||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||
} else {
|
||||
success = true
|
||||
log("%s: ok", color.GreenString(cleanUrl))
|
||||
}
|
||||
}
|
||||
log("\n")
|
||||
|
||||
if success {
|
||||
n.RmChild("new")
|
||||
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
|
||||
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
|
||||
}
|
||||
}
|
||||
|
||||
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
now := nostr.Now()
|
||||
if n.filter.Until != 0 {
|
||||
now = n.filter.Until
|
||||
}
|
||||
aMonthAgo := now - 30*24*60*60
|
||||
out.Mtime = uint64(aMonthAgo)
|
||||
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
|
||||
if n.fetched.CompareAndSwap(true, true) {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
if n.paginate {
|
||||
now := nostr.Now()
|
||||
if n.filter.Until != 0 {
|
||||
now = n.filter.Until
|
||||
}
|
||||
aMonthAgo := now - 30*24*60*60
|
||||
n.filter.Since = aMonthAgo
|
||||
|
||||
filter := n.filter
|
||||
filter.Until = aMonthAgo
|
||||
|
||||
n.AddChild("@previous", n.NewPersistentInode(
|
||||
n.root.ctx,
|
||||
&ViewDir{
|
||||
root: n.root,
|
||||
filter: filter,
|
||||
relays: n.relays,
|
||||
replaceable: n.replaceable,
|
||||
},
|
||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||
), true)
|
||||
}
|
||||
|
||||
if n.replaceable {
|
||||
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
|
||||
Label: "nakfs",
|
||||
}).Range {
|
||||
name := rkey.D
|
||||
if name == "" {
|
||||
name = "_"
|
||||
}
|
||||
if n.GetChild(name) == nil {
|
||||
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
|
||||
nostr.SubscriptionOptions{
|
||||
Label: "nakfs",
|
||||
}) {
|
||||
if n.GetChild(ie.Event.ID.Hex()) == nil {
|
||||
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
|
||||
return nil, syscall.ENOTSUP
|
||||
}
|
||||
|
||||
if n.replaceable {
|
||||
// create a template event that can later be modified and published as new
|
||||
return n.root.CreateEntityDir(n, &nostr.Event{
|
||||
PubKey: n.root.rootPubKey,
|
||||
CreatedAt: 0,
|
||||
Kind: n.filter.Kinds[0],
|
||||
Tags: nostr.Tags{
|
||||
nostr.Tag{"d", name},
|
||||
},
|
||||
}), fs.OK
|
||||
}
|
||||
|
||||
return nil, syscall.ENOTSUP
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package nostrfs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
type WriteableFile struct {
|
||||
fs.Inode
|
||||
root *NostrRoot
|
||||
mu sync.Mutex
|
||||
data []byte
|
||||
attr fuse.Attr
|
||||
onWrite func(string)
|
||||
}
|
||||
|
||||
var (
|
||||
_ = (fs.NodeOpener)((*WriteableFile)(nil))
|
||||
_ = (fs.NodeReader)((*WriteableFile)(nil))
|
||||
_ = (fs.NodeWriter)((*WriteableFile)(nil))
|
||||
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
|
||||
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
|
||||
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
|
||||
)
|
||||
|
||||
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
|
||||
return &WriteableFile{
|
||||
root: r,
|
||||
data: []byte(data),
|
||||
attr: fuse.Attr{
|
||||
Mode: 0666,
|
||||
Ctime: ctime,
|
||||
Mtime: mtime,
|
||||
Size: uint64(len(data)),
|
||||
},
|
||||
onWrite: onWrite,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
offset := int(off)
|
||||
end := offset + len(data)
|
||||
if len(f.data) < end {
|
||||
newData := make([]byte, offset+len(data))
|
||||
copy(newData, f.data)
|
||||
f.data = newData
|
||||
}
|
||||
copy(f.data[offset:], data)
|
||||
f.data = f.data[0:end]
|
||||
|
||||
f.onWrite(string(f.data))
|
||||
return uint32(len(data)), fs.OK
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
out.Attr = f.attr
|
||||
out.Attr.Size = uint64(len(f.data))
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||
f.attr.Mtime = in.Mtime
|
||||
f.attr.Atime = in.Atime
|
||||
f.attr.Ctime = in.Ctime
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
||||
return fs.OK
|
||||
}
|
||||
|
||||
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
end := int(off) + len(dest)
|
||||
if end > len(f.data) {
|
||||
end = len(f.data)
|
||||
}
|
||||
return fuse.ReadResultData(f.data[off:end]), fs.OK
|
||||
}
|
||||
Reference in New Issue
Block a user