Compare commits

..

9 Commits

Author SHA1 Message Date
mattn
ef83b48ca0 Merge pull request #3 from mattn/copilot/fix-cgo-enabled-windows-builds
[WIP] Fix CGO_ENABLED setting for Windows builds
2026-01-17 16:29:19 +09:00
copilot-swe-agent[bot]
766598eee6 Set CGO_ENABLED=0 for Windows builds to fix cross-compilation
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:28:30 +00:00
copilot-swe-agent[bot]
413b5cf161 Initial plan 2026-01-17 07:26:27 +00:00
mattn
c2969ba503 Merge pull request #2 from mattn/copilot/fix-fuse-installation-issue
Switch to softprops/action-gh-release and add FUSE dependencies
2026-01-17 16:18:43 +09:00
copilot-swe-agent[bot]
235e16d34b Switch build-all-for-all to softprops/action-gh-release
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:15:33 +00:00
copilot-swe-agent[bot]
e44dd08527 Add FUSE dependencies installation to build-all-for-all job
Co-authored-by: mattn <10111+mattn@users.noreply.github.com>
2026-01-17 07:02:39 +00:00
copilot-swe-agent[bot]
d856f54394 Initial plan 2026-01-17 07:00:46 +00:00
mattn
d015e979aa Merge pull request #1 from mattn/fix-workflows
Fix workflows
2026-01-17 15:43:19 +09:00
Yasuhiro Matsumoto
120a92920e switch to softprops/action-gh-release 2026-01-17 15:41:36 +09:00
29 changed files with 227 additions and 2969 deletions

View File

@@ -9,44 +9,64 @@ permissions:
contents: write
jobs:
make-release:
runs-on: ubuntu-latest
steps:
- uses: actions/create-release@latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
build-all-for-all:
runs-on: ubuntu-latest
needs:
- make-release
strategy:
matrix:
goos: [linux, freebsd, darwin, windows]
goos: [linux, freebsd, windows]
goarch: [amd64, arm64, riscv64]
exclude:
- goarch: arm64
goos: windows
- goarch: riscv64
goos: windows
- goarch: riscv64
goos: darwin
- goarch: arm64
goos: freebsd
steps:
- uses: actions/checkout@v3
- uses: wangyoucao577/go-release-action@v1.40
- name: Set up Go
uses: actions/setup-go@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
ldflags: -X main.version=${{ github.ref_name }}
overwrite: true
md5sum: false
sha256sum: false
compress_assets: false
go-version: 'stable'
- name: Install FUSE dependencies
run: |
sudo apt-get update
sudo apt-get install -y libfuse-dev
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: ${{ matrix.goos == 'windows' && '0' || '1' }}
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
build-darwin:
runs-on: macos-latest
strategy:
matrix:
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: 'stable'
- name: Install macFUSE
run: brew install --cask macfuse
- name: Build binary
env:
GOOS: darwin
GOARCH: ${{ matrix.goarch }}
run: |
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
- name: Upload Release Asset
uses: softprops/action-gh-release@v1
with:
files: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
smoke-test-linux-amd64:
runs-on: ubuntu-latest
@@ -117,7 +137,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 < /dev/null)
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol)
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
echo "published event ID: $EVENT_ID"

View File

@@ -1,19 +1,9 @@
# nak, the nostr army knife
install with this one-liner:
install with `go install github.com/fiatjaf/nak@latest` or
[download a binary](https://github.com/fiatjaf/nak/releases).
```sh
curl -sSL https://raw.githubusercontent.com/fiatjaf/nak/master/install.sh | sh
```
- or install with `go install github.com/fiatjaf/nak@latest` if you have **Go** set up.
- or [download a binary](https://github.com/fiatjaf/nak/releases) manually.
- or get the source with `git clone https://github.com/fiatjaf/nak` then
- install with `go install`;
- or run with docker using `docker build -t nak . && docker run nak event`.
- or install with `brew install nak` if you use **macOS Homebrew**.
- or install with `paru -S nak-bin` or `yay -S nak-bin` if you are on **Arch Linux**.
- or install with `nix-env --install nak` if you use **Nix**.
or get the source with `git clone https://github.com/fiatjaf/nak` then install with `go install` or run with docker using `docker build -t nak . && docker run nak event`.
## what can you do with it?
@@ -44,7 +34,7 @@ publishing to wss://relay.damus.io... success.
"Activando modo zen…\n\n#GM #Nostr #Hispano"
```
### decode a NIP-19 note1 code, add a relay hint, encode it back to nevent1
### decode a nip19 note1 code, add a relay hint, encode it back to nevent1
```shell
~> nak decode note1ttnnrw78wy0hs5fa59yj03yvcu2r4y0xetg9vh7uf4em39n604vsyp37f2 | jq -r .id | nak encode nevent -r nostr.zbd.gg
nevent1qqs94ee3h0rhz8mc2y76zjf8cjxvw9p6j8nv45zktlwy6uacjea86kgpzfmhxue69uhkummnw3ezu7nzvshxwec8zw8h7
@@ -210,18 +200,6 @@ or give it a named profile:
~> nak bunker --profile myself ...
```
### send a `nostrconnect://` client URI to a running bunker
```shell
~> nak bunker connect 'nostrconnect://...'
```
or, if you're using a persisted profile
```shell
~> nak bunker connect --profile default 'nostrconnect://...'
```
### generate a NIP-70 protected event with a date set to two weeks ago and some multi-value tags
```shell
~> nak event --ts 'two weeks ago' -t '-' -t 'e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.whatever.com;root;a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208' -t 'p=a9e0f110f636f3191644110c19a33448daf09d7cda9708a769e91b7e91340208;wss://p-relay.com' -c 'I know the future'
@@ -249,7 +227,7 @@ or, if you're using a persisted profile
• events stored: 4, subscriptions opened: 1
```
### enable negentropy (NIP-77) support in your development relay
### enable negentropy (nip77) support in your development relay
```shell
~> nak serve --negentropy
```
@@ -449,7 +427,6 @@ gitnostr.com... ok.
```shell
~> nak git clone
~> nak git init
~> nak git status
~> nak git sync
~> nak git fetch
~> nak git pull
@@ -464,11 +441,3 @@ gitnostr.com... ok.
1a851afaa70a26faa82c5b4422ce967c07e278efc56a1413b9719b662f86551a
8031621a54b2502f5bd4dbb87c971c0a69675d252a64d69e22224f3aee6dd2b2
```
### interact with a NIP-29 group
```shell
~> nak group info "<relay>'<id>"
~> nak group admin "<relay>'<id>"
~> nak group chat "<relay>'<id>"
~> nak group chat send "<relay>'<id>" "<message>"
```

View File

@@ -229,56 +229,12 @@ if any of the files are not found the command will fail, otherwise it will succe
},
},
{
Name: "mirror",
Usage: "mirrors a from a server to another",
Description: `examples:
mirroring a single blob:
nak blossom mirror https://nostr.download/5672be22e6da91c12b929a0f46b9e74de8b5366b9b19a645ff949c24052f9ad4 -s blossom.band
mirroring all blobs from a certain pubkey from one server to the other:
nak blossom list 78ce6faa72264387284e647ba6938995735ec8c7d5c5a65737e55130f026307d -s nostr.download | nak blossom mirror -s blossom.band`,
Name: "mirror",
Usage: "",
Description: ``,
DisableSliceFlagSeparator: true,
ArgsUsage: "",
Action: func(ctx context.Context, c *cli.Command) error {
client, err := getBlossomClient(ctx, c)
if err != nil {
return err
}
var bd blossom.BlobDescriptor
if input := c.Args().First(); input != "" {
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
return err
}
out, _ := json.Marshal(bd)
stdout(out)
return nil
} else {
for input := range getJsonsOrBlank() {
if input == "{}" {
continue
}
blobURL := input
if err := json.Unmarshal([]byte(input), &bd); err == nil {
blobURL = bd.URL
}
bd, err := client.MirrorBlob(ctx, blobURL)
if err != nil {
ctx = lineProcessingError(ctx, "failed to mirror '%s': %w", blobURL, err)
continue
}
out, _ := json.Marshal(bd)
stdout(out)
}
exitIfLineProcessingError(ctx)
}
return nil
},
},

299
bunker.go
View File

@@ -4,14 +4,13 @@ import (
"bytes"
"context"
"encoding/hex"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
@@ -74,7 +73,13 @@ var bunker = &cli.Command{
},
Action: func(ctx context.Context, c *cli.Command) error {
// read config from file
config := BunkerConfig{}
config := struct {
AuthorizedKeys []nostr.PubKey `json:"authorized-keys"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
}{
AuthorizedKeys: make([]nostr.PubKey, 0, 3),
}
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
for i, url := range baseRelaysUrls {
baseRelaysUrls[i] = nostr.NormalizeURL(url)
@@ -137,15 +142,6 @@ var bunker = &cli.Command{
if err := json.Unmarshal(b, &config); err != nil {
return err
}
// convert from deprecated field
if len(config.AuthorizedKeys) > 0 {
config.Clients = make([]BunkerConfigClient, len(config.AuthorizedKeys))
for i := range config.AuthorizedKeys {
config.Clients[i] = BunkerConfigClient{PubKey: config.AuthorizedKeys[i]}
}
config.AuthorizedKeys = nil
persist()
}
} else if !os.IsNotExist(err) {
return err
}
@@ -154,11 +150,7 @@ var bunker = &cli.Command{
config.Relays[i] = nostr.NormalizeURL(url)
}
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
for _, bak := range baseAuthorizedKeys {
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool { return c.PubKey == bak }) {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
}
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
// we don't have any secret key stored, so just use whatever was given via flags
@@ -175,9 +167,7 @@ var bunker = &cli.Command{
} else {
config.Secret = baseSecret
config.Relays = baseRelaysUrls
for _, bak := range baseAuthorizedKeys {
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
}
config.AuthorizedKeys = baseAuthorizedKeys
}
// if we got here without any keys set (no flags, first time using a profile), use the default
@@ -215,17 +205,8 @@ var bunker = &cli.Command{
// try to connect to the relays here
qs := url.Values{}
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
copy(allRelays, config.Relays)
for _, c := range config.Clients {
for _, url := range c.CustomRelays {
if !slices.ContainsFunc(allRelays, func(u string) bool { return u == url }) {
allRelays = append(allRelays, url)
}
}
}
relayURLs := make([]string, 0, len(allRelays))
relays := connectToAllRelays(ctx, c, allRelays, nil, nostr.PoolOptions{})
relayURLs := make([]string, 0, len(config.Relays))
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
if len(relays) == 0 {
log("failed to connect to any of the given relays.\n")
os.Exit(3)
@@ -255,22 +236,10 @@ var bunker = &cli.Command{
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
authorizedKeysStr := ""
if len(config.Clients) != 0 {
authorizedKeysStr = "\n authorized clients:"
for _, c := range config.Clients {
authorizedKeysStr += "\n - " + colors.italic(c.PubKey.Hex())
name := ""
if c.Name != "" {
name = c.Name
if c.URL != "" {
name += " " + colors.underline(c.URL)
}
} else if c.URL != "" {
name = colors.underline(c.URL)
}
if name != "" {
authorizedKeysStr += " (" + name + ")"
}
if len(config.AuthorizedKeys) != 0 {
authorizedKeysStr = "\n authorized keys:"
for _, pubkey := range config.AuthorizedKeys {
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
}
}
@@ -280,8 +249,8 @@ var bunker = &cli.Command{
}
preauthorizedFlags := ""
for _, c := range config.Clients {
preauthorizedFlags += " -k " + c.PubKey.Hex()
for _, k := range config.AuthorizedKeys {
preauthorizedFlags += " -k " + k.Hex()
}
for _, s := range authorizedSecrets {
preauthorizedFlags += " -s " + s
@@ -345,85 +314,28 @@ var bunker = &cli.Command{
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
}, nostr.SubscriptionOptions{
Label: "nak-bunker",
})
signer := nip46.NewStaticKeySigner(sec)
signer.DefaultRelays = config.Relays
// unix socket nostrconnect:// handling
go func() {
for uri := range onSocketConnect(ctx, c) {
clientPublicKey, err := nostr.PubKeyFromHex(uri.Host)
if err != nil {
continue
}
log("- got nostrconnect:// request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(clientPublicKey.Hex()), uri.String())
relays := uri.Query()["relay"]
// pre-authorize this client since the user has explicitly added it
if !slices.ContainsFunc(config.Clients, func(c BunkerConfigClient) bool {
return c.PubKey == clientPublicKey
}) {
config.Clients = append(config.Clients, BunkerConfigClient{
PubKey: clientPublicKey,
Name: uri.Query().Get("name"),
URL: uri.Query().Get("url"),
Icon: uri.Query().Get("icon"),
CustomRelays: relays,
})
}
if persist != nil {
persist()
}
resp, eventResponse, err := signer.HandleNostrConnectURI(ctx, uri)
if err != nil {
log("* failed to handle: %s\n", err)
continue
}
go func() {
for event := range sys.Pool.SubscribeMany(ctx, relays, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindNostrConnect},
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
Since: nostr.Now(),
LimitZero: true,
}, nostr.SubscriptionOptions{Label: "nak-bunker"}) {
events <- event
}
}()
time.Sleep(time.Millisecond * 25)
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent through %s\n", res.Relay.URL)
} else {
log("* failed to send through %s: %s\n", res.RelayURL, res.Error)
}
}
}
}()
handlerWg := sync.WaitGroup{}
printLock := sync.Mutex{}
// just a gimmick
var cancelPreviousBunkerInfoPrint context.CancelFunc
_, cancel := context.WithCancel(ctx)
cancelPreviousBunkerInfoPrint = cancel
// asking user for authorization
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
return true
}
if slices.Contains(authorizedSecrets, secret) {
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
return true
}
if secret == newSecret {
// store this key
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
// discard this and generate a new secret
newSecret = randString(12)
// print bunker info again after this
@@ -446,39 +358,34 @@ var bunker = &cli.Command{
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
// handle the NIP-46 request event
from := ie.Event.PubKey
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
if err != nil {
if errors.Is(err, nip46.AlreadyHandled) {
continue
}
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
continue
}
jreq, _ := json.MarshalIndent(req, "", " ")
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
jresp, _ := json.MarshalIndent(resp, "", " ")
log("~ responding with %s\n", string(jresp))
// use custom relays if they are defined for this client
// (normally if the initial connection came from a nostrconnect:// URL)
relays := relayURLs
for _, c := range config.Clients {
if c.PubKey == from && len(c.CustomRelays) > 0 {
relays = c.CustomRelays
break
}
}
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
if res.Error == nil {
log("* sent response through %s\n", res.Relay.URL)
} else {
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
}
handlerWg.Add(len(relayURLs))
for _, relayURL := range relayURLs {
go func(relayURL string) {
defer handlerWg.Done()
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
err := relay.Publish(ctx, eventResponse)
printLock.Lock()
if err == nil {
log("* sent response through %s\n", relay.URL)
} else {
log("* failed to send response: %s\n", err)
}
printLock.Unlock()
}
}(relayURL)
}
handlerWg.Wait()
// just after handling one request we trigger this
go func() {
@@ -503,44 +410,24 @@ var bunker = &cli.Command{
Name: "connect",
Usage: "use the client-initiated NostrConnect flow of NIP46",
ArgsUsage: "<nostrconnect-uri>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "profile",
Usage: "profile name of the bunker to connect to",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
if c.Args().Len() != 1 {
return fmt.Errorf("must be called with a nostrconnect://... uri")
}
if err := sendToSocket(c, c.Args().First()); err != nil {
return fmt.Errorf("failed to connect to running bunker: %w", err)
uri, err := url.Parse(c.Args().First())
if err != nil || uri.Scheme != "nostrconnect" {
return fmt.Errorf("invalid uri")
}
return nil
// TODO
return fmt.Errorf("this is not implemented yet")
},
},
},
}
type BunkerConfig struct {
Clients []BunkerConfigClient `json:"clients"`
Secret plainOrEncryptedKey `json:"sec"`
Relays []string `json:"relays"`
// deprecated
AuthorizedKeys []nostr.PubKey `json:"authorized-keys,omitempty"`
}
type BunkerConfigClient struct {
PubKey nostr.PubKey `json:"pubkey"`
Name string `json:"name,omitempty"`
URL string `json:"url,omitempty"`
Icon string `json:"icon,omitempty"`
CustomRelays []string `json:"custom_relays,omitempty"`
}
type plainOrEncryptedKey struct {
Plain *nostr.SecretKey
Encrypted *string
@@ -608,89 +495,3 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
return true
}
func getSocketPath(c *cli.Command) string {
profile := "default"
if c.IsSet("profile") {
profile = c.String("profile")
}
return filepath.Join(c.String("config-path"), "bunkerconn", profile)
}
func onSocketConnect(ctx context.Context, c *cli.Command) chan *url.URL {
res := make(chan *url.URL)
socketPath := getSocketPath(c)
// ensure directory exists
if err := os.MkdirAll(filepath.Dir(socketPath), 0755); err != nil {
log(color.RedString("failed to create socket directory: %w\n", err))
return res
}
// delete existing socket file if it exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
log(color.RedString("failed to remove existing socket file: %w\n", err))
return res
}
}
listener, err := net.Listen("unix", socketPath)
if err != nil {
log(color.RedString("failed to listen on unix socket %s: %w\n", socketPath, err))
return res
}
go func() {
defer listener.Close()
defer os.Remove(socketPath) // cleanup socket file on exit
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return
default:
continue
}
}
go func(conn net.Conn) {
defer conn.Close()
buf := make([]byte, 4096)
for {
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
n, err := conn.Read(buf)
if err != nil {
break
}
uri, err := url.Parse(string(buf[:n]))
if err == nil && uri.Scheme == "nostrconnect" {
res <- uri
}
}
}(conn)
}
}()
return res
}
func sendToSocket(c *cli.Command, value string) error {
socketPath := getSocketPath(c)
conn, err := net.DialTimeout("unix", socketPath, 5*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to bunker unix socket at %s: %w", socketPath, err)
}
defer conn.Close()
_, err = conn.Write([]byte(value))
if err != nil {
return fmt.Errorf("failed to send uri to bunker: %w", err)
}
return nil
}

View File

@@ -86,7 +86,7 @@ var decode = &cli.Command{
continue
}
ctx = lineProcessingError(ctx, "couldn't decode input '%s': %s", input, err)
ctx = lineProcessingError(ctx, "couldn't decode input '%s'", input)
}
exitIfLineProcessingError(ctx)

View File

@@ -145,7 +145,7 @@ example:
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
relays = connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},

52
fs.go
View File

@@ -1,4 +1,4 @@
//go:build !windows && !openbsd && !cgofuse
//go:build !windows && !openbsd
package main
@@ -13,10 +13,8 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
"github.com/fiatjaf/nak/nostrfs"
"github.com/hanwen/go-fuse/v2/fs"
"github.com/hanwen/go-fuse/v2/fuse"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
var fsCmd = &cli.Command{
@@ -64,7 +62,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
root := NewFSRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -75,7 +73,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
nostrfs.Options{
FSOptions{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},
@@ -83,21 +81,22 @@ var fsCmd = &cli.Command{
// create the server
log("- mounting at %s... ", color.HiCyanString(mountpoint))
timeout := time.Second * 120
server, err := fs.Mount(mountpoint, root, &fs.Options{
MountOptions: fuse.MountOptions{
Debug: isVerbose,
Name: "nak",
FsName: "nak",
RememberInodes: true,
},
AttrTimeout: &timeout,
EntryTimeout: &timeout,
Logger: nostr.DebugLogger,
})
if err != nil {
return fmt.Errorf("mount failed: %w", err)
// create cgofuse host
host := fuse.NewFileSystemHost(root)
host.SetCapReaddirPlus(true)
host.SetUseIno(true)
// mount the filesystem
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
@@ -107,17 +106,12 @@ var fsCmd = &cli.Command{
go func() {
<-ch
log("- unmounting... ")
err := server.Unmount()
if err != nil {
chErr <- fmt.Errorf("unmount failed: %w", err)
} else {
log("ok\n")
chErr <- nil
}
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
}()
// serve the filesystem until unmounted
server.Wait()
// wait for signals
return <-chErr
},
}

118
fs_cgo.go
View File

@@ -1,118 +0,0 @@
//go:build cgofuse && !windows && !openbsd
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"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 := nostrfs.NewNostrRoot(
context.WithValue(
context.WithValue(
ctx,
"log", log,
),
"logverbose", logverbose,
),
sys,
kr,
mountpoint,
nostrfs.Options{
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
mountArgs := []string{"-s", mountpoint}
if isVerbose {
mountArgs = append([]string{"-d"}, mountArgs...)
}
go func() {
host.Mount("", mountArgs)
}()
log("ok.\n")
// setup signal handling for clean unmount
ch := make(chan os.Signal, 1)
chErr := make(chan error)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-ch
log("- unmounting... ")
// cgofuse doesn't have explicit unmount, it unmounts on process exit
log("ok\n")
chErr <- nil
}()
// wait for signals
return <-chErr
},
}

View File

@@ -1,4 +1,4 @@
package nostrfs
package main
import (
"context"
@@ -17,18 +17,18 @@ import (
"github.com/winfsp/cgofuse/fuse"
)
type Options struct {
type FSOptions struct {
AutoPublishNotesTimeout time.Duration
AutoPublishArticlesTimeout time.Duration
}
type NostrRoot struct {
type FSRoot struct {
fuse.FileSystemBase
ctx context.Context
sys *sdk.System
rootPubKey nostr.PubKey
signer nostr.Signer
opts Options
opts FSOptions
mountpoint string
mu sync.RWMutex
@@ -51,9 +51,9 @@ type FSNode struct {
loaded bool
}
var _ fuse.FileSystemInterface = (*NostrRoot)(nil)
var _ fuse.FileSystemInterface = (*FSRoot)(nil)
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot {
func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
var system *sdk.System
if sys != nil {
system = sys.(*sdk.System)
@@ -71,7 +71,7 @@ func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountp
abs, _ := filepath.Abs(mountpoint)
root := &NostrRoot{
root := &FSRoot{
ctx: ctx,
sys: system,
rootPubKey: pubkey,
@@ -101,7 +101,7 @@ func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountp
return root
}
func (r *NostrRoot) initialize() {
func (r *FSRoot) initialize() {
if r.rootPubKey == nostr.ZeroPK {
return
}
@@ -146,7 +146,7 @@ func (r *NostrRoot) initialize() {
r.nodes["/"].children["@me"] = meNode
}
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil {
return
@@ -175,7 +175,7 @@ func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
if pm.Event == nil || pm.Picture == "" {
return
@@ -256,7 +256,7 @@ func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
}
}
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
defer cancel()
@@ -355,7 +355,7 @@ func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
}
}
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
// use event ID first 8 chars + extension based on kind
ext := kindToExtension(evt.Kind)
@@ -391,14 +391,14 @@ func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
return fmt.Sprintf("%s.%s", idHex, ext)
}
func (r *NostrRoot) getLog() func(string, ...interface{}) {
func (r *FSRoot) getLog() func(string, ...interface{}) {
if log := r.ctx.Value("log"); log != nil {
return log.(func(string, ...interface{}))
}
return func(string, ...interface{}) {}
}
func (r *NostrRoot) getNode(path string) *FSNode {
func (r *FSRoot) getNode(path string) *FSNode {
originalPath := path
// normalize path
@@ -451,7 +451,7 @@ func (r *NostrRoot) getNode(path string) *FSNode {
return node
}
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
node := r.getNode(path)
// if node doesn't exist, try dynamic lookup
@@ -480,7 +480,7 @@ func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
}
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
func (r *NostrRoot) dynamicLookup(path string) bool {
func (r *FSRoot) dynamicLookup(path string) bool {
// normalize path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -535,7 +535,7 @@ func (r *NostrRoot) dynamicLookup(path string) bool {
}
}
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
dirPath := "/" + npub
// check if already exists
@@ -628,7 +628,7 @@ func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer
go r.fetchProfilePicture(dirPath, pubkey)
}
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
dirPath := parentPath + "/" + name
// check if already exists
@@ -656,7 +656,7 @@ func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Fi
go r.fetchEvents(dirPath, filter)
}
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
dirPath := "/" + name
// fetch the event
@@ -737,7 +737,7 @@ func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer
return true
}
func (r *NostrRoot) Readdir(path string,
func (r *FSRoot) Readdir(path string,
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
ofst int64,
fh uint64,
@@ -768,7 +768,7 @@ func (r *NostrRoot) Readdir(path string,
return 0
}
func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
func (r *FSRoot) Open(path string, flags int) (int, uint64) {
// log the open attempt
if r.ctx.Value("logverbose") != nil {
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
@@ -799,7 +799,7 @@ func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
return 0, node.ino
}
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil || node.isDir {
return -fuse.ENOENT
@@ -818,7 +818,7 @@ func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *NostrRoot) Opendir(path string) (int, uint64) {
func (r *FSRoot) Opendir(path string) (int, uint64) {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT, ^uint64(0)
@@ -829,16 +829,16 @@ func (r *NostrRoot) Opendir(path string) (int, uint64) {
return 0, node.ino
}
func (r *NostrRoot) Release(path string, fh uint64) int {
func (r *FSRoot) Release(path string, fh uint64) int {
return 0
}
func (r *NostrRoot) Releasedir(path string, fh uint64) int {
func (r *FSRoot) Releasedir(path string, fh uint64) int {
return 0
}
// Create creates a new file
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
// parse path
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
@@ -882,7 +882,7 @@ func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
}
// Truncate truncates a file
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -911,7 +911,7 @@ func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
}
// Write writes data to a file
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT
@@ -955,7 +955,7 @@ func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
return n
}
func (r *NostrRoot) publishNote(path string) {
func (r *FSRoot) publishNote(path string) {
r.mu.Lock()
node, ok := r.nodes[path]
if !ok {
@@ -1032,7 +1032,7 @@ func (r *NostrRoot) publishNote(path string) {
}
// Unlink deletes a file
func (r *NostrRoot) Unlink(path string) int {
func (r *FSRoot) Unlink(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1065,7 +1065,7 @@ func (r *NostrRoot) Unlink(path string) int {
}
// Mkdir creates a new directory
func (r *NostrRoot) Mkdir(path string, mode uint32) int {
func (r *FSRoot) Mkdir(path string, mode uint32) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1107,7 +1107,7 @@ func (r *NostrRoot) Mkdir(path string, mode uint32) int {
}
// Rmdir removes a directory
func (r *NostrRoot) Rmdir(path string) int {
func (r *FSRoot) Rmdir(path string) int {
path = strings.ReplaceAll(path, "\\", "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
@@ -1149,7 +1149,7 @@ func (r *NostrRoot) Rmdir(path string) int {
}
// Utimens updates file timestamps
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
node := r.getNode(path)
if node == nil {
return -fuse.ENOENT

View File

@@ -12,7 +12,6 @@ import (
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/keyer"
"github.com/fatih/color"
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
"github.com/urfave/cli/v3"
"github.com/winfsp/cgofuse/fuse"
)
@@ -62,7 +61,7 @@ var fsCmd = &cli.Command{
apat = time.Hour * 24 * 365 * 3
}
root := nostrfs.NewNostrRoot(
root := NewFSRoot(
context.WithValue(
context.WithValue(
ctx,
@@ -73,7 +72,7 @@ var fsCmd = &cli.Command{
sys,
kr,
mountpoint,
nostrfs.Options{
FSOptions{
AutoPublishNotesTimeout: apnt,
AutoPublishArticlesTimeout: apat,
},

162
git.go
View File

@@ -181,7 +181,7 @@ aside from those, there is also:
var fetchedRepo *nip34.Repository
if existingConfig.Identifier == "" {
log(" searching for existing events... ")
repo, _, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
repo, _, _, err := fetchRepositoryAndState(ctx, owner, identifier, nil)
if err == nil && repo.Event.ID != nostr.ZeroID {
fetchedRepo = &repo
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
@@ -371,7 +371,7 @@ aside from those, there is also:
}
// fetch repository metadata and state
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
if err != nil {
return err
}
@@ -782,98 +782,6 @@ aside from those, there is also:
return err
},
},
{
Name: "status",
Usage: "show repository status and synchronization information",
Action: func(ctx context.Context, c *cli.Command) error {
// read local config
localConfig, err := readNip34ConfigFile("")
if err != nil {
return fmt.Errorf("failed to read nip34.json: %w (run 'nak git init' first)", err)
}
// parse owner
owner, err := parsePubKey(localConfig.Owner)
if err != nil {
return fmt.Errorf("invalid owner public key: %w", err)
}
repo := localConfig.ToRepository()
stdout("\n" + color.CyanString("metadata:"))
stdout(" identifier:", color.CyanString(repo.ID))
stdout(" name:", color.CyanString(repo.Name))
stdout(" owner:", color.CyanString(nip19.EncodeNpub(repo.Event.PubKey)))
stdout(" description:", color.CyanString(repo.Description))
stdout(" web urls:")
for _, url := range repo.Web {
stdout(" ", url)
}
stdout(" earliest unique commit:", color.CyanString(repo.EarliestUniqueCommitID))
// fetch repository announcement and state from relays
_, _, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
if err != nil {
// create a local repo object for display purposes
log("failed to fetch repository announcement from relays: %s\n", err)
}
if state == nil {
stdout(color.YellowString("\n repository state not published."))
}
stateHEAD, _ := state.Branches[state.HEAD]
stdout("\n" + color.CyanString("grasp status:"))
rows := make([][3]string, len(localConfig.GraspServers))
for s, server := range localConfig.GraspServers {
row := [3]string{}
url := graspServerHost(server)
row[0] = url
upToDate := upToDateRelays != nil && slices.ContainsFunc(upToDateRelays, func(s string) bool { return graspServerHost(s) == url })
if upToDate {
row[1] = color.GreenString("announcement up-to-date")
} else {
row[1] = color.YellowString("announcement outdated")
}
if state != nil {
remoteName := gitRemoteName(url)
refSpec := fmt.Sprintf("refs/remotes/%s/HEAD", remoteName)
lsRemoteCmd := exec.Command("git", "rev-parse", "--verify", refSpec)
commitOutput, err := lsRemoteCmd.Output()
if err != nil {
row[2] = color.YellowString("repository not pushed")
} else {
commit := strings.TrimSpace(string(commitOutput))
if commit == stateHEAD {
row[2] = color.GreenString("repository synced with state")
} else {
row[2] = color.YellowString("mismatched HEAD state=%s, pushed=%s", stateHEAD[0:5], commit[0:5])
}
}
}
rows[s] = row
}
maxCol := [3]int{}
for i := range maxCol {
for _, row := range rows {
if len(row[i]) > maxCol[i] {
maxCol[i] = len(row[i])
}
}
}
for _, row := range rows {
line := " " + row[0] + strings.Repeat(" ", maxCol[0]-len(row[0])) + " " + strings.Repeat(" ", maxCol[1]-len(row[1])) + row[1] + " " + strings.Repeat(" ", maxCol[2]-len(row[2])) + row[2]
stdout(line)
}
return nil
},
},
},
}
@@ -961,7 +869,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
}
// fetch repository announcement and state from relays
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
notUpToDate := func(graspServer string) bool {
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
}
@@ -981,40 +889,33 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
}
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
}
var event nostr.Event
if upToDateAnnouncementEvent != nil {
// publish the latest event to the other relays
event = *upToDateAnnouncementEvent
repo = nip34.ParseRepository(event)
} else {
// create a local repository object from config and publish it
localRepo := localConfig.ToRepository()
if signer != nil {
signerPk, err := signer.GetPublicKey(ctx)
if err != nil {
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
// create a local repository object from config and publish it
localRepo := localConfig.ToRepository()
if signer != nil {
signerPk, err := signer.GetPublicKey(ctx)
if err != nil {
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
}
if signerPk != owner {
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
} else {
event := localRepo.ToEvent()
if err := signer.SignEvent(ctx, &event); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
}
if signerPk != owner {
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
} else {
event = localRepo.ToEvent()
if err := signer.SignEvent(ctx, &event); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
for res := range sys.Pool.PublishMany(ctx, relays, event) {
if res.Error != nil {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.RelayURL))
}
}
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
repo = localRepo
}
for res := range sys.Pool.PublishMany(ctx, relays, *upToDateAnnouncementEvent) {
if res.Error != nil {
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
} else {
log("> published to %s\n", color.GreenString(res.RelayURL))
repo = localRepo
}
} else {
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
}
} else {
if err != nil {
@@ -1050,7 +951,6 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
} else {
log("local configuration is newer, publishing updated repository announcement...\n")
announcementEvent := localRepo.ToEvent()
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
}
@@ -1255,7 +1155,7 @@ func fetchRepositoryAndState(
pubkey nostr.PubKey,
identifier string,
relayHints []string,
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) {
// fetch repository announcement (30617)
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
@@ -1276,15 +1176,13 @@ func fetchRepositoryAndState(
// reset this list as the previous was for relays with the older version
upToDateRelays = []string{ie.Relay.URL}
upToDateAnnouncementEvent = &ie.Event
} else if ie.Event.CreatedAt == repo.CreatedAt {
// we discard this because it's the same, but this relay is up-to-date
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
}
}
if repo.Event.ID == nostr.ZeroID {
return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
}
// fetch repository state (30618)
@@ -1314,10 +1212,10 @@ func fetchRepositoryAndState(
}
}
if stateErr != nil {
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
return repo, upToDateRelays, state, stateErr
}
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
return repo, upToDateRelays, state, nil
}
type StateErr struct{ string }

9
go.mod
View File

@@ -3,7 +3,8 @@ module github.com/fiatjaf/nak
go 1.25
require (
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817
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
github.com/btcsuite/btcd/btcec/v2 v2.3.6
@@ -11,6 +12,7 @@ require (
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
github.com/fatih/color v1.16.0
github.com/json-iterator/go v1.1.12
github.com/liamg/magic v0.0.1
github.com/mailru/easyjson v0.9.1
@@ -28,11 +30,6 @@ require (
golang.org/x/term v0.32.0
)
require (
fiatjaf.com/lib v0.3.2
github.com/hanwen/go-fuse/v2 v2.9.0
)
require (
github.com/FastFilter/xorfilter v0.2.1 // indirect
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect

8
go.sum
View File

@@ -1,7 +1,7 @@
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-20260126202222-ca3730e50817 h1:Zp6rPetvwYFOLD+36RtmWmns2C0CLbtphD3DLu3cxCo=
fiatjaf.com/nostr v0.0.0-20260126202222-ca3730e50817/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
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=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=

586
group.go
View File

@@ -1,586 +0,0 @@
package main
import (
"context"
"fmt"
"strings"
"sync"
"time"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip29"
"github.com/fatih/color"
"github.com/urfave/cli/v3"
)
var group = &cli.Command{
Name: "group",
Aliases: []string{"nip29"},
Usage: "group-related operations: info, chat, forum, members, admins, roles",
Description: `manage and interact with Nostr communities (NIP-29). Use "nak group <subcommand> <relay>'<identifier>" where host.tld is the relay and identifier is the group identifier.`,
DisableSliceFlagSeparator: true,
ArgsUsage: "<subcommand> <relay>'<identifier> [flags]",
Flags: defaultKeyFlags,
Commands: []*cli.Command{
{
Name: "info",
Usage: "show group information",
Description: "displays basic group metadata.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMetadata},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMetadataEvent(&ie.Event); err != nil {
return err
}
break
}
stdout("address:", color.HiBlueString(strings.SplitN(nostr.NormalizeURL(relay), "/", 3)[2]+"'"+identifier))
stdout("name:", color.HiBlueString(group.Name))
stdout("picture:", color.HiBlueString(group.Picture))
stdout("about:", color.HiBlueString(group.About))
stdout("restricted:",
color.HiBlueString("%s", cond(group.Restricted, "yes", "no"))+
", "+
cond(group.Restricted, "only explicit members can publish", "non-members can publish (restricted by relay policy)"),
)
stdout("closed:",
color.HiBlueString("%s", cond(group.Closed, "yes", "no"))+
", "+
cond(group.Closed, "joining requires an invite", "anyone can join (restricted by relay policy)"),
)
stdout("hidden:",
color.HiBlueString("%s", cond(group.Hidden, "yes", "no"))+
", "+
cond(group.Hidden, "group doesn't show up when listing relay groups", "group is visible to users browsing the relay"),
)
stdout("private:",
color.HiBlueString("%s", cond(group.Private, "yes", "no"))+
", "+
cond(group.Private, "group content is not accessible to non-members", "group content is public"),
)
return nil
},
},
{
Name: "members",
Usage: "list and manage group members",
Description: "view group membership information.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Members: make(map[nostr.PubKey][]*nip29.Role),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupMembers},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInMembersEvent(&ie.Event); err != nil {
return err
}
break
}
lines := make(chan string)
wg := sync.WaitGroup{}
for member, roles := range group.Members {
wg.Go(func() {
line := member.Hex()
meta := sys.FetchProfileMetadata(ctx, member)
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
for _, role := range roles {
line += ", " + role.Name
}
lines <- line
})
}
go func() {
wg.Wait()
close(lines)
}()
for line := range lines {
stdout(line)
}
return nil
},
},
{
Name: "admins",
Usage: "manage group administrators",
Description: "view and manage group admin permissions.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Members: make(map[nostr.PubKey][]*nip29.Role),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupAdmins},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInAdminsEvent(&ie.Event); err != nil {
return err
}
break
}
lines := make(chan string)
wg := sync.WaitGroup{}
for member, roles := range group.Members {
wg.Go(func() {
line := member.Hex()
meta := sys.FetchProfileMetadata(ctx, member)
line += " (" + color.HiBlueString(meta.ShortName()) + ")"
for _, role := range roles {
line += ", " + role.Name
}
lines <- line
})
}
go func() {
wg.Wait()
close(lines)
}()
for line := range lines {
stdout(line)
}
return nil
},
},
{
Name: "roles",
Usage: "manage group roles and permissions",
Description: "configure custom roles and permissions within the group.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
group := nip29.Group{
Roles: make([]*nip29.Role, 0),
}
for ie := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{nostr.KindSimpleGroupRoles},
Tags: nostr.TagMap{"d": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
if err := group.MergeInRolesEvent(&ie.Event); err != nil {
return err
}
break
}
for _, role := range group.Roles {
stdout(color.HiBlueString(role.Name) + " " + role.Description)
}
return nil
},
},
{
Name: "chat",
Usage: "send and read group chat messages",
Description: "interact with group chat functionality.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
sub, err := r.Subscribe(ctx, nostr.Filter{
Kinds: []nostr.Kind{9},
Tags: nostr.TagMap{"h": []string{identifier}},
Limit: 200,
}, nostr.SubscriptionOptions{Label: "nak-nip29"})
if err != nil {
return err
}
defer sub.Close()
eosed := false
messages := make([]struct {
message string
rendered bool
}, 200)
base := len(messages)
tryRender := func(i int) {
// if all messages before these are loaded we can render this,
// otherwise we render whatever we can and stop
for m, msg := range messages[base:] {
if msg.rendered {
continue
}
if msg.message == "" {
break
}
messages[base+m].rendered = true
stdout(msg.message)
}
}
for {
select {
case evt := <-sub.Events:
var i int
if eosed {
i = len(messages)
messages = append(messages, struct {
message string
rendered bool
}{})
} else {
base--
i = base
}
go func() {
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
messages[i].message = color.HiBlueString(meta.ShortName()) + " " + color.HiCyanString(evt.CreatedAt.Time().Format(time.DateTime)) + ": " + evt.Content
if eosed {
tryRender(i)
}
}()
case reason := <-sub.ClosedReason:
stdout("closed:" + color.YellowString(reason))
case <-sub.EndOfStoredEvents:
eosed = true
tryRender(len(messages) - 1)
case <-sub.Context.Done():
return fmt.Errorf("subscription ended: %w", context.Cause(sub.Context))
}
}
},
Commands: []*cli.Command{
{
Name: "send",
Usage: "sends a message to the chat",
ArgsUsage: "<relay>'<identifier> <message>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
msg := nostr.Event{
Kind: 9,
CreatedAt: nostr.Now(),
Content: strings.Join(c.Args().Tail(), " "),
Tags: nostr.Tags{
{"h", identifier},
},
}
if err := kr.SignEvent(ctx, &msg); err != nil {
return fmt.Errorf("failed to sign message: %w", err)
}
if r, err := sys.Pool.EnsureRelay(relay); err != nil {
return err
} else {
return r.Publish(ctx, msg)
}
},
},
},
},
{
Name: "forum",
Usage: "read group forum posts",
Description: "access group forum functionality.",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
for evt := range sys.Pool.FetchMany(ctx, []string{relay}, nostr.Filter{
Kinds: []nostr.Kind{11},
Tags: nostr.TagMap{"#h": []string{identifier}},
}, nostr.SubscriptionOptions{Label: "nak-nip29"}) {
title := evt.Tags.Find("title")
if title != nil {
stdout(colors.bold(title[1]))
} else {
stdout(colors.bold("<untitled>"))
}
meta := sys.FetchProfileMetadata(ctx, evt.PubKey)
stdout("by " + evt.PubKey.Hex() + " (" + color.HiBlueString(meta.ShortName()) + ") at " + evt.CreatedAt.Time().Format(time.DateTime))
stdout(evt.Content)
}
// TODO: see what to do about this
return nil
},
},
{
Name: "put-user",
Usage: "add a user to the group with optional roles",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "pubkey",
Required: true,
},
&cli.StringSliceFlag{
Name: "role",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9000, func(evt *nostr.Event, args []string) error {
pubkey := getPubKey(c, "pubkey")
tag := nostr.Tag{"p", pubkey.Hex()}
tag = append(tag, c.StringSlice("role")...)
evt.Tags = append(evt.Tags, tag)
return nil
})
},
},
{
Name: "remove-user",
Usage: "remove a user from the group",
ArgsUsage: "<relay>'<identifier> <pubkey>",
Flags: []cli.Flag{
&PubKeyFlag{
Name: "pubkey",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9001, func(evt *nostr.Event, args []string) error {
pubkey := getPubKey(c, "pubkey")
evt.Tags = append(evt.Tags, nostr.Tag{"p", pubkey.Hex()})
return nil
})
},
},
{
Name: "edit-metadata",
Usage: "edits the group metadata",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
},
&cli.StringFlag{
Name: "about",
},
&cli.StringFlag{
Name: "picture",
},
&cli.BoolFlag{
Name: "restricted",
},
&cli.BoolFlag{
Name: "unrestricted",
},
&cli.BoolFlag{
Name: "closed",
},
&cli.BoolFlag{
Name: "open",
},
&cli.BoolFlag{
Name: "hidden",
},
&cli.BoolFlag{
Name: "visible",
},
&cli.BoolFlag{
Name: "private",
},
&cli.BoolFlag{
Name: "public",
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9002, func(evt *nostr.Event, args []string) error {
if name := c.String("name"); name != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"name", name})
}
if picture := c.String("picture"); picture != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"picture", picture})
}
if about := c.String("about"); about != "" {
evt.Tags = append(evt.Tags, nostr.Tag{"about", about})
}
if c.Bool("restricted") {
evt.Tags = append(evt.Tags, nostr.Tag{"restricted"})
} else if c.Bool("unrestricted") {
evt.Tags = append(evt.Tags, nostr.Tag{"unrestricted"})
}
if c.Bool("closed") {
evt.Tags = append(evt.Tags, nostr.Tag{"closed"})
} else if c.Bool("open") {
evt.Tags = append(evt.Tags, nostr.Tag{"open"})
}
if c.Bool("hidden") {
evt.Tags = append(evt.Tags, nostr.Tag{"hidden"})
} else if c.Bool("visible") {
evt.Tags = append(evt.Tags, nostr.Tag{"visible"})
}
if c.Bool("private") {
evt.Tags = append(evt.Tags, nostr.Tag{"private"})
} else if c.Bool("public") {
evt.Tags = append(evt.Tags, nostr.Tag{"public"})
}
return nil
})
},
},
{
Name: "delete-event",
Usage: "delete an event from the group",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&IDFlag{
Name: "event",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9005, func(evt *nostr.Event, args []string) error {
id := getID(c, "event")
evt.Tags = append(evt.Tags, nostr.Tag{"e", id.Hex()})
return nil
})
},
},
{
Name: "delete-group",
Usage: "deletes the group",
ArgsUsage: "<relay>'<identifier>",
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9008, func(evt *nostr.Event, args []string) error {
return nil
})
},
},
{
Name: "create-invite",
Usage: "creates an invite code",
ArgsUsage: "<relay>'<identifier>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "code",
Required: true,
},
},
Action: func(ctx context.Context, c *cli.Command) error {
return createModerationEvent(ctx, c, 9009, func(evt *nostr.Event, args []string) error {
evt.Tags = append(evt.Tags, nostr.Tag{"code", c.String("code")})
return nil
})
},
},
},
}
func createModerationEvent(ctx context.Context, c *cli.Command, kind nostr.Kind, setupFunc func(*nostr.Event, []string) error) error {
args := c.Args().Slice()
if len(args) < 1 {
return fmt.Errorf("requires group identifier")
}
relay, identifier, err := parseGroupIdentifier(c)
if err != nil {
return err
}
kr, _, err := gatherKeyerFromArguments(ctx, c)
if err != nil {
return err
}
evt := nostr.Event{
Kind: kind,
CreatedAt: nostr.Now(),
Content: "",
Tags: nostr.Tags{
{"h", identifier},
},
}
if err := setupFunc(&evt, args); err != nil {
return err
}
if err := kr.SignEvent(ctx, &evt); err != nil {
return fmt.Errorf("failed to sign event: %w", err)
}
stdout(evt.String())
r, err := sys.Pool.EnsureRelay(relay)
if err != nil {
return err
}
return r.Publish(ctx, evt)
}
func cond(b bool, ifYes string, ifNo string) string {
if b {
return ifYes
}
return ifNo
}
func parseGroupIdentifier(c *cli.Command) (relay string, identifier string, err error) {
groupArg := c.Args().First()
if !strings.Contains(groupArg, "'") {
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
}
parts := strings.SplitN(groupArg, "'", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return "", "", fmt.Errorf("invalid group identifier format, expected <relay>'<identifier>")
}
return strings.TrimSuffix(parts[0], "/"), parts[1], nil
}

View File

@@ -536,25 +536,21 @@ func decodeTagValue(value string) string {
}
var colors = struct {
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
underline func(...any) string
underlinef func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
reset func(...any) (int, error)
italic func(...any) string
italicf func(string, ...any) string
bold func(...any) string
boldf func(string, ...any) string
error func(...any) string
errorf func(string, ...any) string
success func(...any) string
successf func(string, ...any) string
}{
color.New(color.Reset).Print,
color.New(color.Italic).Sprint,
color.New(color.Italic).Sprintf,
color.New(color.Bold).Sprint,
color.New(color.Bold).Sprintf,
color.New(color.Underline).Sprint,
color.New(color.Underline).Sprintf,
color.New(color.Bold, color.FgHiRed).Sprint,
color.New(color.Bold, color.FgHiRed).Sprintf,
color.New(color.Bold, color.FgHiGreen).Sprint,

View File

@@ -1,73 +0,0 @@
#!/usr/bin/env sh
set -e
# detect OS
detect_os() {
case "$(uname -s)" in
Linux*) echo "linux";;
Darwin*) echo "darwin";;
FreeBSD*) echo "freebsd";;
MINGW*|MSYS*|CYGWIN*) echo "windows";;
*)
echo "error: unsupported OS $(uname -s)" >&2
exit 1
;;
esac
}
# detect architecture
detect_arch() {
case "$(uname -m)" in
x86_64|amd64) echo "amd64";;
aarch64|arm64) echo "arm64";;
riscv64) echo "riscv64";;
*)
echo "error: unsupported architecture $(uname -m)" >&2
exit 1
;;
esac
}
# set install directory
INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/bin}"
# detect platform
OS=$(detect_os)
ARCH=$(detect_arch)
echo "installing nak ($OS-$ARCH) to $INSTALL_DIR..."
# check if curl is available
command -v curl >/dev/null 2>&1 || { echo "error: curl is required" >&2; exit 1; }
# get latest release tag
RELEASE_INFO=$(curl -s https://api.github.com/repos/fiatjaf/nak/releases/latest)
TAG="${RELEASE_INFO#*\"tag_name\"}"
TAG="${TAG#*\"}"
TAG="${TAG%%\"*}"
[ -z "$TAG" ] && { echo "error: failed to fetch release info" >&2; exit 1; }
# construct download URL
BINARY_NAME="nak-${TAG}-${OS}-${ARCH}"
[ "$OS" = "windows" ] && BINARY_NAME="${BINARY_NAME}.exe"
DOWNLOAD_URL="https://github.com/fiatjaf/nak/releases/download/${TAG}/${BINARY_NAME}"
# create install directory and download
mkdir -p "$INSTALL_DIR"
TARGET_PATH="$INSTALL_DIR/nak"
[ "$OS" = "windows" ] && TARGET_PATH="${TARGET_PATH}.exe"
if curl -sS -L -f -o "$TARGET_PATH" "$DOWNLOAD_URL"; then
chmod +x "$TARGET_PATH"
echo "installed nak $TAG to $TARGET_PATH"
# check if install dir is in PATH
case ":$PATH:" in
*":$INSTALL_DIR:"*) ;;
*) echo "note: add $INSTALL_DIR to your PATH" ;;
esac
else
echo "error: download failed from $DOWNLOAD_URL" >&2
exit 1
fi

View File

@@ -50,7 +50,6 @@ var app = &cli.Command{
fsCmd,
publish,
git,
group,
nip,
syncCmd,
spell,

104
mcp.go
View File

@@ -6,7 +6,6 @@ import (
"strings"
"fiatjaf.com/nostr"
"fiatjaf.com/nostr/nip11"
"fiatjaf.com/nostr/nip19"
"fiatjaf.com/nostr/sdk"
"github.com/mark3labs/mcp-go/mcp"
@@ -34,10 +33,10 @@ var mcpServer = &cli.Command{
}
s.AddTool(mcp.NewTool("publish_note",
mcp.WithDescription("publish a short note event to Nostr with the given text content"),
mcp.WithString("content", mcp.Description("arbitrary string to be published"), mcp.Required()),
mcp.WithString("relay", mcp.Description("relay to publish the note to")),
mcp.WithString("mention", mcp.Description("nostr user's public key to be mentioned")),
mcp.WithDescription("Publish a short note event to Nostr with the given text content"),
mcp.WithString("content", mcp.Description("Arbitrary string to be published"), mcp.Required()),
mcp.WithString("relay", mcp.Description("Relay to publish the note to")),
mcp.WithString("mention", mcp.Description("Nostr user's public key to be mentioned")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
content := required[string](r, "content")
mention, _ := optional[string](r, "mention")
@@ -106,7 +105,7 @@ var mcpServer = &cli.Command{
})
s.AddTool(mcp.NewTool("resolve_nostr_uri",
mcp.WithDescription("resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithDescription("Resolve URIs prefixed with nostr:, including nostr:nevent1..., nostr:npub1..., nostr:nprofile1... and nostr:naddr1..."),
mcp.WithString("uri", mcp.Description("URI to be resolved"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
uri := required[string](r, "uri")
@@ -137,23 +136,23 @@ var mcpServer = &cli.Command{
WithRelays: false,
})
if err != nil {
return mcp.NewToolResultError("couldn't find this event anywhere"), nil
return mcp.NewToolResultError("Couldn't find this event anywhere"), nil
}
return mcp.NewToolResultText(
fmt.Sprintf("this is a Nostr event: %s", event),
), nil
case "naddr":
return mcp.NewToolResultError("for now we can't handle this kind of Nostr uri"), nil
return mcp.NewToolResultError("For now we can't handle this kind of Nostr uri"), nil
default:
return mcp.NewToolResultError("we don't know how to handle this Nostr uri"), nil
return mcp.NewToolResultError("We don't know how to handle this Nostr uri"), nil
}
})
s.AddTool(mcp.NewTool("search_profile",
mcp.WithDescription("search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.Description("name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("how many results to return")),
mcp.WithDescription("Search for the public key of a Nostr user given their name"),
mcp.WithString("name", mcp.Description("Name to be searched"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("How many results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
name := required[string](r, "name")
limit, _ := optional[float64](r, "limit")
@@ -164,7 +163,7 @@ var mcpServer = &cli.Command{
}
res := strings.Builder{}
res.WriteString("search results: ")
res.WriteString("Search results: ")
l := 0
for result := range sys.Pool.FetchMany(ctx, []string{"relay.nostr.band", "nostr.wine"}, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
@@ -179,14 +178,14 @@ var mcpServer = &cli.Command{
}
}
if l == 0 {
return mcp.NewToolResultError("couldn't find anyone with that name."), nil
return mcp.NewToolResultError("Couldn't find anyone with that name."), nil
}
return mcp.NewToolResultText(res.String()), nil
})
s.AddTool(mcp.NewTool("get_outbox_relay_for_pubkey",
mcp.WithDescription("get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey", mcp.Description("public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
mcp.WithDescription("Get the best relay from where to read notes from a specific Nostr user"),
mcp.WithString("pubkey", mcp.Description("Public key of Nostr user we want to know the relay from where to read"), mcp.Required()),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
pubkey, err := nostr.PubKeyFromHex(required[string](r, "pubkey"))
if err != nil {
@@ -198,7 +197,7 @@ var mcpServer = &cli.Command{
})
s.AddTool(mcp.NewTool("read_events_from_relay",
mcp.WithDescription("makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithDescription("Makes a REQ query to one relay using the specified parameters, this can be used to fetch notes from a profile"),
mcp.WithString("relay", mcp.Description("relay URL to send the query to"), mcp.Required()),
mcp.WithNumber("kind", mcp.Description("event kind number to include in the 'kinds' field"), mcp.Required()),
mcp.WithNumber("limit", mcp.Description("maximum number of events to query"), mcp.Required()),
@@ -239,77 +238,6 @@ var mcpServer = &cli.Command{
return mcp.NewToolResultText(result.String()), nil
})
s.AddTool(mcp.NewTool("search_events",
mcp.WithDescription("search for Nostr events. specifying the author makes it so we'll try to use their relays instead of generic ones."),
mcp.WithString("search", mcp.Description("search query string"), mcp.Required()),
mcp.WithString("author", mcp.Description("author public key to filter by")),
mcp.WithNumber("kind", mcp.Description("event kind to filter by")),
mcp.WithNumber("limit", mcp.Description("maximum number of results to return")),
), func(ctx context.Context, r mcp.CallToolRequest) (*mcp.CallToolResult, error) {
search := required[string](r, "search")
author, hasAuthor := optional[string](r, "author")
kind, hasKind := optional[float64](r, "kind")
limit, _ := optional[float64](r, "limit")
if limit == 0 {
limit = 50
}
filter := nostr.Filter{Search: search, Limit: int(limit)}
if hasKind {
filter.Kinds = []nostr.Kind{nostr.Kind(int(kind))}
}
var relays []string
if hasAuthor {
if pk, err := nostr.PubKeyFromHex(author); err != nil {
return mcp.NewToolResultError("the author given isn't a valid public key, it must be 32 bytes hex. Got error: " + err.Error()), nil
} else {
filter.Authors = append(filter.Authors, pk)
}
pk, _ := nostr.PubKeyFromHex(author)
writeRelays := sys.FetchOutboxRelays(ctx, pk, 5)
for _, relayURL := range writeRelays {
if info, err := nip11.Fetch(ctx, relayURL); err == nil {
for _, nip := range info.SupportedNIPs {
if nipInt, ok := nip.(float64); ok && nipInt == 50 {
relays = append(relays, relayURL)
break
}
}
}
}
}
if len(relays) == 0 {
relays = []string{"relay.nostr.band", "nostr.polyserv.xyz/", "search.nos.today/"}
}
result := strings.Builder{}
result.WriteString(fmt.Sprintf("search results for '%s':\n\n", search))
l := 0
for event := range sys.Pool.FetchMany(ctx, relays, filter, nostr.SubscriptionOptions{
Label: "nak-mcp-search",
}) {
l++
result.WriteString(fmt.Sprintf("result %d\nID: %s\nKind: %d\nAuthor: %s\nContent: %s\n---\n",
l, event.ID, event.Kind, event.PubKey.Hex(), event.Content))
if l >= int(limit) {
break
}
}
if l == 0 {
return mcp.NewToolResultError("no events found matching the search criteria."), nil
}
return mcp.NewToolResultText(result.String()), nil
})
return server.ServeStdio(s)
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -153,7 +153,7 @@ example:
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
relays := connectToAllRelays(ctx, c, relayUrls, nil,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
},
},

2
req.go
View File

@@ -138,7 +138,7 @@ example:
relayUrls,
forcePreAuthSigner,
nostr.PoolOptions{
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
return authSigner(ctx, c, func(s string, args ...any) {
if strings.HasPrefix(s, "authenticating as") {
cleanUrl, _ := strings.CutPrefix(