mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-25 20:08:52 +00:00
Compare commits
17 Commits
test-v1.0.
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee6320312 | ||
|
|
91474d65eb | ||
|
|
7d782737c4 | ||
|
|
9160c68cb5 | ||
|
|
bf19f38996 | ||
|
|
4e2c136e45 | ||
|
|
8cef1ed0ea | ||
|
|
e05b455a05 | ||
|
|
9190c9d988 | ||
|
|
e64ad8f078 | ||
|
|
b36718caaa | ||
|
|
5c658c38f1 | ||
|
|
2a5ce3b249 | ||
|
|
c0b85af734 | ||
|
|
cb2247c9da | ||
|
|
686d960f62 | ||
|
|
af04838153 |
42
.github/workflows/release-cli.yml
vendored
42
.github/workflows/release-cli.yml
vendored
@@ -24,13 +24,15 @@ jobs:
|
|||||||
- make-release
|
- make-release
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
goos: [linux, freebsd, windows]
|
goos: [linux, freebsd, darwin, windows]
|
||||||
goarch: [amd64, arm64, riscv64]
|
goarch: [amd64, arm64, riscv64]
|
||||||
exclude:
|
exclude:
|
||||||
- goarch: arm64
|
- goarch: arm64
|
||||||
goos: windows
|
goos: windows
|
||||||
- goarch: riscv64
|
- goarch: riscv64
|
||||||
goos: windows
|
goos: windows
|
||||||
|
- goarch: riscv64
|
||||||
|
goos: darwin
|
||||||
- goarch: arm64
|
- goarch: arm64
|
||||||
goos: freebsd
|
goos: freebsd
|
||||||
steps:
|
steps:
|
||||||
@@ -40,42 +42,12 @@ jobs:
|
|||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
goos: ${{ matrix.goos }}
|
goos: ${{ matrix.goos }}
|
||||||
goarch: ${{ matrix.goarch }}
|
goarch: ${{ matrix.goarch }}
|
||||||
ldflags: -X main.version=${{ github. ref_name }}
|
ldflags: -X main.version=${{ github.ref_name }}
|
||||||
overwrite: true
|
overwrite: true
|
||||||
md5sum: false
|
md5sum: false
|
||||||
sha256sum: false
|
sha256sum: false
|
||||||
compress_assets: false
|
compress_assets: false
|
||||||
|
|
||||||
build-darwin:
|
|
||||||
runs-on: macos-latest
|
|
||||||
needs:
|
|
||||||
- make-release
|
|
||||||
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: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets. GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ needs.make-release.outputs.upload_url }}
|
|
||||||
asset_path: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
|
||||||
asset_name: nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
|
||||||
asset_content_type: application/octet-stream
|
|
||||||
smoke-test-linux-amd64:
|
smoke-test-linux-amd64:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
needs:
|
||||||
@@ -131,7 +103,7 @@ jobs:
|
|||||||
# test NIP-49 key encryption/decryption
|
# test NIP-49 key encryption/decryption
|
||||||
echo "testing NIP-49 key encryption/decryption..."
|
echo "testing NIP-49 key encryption/decryption..."
|
||||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||||
echo "encrypted key: ${ENCRYPTED_KEY:0:20}..."
|
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||||
echo "nip-49 encryption/decryption test failed!"
|
echo "nip-49 encryption/decryption test failed!"
|
||||||
@@ -145,7 +117,7 @@ jobs:
|
|||||||
# test relay operations (with a public relay)
|
# test relay operations (with a public relay)
|
||||||
echo "testing publishing..."
|
echo "testing publishing..."
|
||||||
# publish a simple event to a public relay
|
# publish a simple event to a public relay
|
||||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol)
|
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol < /dev/null)
|
||||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||||
echo "published event ID: $EVENT_ID"
|
echo "published event ID: $EVENT_ID"
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ gitnostr.com... ok.
|
|||||||
```shell
|
```shell
|
||||||
~> nak git clone
|
~> nak git clone
|
||||||
~> nak git init
|
~> nak git init
|
||||||
|
~> nak git status
|
||||||
~> nak git sync
|
~> nak git sync
|
||||||
~> nak git fetch
|
~> nak git fetch
|
||||||
~> nak git pull
|
~> nak git pull
|
||||||
|
|||||||
52
blossom.go
52
blossom.go
@@ -229,12 +229,56 @@ if any of the files are not found the command will fail, otherwise it will succe
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "mirror",
|
Name: "mirror",
|
||||||
Usage: "",
|
Usage: "mirrors a from a server to another",
|
||||||
Description: ``,
|
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`,
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
ArgsUsage: "",
|
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
299
bunker.go
299
bunker.go
@@ -4,13 +4,14 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
@@ -73,13 +74,7 @@ var bunker = &cli.Command{
|
|||||||
},
|
},
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
// read config from file
|
// read config from file
|
||||||
config := struct {
|
config := BunkerConfig{}
|
||||||
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")...)
|
baseRelaysUrls := appendUnique(c.Args().Slice(), c.StringSlice("relay")...)
|
||||||
for i, url := range baseRelaysUrls {
|
for i, url := range baseRelaysUrls {
|
||||||
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
baseRelaysUrls[i] = nostr.NormalizeURL(url)
|
||||||
@@ -142,6 +137,15 @@ var bunker = &cli.Command{
|
|||||||
if err := json.Unmarshal(b, &config); err != nil {
|
if err := json.Unmarshal(b, &config); err != nil {
|
||||||
return err
|
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) {
|
} else if !os.IsNotExist(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,11 @@ var bunker = &cli.Command{
|
|||||||
config.Relays[i] = nostr.NormalizeURL(url)
|
config.Relays[i] = nostr.NormalizeURL(url)
|
||||||
}
|
}
|
||||||
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
config.Relays = appendUnique(config.Relays, baseRelaysUrls...)
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, baseAuthorizedKeys...)
|
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})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if config.Secret.Plain == nil && config.Secret.Encrypted == nil {
|
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
|
// we don't have any secret key stored, so just use whatever was given via flags
|
||||||
@@ -167,7 +175,9 @@ var bunker = &cli.Command{
|
|||||||
} else {
|
} else {
|
||||||
config.Secret = baseSecret
|
config.Secret = baseSecret
|
||||||
config.Relays = baseRelaysUrls
|
config.Relays = baseRelaysUrls
|
||||||
config.AuthorizedKeys = baseAuthorizedKeys
|
for _, bak := range baseAuthorizedKeys {
|
||||||
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: bak})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// if we got here without any keys set (no flags, first time using a profile), use the default
|
// if we got here without any keys set (no flags, first time using a profile), use the default
|
||||||
@@ -205,8 +215,17 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
// try to connect to the relays here
|
// try to connect to the relays here
|
||||||
qs := url.Values{}
|
qs := url.Values{}
|
||||||
relayURLs := make([]string, 0, len(config.Relays))
|
allRelays := make([]string, len(config.Relays), len(config.Relays)+5)
|
||||||
relays := connectToAllRelays(ctx, c, config.Relays, nil, nostr.PoolOptions{})
|
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{})
|
||||||
if len(relays) == 0 {
|
if len(relays) == 0 {
|
||||||
log("failed to connect to any of the given relays.\n")
|
log("failed to connect to any of the given relays.\n")
|
||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
@@ -236,10 +255,22 @@ var bunker = &cli.Command{
|
|||||||
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
bunkerURI := fmt.Sprintf("bunker://%s?%s", pubkey.Hex(), qs.Encode())
|
||||||
|
|
||||||
authorizedKeysStr := ""
|
authorizedKeysStr := ""
|
||||||
if len(config.AuthorizedKeys) != 0 {
|
if len(config.Clients) != 0 {
|
||||||
authorizedKeysStr = "\n authorized keys:"
|
authorizedKeysStr = "\n authorized clients:"
|
||||||
for _, pubkey := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
authorizedKeysStr += "\n - " + colors.italic(pubkey.Hex())
|
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 + ")"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +280,8 @@ var bunker = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
preauthorizedFlags := ""
|
preauthorizedFlags := ""
|
||||||
for _, k := range config.AuthorizedKeys {
|
for _, c := range config.Clients {
|
||||||
preauthorizedFlags += " -k " + k.Hex()
|
preauthorizedFlags += " -k " + c.PubKey.Hex()
|
||||||
}
|
}
|
||||||
for _, s := range authorizedSecrets {
|
for _, s := range authorizedSecrets {
|
||||||
preauthorizedFlags += " -s " + s
|
preauthorizedFlags += " -s " + s
|
||||||
@@ -314,28 +345,85 @@ var bunker = &cli.Command{
|
|||||||
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
Tags: nostr.TagMap{"p": []string{pubkey.Hex()}},
|
||||||
Since: nostr.Now(),
|
Since: nostr.Now(),
|
||||||
LimitZero: true,
|
LimitZero: true,
|
||||||
}, nostr.SubscriptionOptions{
|
}, nostr.SubscriptionOptions{Label: "nak-bunker"})
|
||||||
Label: "nak-bunker",
|
|
||||||
})
|
|
||||||
|
|
||||||
signer := nip46.NewStaticKeySigner(sec)
|
signer := nip46.NewStaticKeySigner(sec)
|
||||||
handlerWg := sync.WaitGroup{}
|
signer.DefaultRelays = config.Relays
|
||||||
printLock := sync.Mutex{}
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// just a gimmick
|
// just a gimmick
|
||||||
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
var cancelPreviousBunkerInfoPrint context.CancelFunc
|
||||||
_, cancel := context.WithCancel(ctx)
|
_, cancel := context.WithCancel(ctx)
|
||||||
cancelPreviousBunkerInfoPrint = cancel
|
cancelPreviousBunkerInfoPrint = cancel
|
||||||
|
|
||||||
// asking user for authorization
|
|
||||||
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
signer.AuthorizeRequest = func(harmless bool, from nostr.PubKey, secret string) bool {
|
||||||
if slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret) {
|
if slices.ContainsFunc(config.Clients, func(b BunkerConfigClient) bool { return b.PubKey == from }) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if slices.Contains(authorizedSecrets, secret) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
if secret == newSecret {
|
if secret == newSecret {
|
||||||
// store this key
|
// store this key
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
config.Clients = append(config.Clients, BunkerConfigClient{PubKey: from})
|
||||||
// discard this and generate a new secret
|
// discard this and generate a new secret
|
||||||
newSecret = randString(12)
|
newSecret = randString(12)
|
||||||
// print bunker info again after this
|
// print bunker info again after this
|
||||||
@@ -358,34 +446,39 @@ var bunker = &cli.Command{
|
|||||||
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
cancelPreviousBunkerInfoPrint() // this prevents us from printing a million bunker info blocks
|
||||||
|
|
||||||
// handle the NIP-46 request event
|
// handle the NIP-46 request event
|
||||||
|
from := ie.Event.PubKey
|
||||||
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
req, resp, eventResponse, err := signer.HandleRequest(ctx, ie.Event)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log("< failed to handle request from %s: %s\n", ie.Event.PubKey, err.Error())
|
if errors.Is(err, nip46.AlreadyHandled) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
log("< failed to handle request from %s: %s\n", from.Hex(), err.Error())
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
jreq, _ := json.MarshalIndent(req, "", " ")
|
jreq, _ := json.MarshalIndent(req, "", " ")
|
||||||
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(ie.Event.PubKey.Hex()), string(jreq))
|
log("- got request from '%s': %s\n", color.New(color.Bold, color.FgBlue).Sprint(from.Hex()), string(jreq))
|
||||||
jresp, _ := json.MarshalIndent(resp, "", " ")
|
jresp, _ := json.MarshalIndent(resp, "", " ")
|
||||||
log("~ responding with %s\n", string(jresp))
|
log("~ responding with %s\n", string(jresp))
|
||||||
|
|
||||||
handlerWg.Add(len(relayURLs))
|
// use custom relays if they are defined for this client
|
||||||
for _, relayURL := range relayURLs {
|
// (normally if the initial connection came from a nostrconnect:// URL)
|
||||||
go func(relayURL string) {
|
relays := relayURLs
|
||||||
defer handlerWg.Done()
|
for _, c := range config.Clients {
|
||||||
if relay, _ := sys.Pool.EnsureRelay(relayURL); relay != nil {
|
if c.PubKey == from && len(c.CustomRelays) > 0 {
|
||||||
err := relay.Publish(ctx, eventResponse)
|
relays = c.CustomRelays
|
||||||
printLock.Lock()
|
break
|
||||||
if err == nil {
|
}
|
||||||
log("* sent response through %s\n", relay.URL)
|
}
|
||||||
} else {
|
|
||||||
log("* failed to send response: %s\n", err)
|
for res := range sys.Pool.PublishMany(ctx, relays, eventResponse) {
|
||||||
}
|
if res.Error == nil {
|
||||||
printLock.Unlock()
|
log("* sent response through %s\n", res.Relay.URL)
|
||||||
}
|
} else {
|
||||||
}(relayURL)
|
log("* failed to send response through %s: %s\n", res.RelayURL, res.Error)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handlerWg.Wait()
|
|
||||||
|
|
||||||
// just after handling one request we trigger this
|
// just after handling one request we trigger this
|
||||||
go func() {
|
go func() {
|
||||||
@@ -410,24 +503,44 @@ var bunker = &cli.Command{
|
|||||||
Name: "connect",
|
Name: "connect",
|
||||||
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
Usage: "use the client-initiated NostrConnect flow of NIP46",
|
||||||
ArgsUsage: "<nostrconnect-uri>",
|
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 {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
if c.Args().Len() != 1 {
|
if c.Args().Len() != 1 {
|
||||||
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
return fmt.Errorf("must be called with a nostrconnect://... uri")
|
||||||
}
|
}
|
||||||
|
|
||||||
uri, err := url.Parse(c.Args().First())
|
if err := sendToSocket(c, c.Args().First()); err != nil {
|
||||||
if err != nil || uri.Scheme != "nostrconnect" {
|
return fmt.Errorf("failed to connect to running bunker: %w", err)
|
||||||
return fmt.Errorf("invalid uri")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO
|
return nil
|
||||||
|
|
||||||
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 {
|
type plainOrEncryptedKey struct {
|
||||||
Plain *nostr.SecretKey
|
Plain *nostr.SecretKey
|
||||||
Encrypted *string
|
Encrypted *string
|
||||||
@@ -495,3 +608,89 @@ func (a plainOrEncryptedKey) equals(b plainOrEncryptedKey) bool {
|
|||||||
|
|
||||||
return true
|
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
|
||||||
|
}
|
||||||
|
|||||||
2
event.go
2
event.go
@@ -145,7 +145,7 @@ example:
|
|||||||
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
if relayUrls := c.Args().Slice(); len(relayUrls) > 0 {
|
||||||
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
relays = connectToAllRelays(ctx, c, relayUrls, nil,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
52
fs.go
52
fs.go
@@ -1,4 +1,4 @@
|
|||||||
//go:build !windows && !openbsd
|
//go:build !windows && !openbsd && !cgofuse
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -13,8 +13,10 @@ import (
|
|||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/keyer"
|
"fiatjaf.com/nostr/keyer"
|
||||||
"github.com/fatih/color"
|
"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/urfave/cli/v3"
|
||||||
"github.com/winfsp/cgofuse/fuse"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var fsCmd = &cli.Command{
|
var fsCmd = &cli.Command{
|
||||||
@@ -62,7 +64,7 @@ var fsCmd = &cli.Command{
|
|||||||
apat = time.Hour * 24 * 365 * 3
|
apat = time.Hour * 24 * 365 * 3
|
||||||
}
|
}
|
||||||
|
|
||||||
root := NewFSRoot(
|
root := nostrfs.NewNostrRoot(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -73,7 +75,7 @@ var fsCmd = &cli.Command{
|
|||||||
sys,
|
sys,
|
||||||
kr,
|
kr,
|
||||||
mountpoint,
|
mountpoint,
|
||||||
FSOptions{
|
nostrfs.Options{
|
||||||
AutoPublishNotesTimeout: apnt,
|
AutoPublishNotesTimeout: apnt,
|
||||||
AutoPublishArticlesTimeout: apat,
|
AutoPublishArticlesTimeout: apat,
|
||||||
},
|
},
|
||||||
@@ -81,22 +83,21 @@ var fsCmd = &cli.Command{
|
|||||||
|
|
||||||
// create the server
|
// create the server
|
||||||
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||||
|
timeout := time.Second * 120
|
||||||
// create cgofuse host
|
server, err := fs.Mount(mountpoint, root, &fs.Options{
|
||||||
host := fuse.NewFileSystemHost(root)
|
MountOptions: fuse.MountOptions{
|
||||||
host.SetCapReaddirPlus(true)
|
Debug: isVerbose,
|
||||||
host.SetUseIno(true)
|
Name: "nak",
|
||||||
|
FsName: "nak",
|
||||||
// mount the filesystem
|
RememberInodes: true,
|
||||||
mountArgs := []string{"-s", mountpoint}
|
},
|
||||||
if isVerbose {
|
AttrTimeout: &timeout,
|
||||||
mountArgs = append([]string{"-d"}, mountArgs...)
|
EntryTimeout: &timeout,
|
||||||
|
Logger: nostr.DebugLogger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("mount failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
|
||||||
host.Mount("", mountArgs)
|
|
||||||
}()
|
|
||||||
|
|
||||||
log("ok.\n")
|
log("ok.\n")
|
||||||
|
|
||||||
// setup signal handling for clean unmount
|
// setup signal handling for clean unmount
|
||||||
@@ -106,12 +107,17 @@ var fsCmd = &cli.Command{
|
|||||||
go func() {
|
go func() {
|
||||||
<-ch
|
<-ch
|
||||||
log("- unmounting... ")
|
log("- unmounting... ")
|
||||||
// cgofuse doesn't have explicit unmount, it unmounts on process exit
|
err := server.Unmount()
|
||||||
log("ok\n")
|
if err != nil {
|
||||||
chErr <- nil
|
chErr <- fmt.Errorf("unmount failed: %w", err)
|
||||||
|
} else {
|
||||||
|
log("ok\n")
|
||||||
|
chErr <- nil
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// wait for signals
|
// serve the filesystem until unmounted
|
||||||
|
server.Wait()
|
||||||
return <-chErr
|
return <-chErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
118
fs_cgo.go
Normal file
118
fs_cgo.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
//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
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
"fiatjaf.com/nostr/keyer"
|
"fiatjaf.com/nostr/keyer"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
|
nostrfs "github.com/fiatjaf/nak/nostrfs_cgo"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
"github.com/winfsp/cgofuse/fuse"
|
"github.com/winfsp/cgofuse/fuse"
|
||||||
)
|
)
|
||||||
@@ -61,7 +62,7 @@ var fsCmd = &cli.Command{
|
|||||||
apat = time.Hour * 24 * 365 * 3
|
apat = time.Hour * 24 * 365 * 3
|
||||||
}
|
}
|
||||||
|
|
||||||
root := NewFSRoot(
|
root := nostrfs.NewNostrRoot(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -72,7 +73,7 @@ var fsCmd = &cli.Command{
|
|||||||
sys,
|
sys,
|
||||||
kr,
|
kr,
|
||||||
mountpoint,
|
mountpoint,
|
||||||
FSOptions{
|
nostrfs.Options{
|
||||||
AutoPublishNotesTimeout: apnt,
|
AutoPublishNotesTimeout: apnt,
|
||||||
AutoPublishArticlesTimeout: apat,
|
AutoPublishArticlesTimeout: apat,
|
||||||
},
|
},
|
||||||
|
|||||||
162
git.go
162
git.go
@@ -181,7 +181,7 @@ aside from those, there is also:
|
|||||||
var fetchedRepo *nip34.Repository
|
var fetchedRepo *nip34.Repository
|
||||||
if existingConfig.Identifier == "" {
|
if existingConfig.Identifier == "" {
|
||||||
log(" searching for existing events... ")
|
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 {
|
if err == nil && repo.Event.ID != nostr.ZeroID {
|
||||||
fetchedRepo = &repo
|
fetchedRepo = &repo
|
||||||
log("found one from %s.\n", repo.Event.CreatedAt.Time().Format(time.DateOnly))
|
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
|
// fetch repository metadata and state
|
||||||
repo, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
repo, _, _, state, err := fetchRepositoryAndState(ctx, owner, identifier, relayHints)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -782,6 +782,98 @@ aside from those, there is also:
|
|||||||
return err
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -869,7 +961,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository announcement and state from relays
|
// fetch repository announcement and state from relays
|
||||||
repo, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
repo, upToDateAnnouncementEvent, upToDateRelays, state, err := fetchRepositoryAndState(ctx, owner, localConfig.Identifier, localConfig.GraspServers)
|
||||||
notUpToDate := func(graspServer string) bool {
|
notUpToDate := func(graspServer string) bool {
|
||||||
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
return !slices.Contains(upToDateRelays, nostr.NormalizeURL(graspServer))
|
||||||
}
|
}
|
||||||
@@ -889,33 +981,40 @@ 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)
|
log("some grasp servers (%v) are not up-to-date, will publish to them\n", relays)
|
||||||
}
|
}
|
||||||
// create a local repository object from config and publish it
|
var event nostr.Event
|
||||||
localRepo := localConfig.ToRepository()
|
if upToDateAnnouncementEvent != nil {
|
||||||
|
// publish the latest event to the other relays
|
||||||
if signer != nil {
|
event = *upToDateAnnouncementEvent
|
||||||
signerPk, err := signer.GetPublicKey(ctx)
|
repo = nip34.ParseRepository(event)
|
||||||
if err != nil {
|
} else {
|
||||||
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 signerPk != owner {
|
if signer != nil {
|
||||||
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
signerPk, err := signer.GetPublicKey(ctx)
|
||||||
} else {
|
if err != nil {
|
||||||
event := localRepo.ToEvent()
|
return repo, nil, fmt.Errorf("failed to get signer pubkey: %w", err)
|
||||||
if err := signer.SignEvent(ctx, &event); err != nil {
|
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
|
||||||
}
|
}
|
||||||
|
if signerPk != owner {
|
||||||
for res := range sys.Pool.PublishMany(ctx, relays, event) {
|
return repo, nil, fmt.Errorf("provided signer pubkey does not match owner, can't publish repository")
|
||||||
if res.Error != nil {
|
} else {
|
||||||
log("! error publishing to %s: %v\n", color.YellowString(res.RelayURL), res.Error)
|
event = localRepo.ToEvent()
|
||||||
} else {
|
if err := signer.SignEvent(ctx, &event); err != nil {
|
||||||
log("> published to %s\n", color.GreenString(res.RelayURL))
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
repo = localRepo
|
} 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))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return repo, nil, fmt.Errorf("no signer provided to publish repository (run 'nak git sync' with the '--sec' flag)")
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -951,6 +1050,7 @@ func gitSync(ctx context.Context, signer nostr.Keyer) (nip34.Repository, *nip34.
|
|||||||
} else {
|
} else {
|
||||||
log("local configuration is newer, publishing updated repository announcement...\n")
|
log("local configuration is newer, publishing updated repository announcement...\n")
|
||||||
announcementEvent := localRepo.ToEvent()
|
announcementEvent := localRepo.ToEvent()
|
||||||
|
announcementEvent.CreatedAt = nostr.Timestamp(configModTime.Unix())
|
||||||
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
if err := signer.SignEvent(ctx, &announcementEvent); err != nil {
|
||||||
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
return repo, state, fmt.Errorf("failed to sign announcement: %w", err)
|
||||||
}
|
}
|
||||||
@@ -1155,7 +1255,7 @@ func fetchRepositoryAndState(
|
|||||||
pubkey nostr.PubKey,
|
pubkey nostr.PubKey,
|
||||||
identifier string,
|
identifier string,
|
||||||
relayHints []string,
|
relayHints []string,
|
||||||
) (repo nip34.Repository, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
) (repo nip34.Repository, upToDateAnnouncementEvent *nostr.Event, upToDateRelays []string, state *nip34.RepositoryState, err error) {
|
||||||
// fetch repository announcement (30617)
|
// fetch repository announcement (30617)
|
||||||
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
relays := appendUnique(relayHints, sys.FetchOutboxRelays(ctx, pubkey, 3)...)
|
||||||
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
for ie := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
@@ -1176,13 +1276,15 @@ func fetchRepositoryAndState(
|
|||||||
|
|
||||||
// reset this list as the previous was for relays with the older version
|
// reset this list as the previous was for relays with the older version
|
||||||
upToDateRelays = []string{ie.Relay.URL}
|
upToDateRelays = []string{ie.Relay.URL}
|
||||||
|
|
||||||
|
upToDateAnnouncementEvent = &ie.Event
|
||||||
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
} else if ie.Event.CreatedAt == repo.CreatedAt {
|
||||||
// we discard this because it's the same, but this relay is up-to-date
|
// we discard this because it's the same, but this relay is up-to-date
|
||||||
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
upToDateRelays = append(upToDateRelays, ie.Relay.URL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if repo.Event.ID == nostr.ZeroID {
|
if repo.Event.ID == nostr.ZeroID {
|
||||||
return repo, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
return repo, nil, upToDateRelays, state, fmt.Errorf("no repository announcement (kind 30617) found for %s", identifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch repository state (30618)
|
// fetch repository state (30618)
|
||||||
@@ -1212,10 +1314,10 @@ func fetchRepositoryAndState(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stateErr != nil {
|
if stateErr != nil {
|
||||||
return repo, upToDateRelays, state, stateErr
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, stateErr
|
||||||
}
|
}
|
||||||
|
|
||||||
return repo, upToDateRelays, state, nil
|
return repo, upToDateAnnouncementEvent, upToDateRelays, state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type StateErr struct{ string }
|
type StateErr struct{ string }
|
||||||
|
|||||||
9
go.mod
9
go.mod
@@ -3,8 +3,7 @@ module github.com/fiatjaf/nak
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/lib v0.3.1
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6
|
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||||
github.com/bep/debounce v1.2.1
|
github.com/bep/debounce v1.2.1
|
||||||
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
github.com/btcsuite/btcd/btcec/v2 v2.3.6
|
||||||
@@ -12,7 +11,6 @@ require (
|
|||||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0
|
||||||
github.com/fatih/color v1.16.0
|
github.com/fatih/color v1.16.0
|
||||||
|
|
||||||
github.com/json-iterator/go v1.1.12
|
github.com/json-iterator/go v1.1.12
|
||||||
github.com/liamg/magic v0.0.1
|
github.com/liamg/magic v0.0.1
|
||||||
github.com/mailru/easyjson v0.9.1
|
github.com/mailru/easyjson v0.9.1
|
||||||
@@ -30,6 +28,11 @@ require (
|
|||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
fiatjaf.com/lib v0.3.2
|
||||||
|
github.com/hanwen/go-fuse/v2 v2.9.0
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
github.com/FastFilter/xorfilter v0.2.1 // indirect
|
||||||
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
github.com/ImVexed/fasturl v0.0.0-20230304231329-4e41488060f3 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -1,7 +1,7 @@
|
|||||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
fiatjaf.com/lib v0.3.2 h1:RBS41z70d8Rp8e2nemQsbPY1NLLnEGShiY2c+Bom3+Q=
|
||||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
fiatjaf.com/lib v0.3.2/go.mod h1:UlHaZvPHj25PtKLh9GjZkUHRmQ2xZ8Jkoa4VRaLeeQ8=
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4 h1:1KAEp9ktrnm7pB/o2QrdRT3dJyXYwei8N9RRRppiMFY=
|
||||||
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
fiatjaf.com/nostr v0.0.0-20260122014616-241959d1e3f4/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
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/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||||
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
github.com/FastFilter/xorfilter v0.2.1 h1:lbdeLG9BdpquK64ZsleBS8B4xO/QW1IM0gMzF7KaBKc=
|
||||||
|
|||||||
586
group.go
Normal file
586
group.go
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
22
helpers.go
22
helpers.go
@@ -536,21 +536,25 @@ func decodeTagValue(value string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var colors = struct {
|
var colors = struct {
|
||||||
reset func(...any) (int, error)
|
reset func(...any) (int, error)
|
||||||
italic func(...any) string
|
italic func(...any) string
|
||||||
italicf func(string, ...any) string
|
italicf func(string, ...any) string
|
||||||
bold func(...any) string
|
bold func(...any) string
|
||||||
boldf func(string, ...any) string
|
boldf func(string, ...any) string
|
||||||
error func(...any) string
|
underline func(...any) string
|
||||||
errorf func(string, ...any) string
|
underlinef func(string, ...any) string
|
||||||
success func(...any) string
|
error func(...any) string
|
||||||
successf func(string, ...any) string
|
errorf func(string, ...any) string
|
||||||
|
success func(...any) string
|
||||||
|
successf func(string, ...any) string
|
||||||
}{
|
}{
|
||||||
color.New(color.Reset).Print,
|
color.New(color.Reset).Print,
|
||||||
color.New(color.Italic).Sprint,
|
color.New(color.Italic).Sprint,
|
||||||
color.New(color.Italic).Sprintf,
|
color.New(color.Italic).Sprintf,
|
||||||
color.New(color.Bold).Sprint,
|
color.New(color.Bold).Sprint,
|
||||||
color.New(color.Bold).Sprintf,
|
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).Sprint,
|
||||||
color.New(color.Bold, color.FgHiRed).Sprintf,
|
color.New(color.Bold, color.FgHiRed).Sprintf,
|
||||||
color.New(color.Bold, color.FgHiGreen).Sprint,
|
color.New(color.Bold, color.FgHiGreen).Sprint,
|
||||||
|
|||||||
1
main.go
1
main.go
@@ -50,6 +50,7 @@ var app = &cli.Command{
|
|||||||
fsCmd,
|
fsCmd,
|
||||||
publish,
|
publish,
|
||||||
git,
|
git,
|
||||||
|
group,
|
||||||
nip,
|
nip,
|
||||||
syncCmd,
|
syncCmd,
|
||||||
spell,
|
spell,
|
||||||
|
|||||||
56
nostrfs/asyncfile.go
Normal file
56
nostrfs/asyncfile.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AsyncFile struct {
|
||||||
|
fs.Inode
|
||||||
|
ctx context.Context
|
||||||
|
fetched atomic.Bool
|
||||||
|
data []byte
|
||||||
|
ts nostr.Timestamp
|
||||||
|
load func() ([]byte, nostr.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*AsyncFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
if af.fetched.CompareAndSwap(false, true) {
|
||||||
|
af.data, af.ts = af.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Size = uint64(len(af.data))
|
||||||
|
out.Mtime = uint64(af.ts)
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
||||||
|
if af.fetched.CompareAndSwap(false, true) {
|
||||||
|
af.data, af.ts = af.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (af *AsyncFile) Read(
|
||||||
|
ctx context.Context,
|
||||||
|
f fs.FileHandle,
|
||||||
|
dest []byte,
|
||||||
|
off int64,
|
||||||
|
) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(af.data) {
|
||||||
|
end = len(af.data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(af.data[off:end]), 0
|
||||||
|
}
|
||||||
50
nostrfs/deterministicfile.go
Normal file
50
nostrfs/deterministicfile.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DeterministicFile struct {
|
||||||
|
fs.Inode
|
||||||
|
get func() (ctime, mtime uint64, data string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
|
||||||
|
_ = (fs.NodeReader)((*DeterministicFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
|
||||||
|
return &DeterministicFile{
|
||||||
|
get: get,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
var content string
|
||||||
|
out.Mode = 0444
|
||||||
|
out.Ctime, out.Mtime, content = f.get()
|
||||||
|
out.Size = uint64(len(content))
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
_, _, content := f.get()
|
||||||
|
data := unsafe.Slice(unsafe.StringData(content), len(content))
|
||||||
|
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(data) {
|
||||||
|
end = len(data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(data[off:end]), fs.OK
|
||||||
|
}
|
||||||
408
nostrfs/entitydir.go
Normal file
408
nostrfs/entitydir.go
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"fiatjaf.com/lib/debouncer"
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
"fiatjaf.com/nostr/nip27"
|
||||||
|
"fiatjaf.com/nostr/nip73"
|
||||||
|
"fiatjaf.com/nostr/nip92"
|
||||||
|
sdk "fiatjaf.com/nostr/sdk"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EntityDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
|
||||||
|
publisher *debouncer.Debouncer
|
||||||
|
event *nostr.Event
|
||||||
|
updating struct {
|
||||||
|
title string
|
||||||
|
content string
|
||||||
|
publishedAt uint64
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeCreater)((*EntityDir)(nil))
|
||||||
|
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
out.Ctime = uint64(e.event.CreatedAt)
|
||||||
|
if e.updating.publishedAt != 0 {
|
||||||
|
out.Mtime = e.updating.publishedAt
|
||||||
|
} else {
|
||||||
|
out.Mtime = e.PublishedAt()
|
||||||
|
}
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Create(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
flags uint32,
|
||||||
|
mode uint32,
|
||||||
|
out *fuse.EntryOut,
|
||||||
|
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
if name == "publish" && e.publisher.IsRunning() {
|
||||||
|
// this causes the publish process to be triggered faster
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing now!\n")
|
||||||
|
e.publisher.Flush()
|
||||||
|
return nil, nil, 0, syscall.ENOTDIR
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||||
|
switch name {
|
||||||
|
case "content" + kindToExtension(e.event.Kind):
|
||||||
|
e.updating.content = e.event.Content
|
||||||
|
return syscall.ENOTDIR
|
||||||
|
case "title":
|
||||||
|
e.updating.title = e.Title()
|
||||||
|
return syscall.ENOTDIR
|
||||||
|
default:
|
||||||
|
return syscall.EINTR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
e.updating.publishedAt = in.Mtime
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) OnAdd(_ context.Context) {
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
e.AddChild("@author", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("event.json", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
eventj, _ := json.MarshalIndent(e.event, "", " ")
|
||||||
|
return uint64(e.event.CreatedAt),
|
||||||
|
uint64(e.event.CreatedAt),
|
||||||
|
unsafe.String(unsafe.SliceData(eventj), len(eventj))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("identifier", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(e.event.Tags.GetD()),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(e.event.CreatedAt),
|
||||||
|
Mtime: uint64(e.event.CreatedAt),
|
||||||
|
Size: uint64(len(e.event.Tags.GetD())),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
|
||||||
|
// read-only
|
||||||
|
e.AddChild("title", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&DeterministicFile{
|
||||||
|
get: func() (ctime uint64, mtime uint64, data string) {
|
||||||
|
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
} else {
|
||||||
|
// writeable
|
||||||
|
e.updating.title = e.Title()
|
||||||
|
e.updating.publishedAt = e.PublishedAt()
|
||||||
|
e.updating.content = e.event.Content
|
||||||
|
|
||||||
|
e.AddChild("title", e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||||
|
log("title updated")
|
||||||
|
e.updating.title = strings.TrimSpace(s)
|
||||||
|
e.handleWrite()
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
||||||
|
log("content updated")
|
||||||
|
e.updating.content = strings.TrimSpace(s)
|
||||||
|
e.handleWrite()
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refsdir *fs.Inode
|
||||||
|
i := 0
|
||||||
|
for ref := range nip27.Parse(e.event.Content) {
|
||||||
|
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
|
||||||
|
if refsdir == nil {
|
||||||
|
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
e.root.AddChild("references", refsdir, true)
|
||||||
|
}
|
||||||
|
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imagesdir *fs.Inode
|
||||||
|
addImage := func(url string) {
|
||||||
|
if imagesdir == nil {
|
||||||
|
in := &fs.Inode{}
|
||||||
|
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
e.AddChild("images", imagesdir, true)
|
||||||
|
}
|
||||||
|
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
|
||||||
|
e.root.ctx,
|
||||||
|
&AsyncFile{
|
||||||
|
ctx: e.root.ctx,
|
||||||
|
load: func() ([]byte, nostr.Timestamp) {
|
||||||
|
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
log("failed to load image %s: %s\n", url, err)
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
return w.Bytes(), 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
images := nip92.ParseTags(e.event.Tags)
|
||||||
|
for _, imeta := range images {
|
||||||
|
if imeta.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addImage(imeta.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tag := e.event.Tags.Find("image"); tag != nil {
|
||||||
|
addImage(tag[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) IsNew() bool {
|
||||||
|
return e.event.CreatedAt == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) PublishedAt() uint64 {
|
||||||
|
if tag := e.event.Tags.Find("published_at"); tag != nil {
|
||||||
|
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
|
||||||
|
return publishedAt
|
||||||
|
}
|
||||||
|
return uint64(e.event.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) Title() string {
|
||||||
|
if tag := e.event.Tags.Find("title"); tag != nil {
|
||||||
|
return tag[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EntityDir) handleWrite() {
|
||||||
|
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
|
||||||
|
if e.publisher.IsRunning() {
|
||||||
|
log(", timer reset")
|
||||||
|
}
|
||||||
|
log(", publishing the ")
|
||||||
|
if e.IsNew() {
|
||||||
|
log("new")
|
||||||
|
} else {
|
||||||
|
log("updated")
|
||||||
|
}
|
||||||
|
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
|
||||||
|
} else {
|
||||||
|
log(".\n")
|
||||||
|
}
|
||||||
|
if !e.publisher.IsRunning() {
|
||||||
|
log("- `touch publish` to publish immediately\n")
|
||||||
|
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
e.publisher.Call(func() {
|
||||||
|
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
|
||||||
|
log("not modified, publish canceled.\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: e.event.Kind,
|
||||||
|
Content: e.updating.content,
|
||||||
|
Tags: make(nostr.Tags, len(e.event.Tags)),
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
}
|
||||||
|
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
|
||||||
|
if e.updating.title != "" {
|
||||||
|
if titleTag := evt.Tags.Find("title"); titleTag != nil {
|
||||||
|
titleTag[1] = e.updating.title
|
||||||
|
} else {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "published_at" tag
|
||||||
|
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
|
||||||
|
if publishedAtStr != "0" {
|
||||||
|
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
|
||||||
|
publishedAtTag[1] = publishedAtStr
|
||||||
|
} else {
|
||||||
|
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add "p" tags from people mentioned and "q" tags from events mentioned
|
||||||
|
for ref := range nip27.Parse(evt.Content) {
|
||||||
|
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tag := ref.Pointer.AsTag()
|
||||||
|
key := tag[0]
|
||||||
|
val := tag[1]
|
||||||
|
if key == "e" || key == "a" {
|
||||||
|
key = "q"
|
||||||
|
}
|
||||||
|
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
||||||
|
evt.Tags = append(evt.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign and publish
|
||||||
|
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
|
||||||
|
log("failed to sign: '%s'.\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logverbose("%s\n", evt)
|
||||||
|
|
||||||
|
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
log("publishing to %d relays... ", len(relays))
|
||||||
|
success := false
|
||||||
|
first := true
|
||||||
|
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
log("%s: ok", color.GreenString(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
|
||||||
|
if success {
|
||||||
|
e.event = &evt
|
||||||
|
log("event updated locally.\n")
|
||||||
|
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
|
||||||
|
} else {
|
||||||
|
log("failed.\n")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) FetchAndCreateEntityDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
extension string,
|
||||||
|
pointer nostr.EntityPointer,
|
||||||
|
) (*fs.Inode, error) {
|
||||||
|
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.CreateEntityDir(parent, event), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateEntityDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
event *nostr.Event,
|
||||||
|
) *fs.Inode {
|
||||||
|
return parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
)
|
||||||
|
}
|
||||||
241
nostrfs/eventdir.go
Normal file
241
nostrfs/eventdir.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip10"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
"fiatjaf.com/nostr/nip22"
|
||||||
|
"fiatjaf.com/nostr/nip27"
|
||||||
|
"fiatjaf.com/nostr/nip73"
|
||||||
|
"fiatjaf.com/nostr/nip92"
|
||||||
|
sdk "fiatjaf.com/nostr/sdk"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EventDir struct {
|
||||||
|
fs.Inode
|
||||||
|
ctx context.Context
|
||||||
|
wd string
|
||||||
|
evt *nostr.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
|
||||||
|
|
||||||
|
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
out.Mtime = uint64(e.evt.CreatedAt)
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) FetchAndCreateEventDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
pointer nostr.EventPointer,
|
||||||
|
) (*fs.Inode, error) {
|
||||||
|
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
||||||
|
WithRelays: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.CreateEventDir(parent, event), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateEventDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
event *nostr.Event,
|
||||||
|
) *fs.Inode {
|
||||||
|
h := parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
|
||||||
|
)
|
||||||
|
|
||||||
|
h.AddChild("@author", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
eventj, _ := json.MarshalIndent(event, "", " ")
|
||||||
|
h.AddChild("event.json", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: eventj,
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(len(event.Content)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
h.AddChild("id", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(event.ID.Hex()),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(64),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
h.AddChild("content.txt", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(event.Content),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mode: 0444,
|
||||||
|
Ctime: uint64(event.CreatedAt),
|
||||||
|
Mtime: uint64(event.CreatedAt),
|
||||||
|
Size: uint64(len(event.Content)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
var refsdir *fs.Inode
|
||||||
|
i := 0
|
||||||
|
for ref := range nip27.Parse(event.Content) {
|
||||||
|
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
|
||||||
|
if refsdir == nil {
|
||||||
|
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
h.AddChild("references", refsdir, true)
|
||||||
|
}
|
||||||
|
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var imagesdir *fs.Inode
|
||||||
|
images := nip92.ParseTags(event.Tags)
|
||||||
|
for _, imeta := range images {
|
||||||
|
if imeta.URL == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if imagesdir == nil {
|
||||||
|
in := &fs.Inode{}
|
||||||
|
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
||||||
|
h.AddChild("images", imagesdir, true)
|
||||||
|
}
|
||||||
|
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&AsyncFile{
|
||||||
|
ctx: r.ctx,
|
||||||
|
load: func() ([]byte, nostr.Timestamp) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, 0
|
||||||
|
}
|
||||||
|
w := &bytes.Buffer{}
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
return w.Bytes(), 0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Kind == 1 {
|
||||||
|
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
|
||||||
|
nevent := nip19.EncodePointer(pointer)
|
||||||
|
h.AddChild("@root", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
|
||||||
|
nevent := nip19.EncodePointer(pointer)
|
||||||
|
h.AddChild("@parent", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{
|
||||||
|
Data: []byte(r.wd + "/" + nevent),
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
} else if event.Kind == 1111 {
|
||||||
|
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
|
||||||
|
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
||||||
|
h.AddChild("@root", h.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: []byte(`<!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
|
||||||
|
}
|
||||||
16
nostrfs/helpers.go
Normal file
16
nostrfs/helpers.go
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func kindToExtension(kind nostr.Kind) string {
|
||||||
|
switch kind {
|
||||||
|
case 30023:
|
||||||
|
return "md"
|
||||||
|
case 30818:
|
||||||
|
return "adoc"
|
||||||
|
default:
|
||||||
|
return "txt"
|
||||||
|
}
|
||||||
|
}
|
||||||
261
nostrfs/npubdir.go
Normal file
261
nostrfs/npubdir.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
"github.com/liamg/magic"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NpubDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
pointer nostr.ProfilePointer
|
||||||
|
fetched atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
|
||||||
|
|
||||||
|
func (r *NostrRoot) CreateNpubDir(
|
||||||
|
parent fs.InodeEmbedder,
|
||||||
|
pointer nostr.ProfilePointer,
|
||||||
|
signer nostr.Signer,
|
||||||
|
) *fs.Inode {
|
||||||
|
npubdir := &NpubDir{root: r, pointer: pointer}
|
||||||
|
return parent.EmbeddedInode().NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
npubdir,
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *NpubDir) OnAdd(_ context.Context) {
|
||||||
|
log := h.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
|
||||||
|
log("- adding folder for %s with relays %s\n",
|
||||||
|
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
|
||||||
|
|
||||||
|
h.AddChild("pubkey", h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
|
||||||
|
if pm.Event == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataj, _ := json.MarshalIndent(pm, "", " ")
|
||||||
|
h.AddChild(
|
||||||
|
"metadata.json",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: metadataj,
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mtime: uint64(pm.Event.CreatedAt),
|
||||||
|
Mode: 0444,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
|
||||||
|
if err == nil {
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode < 300 {
|
||||||
|
b := &bytes.Buffer{}
|
||||||
|
io.Copy(b, resp.Body)
|
||||||
|
|
||||||
|
ext := "png"
|
||||||
|
if ft, err := magic.Lookup(b.Bytes()); err == nil {
|
||||||
|
ext = ft.Extension
|
||||||
|
}
|
||||||
|
|
||||||
|
h.AddChild("picture."+ext, h.NewPersistentInode(
|
||||||
|
ctx,
|
||||||
|
&fs.MemRegularFile{
|
||||||
|
Data: b.Bytes(),
|
||||||
|
Attr: fuse.Attr{
|
||||||
|
Mtime: uint64(pm.Event.CreatedAt),
|
||||||
|
Mode: 0444,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fs.StableAttr{},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if h.GetChild("notes") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"notes",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{1},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("comments") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"comments",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{1111},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("photos") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"photos",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{20},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: true,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("videos") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"videos",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{21, 22},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("highlights") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"highlights",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{9802},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: false,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("articles") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"articles",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{30023},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: true,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.GetChild("wiki") == nil {
|
||||||
|
h.AddChild(
|
||||||
|
"wiki",
|
||||||
|
h.NewPersistentInode(
|
||||||
|
h.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: h.root,
|
||||||
|
filter: nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{30818},
|
||||||
|
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
||||||
|
},
|
||||||
|
paginate: false,
|
||||||
|
relays: relays,
|
||||||
|
replaceable: true,
|
||||||
|
createable: true,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
nostrfs/root.go
Normal file
130
nostrfs/root.go
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"path/filepath"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip05"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
|
"fiatjaf.com/nostr/sdk"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
AutoPublishNotesTimeout time.Duration
|
||||||
|
AutoPublishArticlesTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type NostrRoot struct {
|
||||||
|
fs.Inode
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
wd string
|
||||||
|
sys *sdk.System
|
||||||
|
rootPubKey nostr.PubKey
|
||||||
|
signer nostr.Signer
|
||||||
|
|
||||||
|
opts Options
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
|
||||||
|
|
||||||
|
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
|
||||||
|
pubkey, _ := user.GetPublicKey(ctx)
|
||||||
|
abs, _ := filepath.Abs(mountpoint)
|
||||||
|
|
||||||
|
var signer nostr.Signer
|
||||||
|
if user != nil {
|
||||||
|
signer, _ = user.(nostr.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &NostrRoot{
|
||||||
|
ctx: ctx,
|
||||||
|
sys: sys,
|
||||||
|
rootPubKey: pubkey,
|
||||||
|
signer: signer,
|
||||||
|
wd: abs,
|
||||||
|
|
||||||
|
opts: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) OnAdd(_ context.Context) {
|
||||||
|
if r.rootPubKey == nostr.ZeroPK {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(time.Millisecond * 100)
|
||||||
|
|
||||||
|
// add our contacts
|
||||||
|
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
|
||||||
|
for _, f := range fl.Items {
|
||||||
|
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
|
||||||
|
r.AddChild(
|
||||||
|
nip19.EncodeNpub(f.Pubkey),
|
||||||
|
r.CreateNpubDir(r, pointer, nil),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add ourselves
|
||||||
|
npub := nip19.EncodeNpub(r.rootPubKey)
|
||||||
|
if r.GetChild(npub) == nil {
|
||||||
|
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
|
||||||
|
|
||||||
|
r.AddChild(
|
||||||
|
npub,
|
||||||
|
r.CreateNpubDir(r, pointer, r.signer),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add a link to ourselves
|
||||||
|
r.AddChild("@me", r.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), true)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||||
|
out.SetEntryTimeout(time.Minute * 5)
|
||||||
|
|
||||||
|
child := r.GetChild(name)
|
||||||
|
if child != nil {
|
||||||
|
return child, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
|
||||||
|
return r.NewPersistentInode(
|
||||||
|
r.ctx,
|
||||||
|
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFLNK},
|
||||||
|
), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
pointer, err := nip19.ToPointer(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p := pointer.(type) {
|
||||||
|
case nostr.ProfilePointer:
|
||||||
|
npubdir := r.CreateNpubDir(r, p, nil)
|
||||||
|
return npubdir, fs.OK
|
||||||
|
case nostr.EventPointer:
|
||||||
|
eventdir, err := r.FetchAndCreateEventDir(r, p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
return eventdir, fs.OK
|
||||||
|
default:
|
||||||
|
return nil, syscall.ENOENT
|
||||||
|
}
|
||||||
|
}
|
||||||
267
nostrfs/viewdir.go
Normal file
267
nostrfs/viewdir.go
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"fiatjaf.com/lib/debouncer"
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ViewDir struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
fetched atomic.Bool
|
||||||
|
filter nostr.Filter
|
||||||
|
paginate bool
|
||||||
|
relays []string
|
||||||
|
replaceable bool
|
||||||
|
createable bool
|
||||||
|
publisher *debouncer.Debouncer
|
||||||
|
publishing struct {
|
||||||
|
note string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeCreater)((*ViewDir)(nil))
|
||||||
|
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Create(
|
||||||
|
_ context.Context,
|
||||||
|
name string,
|
||||||
|
flags uint32,
|
||||||
|
mode uint32,
|
||||||
|
out *fuse.EntryOut,
|
||||||
|
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return nil, nil, 0, syscall.EPERM
|
||||||
|
}
|
||||||
|
if n.publisher == nil {
|
||||||
|
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||||
|
}
|
||||||
|
if n.filter.Kinds[0] != 1 {
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "new":
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
if n.publisher.IsRunning() {
|
||||||
|
log("pending note updated, timer reset.")
|
||||||
|
} else {
|
||||||
|
log("new note detected")
|
||||||
|
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
|
||||||
|
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
|
||||||
|
} else {
|
||||||
|
log(".\n")
|
||||||
|
}
|
||||||
|
log("- `touch publish` to publish immediately\n")
|
||||||
|
log("- `rm new` to erase and cancel the publication.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
n.publisher.Call(n.publishNote)
|
||||||
|
|
||||||
|
first := true
|
||||||
|
|
||||||
|
return n.NewPersistentInode(
|
||||||
|
n.root.ctx,
|
||||||
|
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
|
||||||
|
if !first {
|
||||||
|
log("pending note updated, timer reset.\n")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
n.publishing.note = strings.TrimSpace(s)
|
||||||
|
n.publisher.Call(n.publishNote)
|
||||||
|
}),
|
||||||
|
fs.StableAttr{},
|
||||||
|
), nil, 0, fs.OK
|
||||||
|
case "publish":
|
||||||
|
if n.publisher.IsRunning() {
|
||||||
|
// this causes the publish process to be triggered faster
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing now!\n")
|
||||||
|
n.publisher.Flush()
|
||||||
|
return nil, nil, 0, syscall.ENOTDIR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil, 0, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||||
|
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return syscall.EPERM
|
||||||
|
}
|
||||||
|
if n.publisher == nil {
|
||||||
|
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
||||||
|
}
|
||||||
|
if n.filter.Kinds[0] != 1 {
|
||||||
|
return syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "new":
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
log("publishing canceled.\n")
|
||||||
|
n.publisher.Stop()
|
||||||
|
n.publishing.note = ""
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
return syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) publishNote() {
|
||||||
|
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
||||||
|
|
||||||
|
log("publishing note...\n")
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: 1,
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Content: n.publishing.note,
|
||||||
|
Tags: make(nostr.Tags, 0, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
// our write relays
|
||||||
|
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
|
||||||
|
if len(relays) == 0 {
|
||||||
|
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// massage and extract tags from raw text
|
||||||
|
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
|
||||||
|
relays = nostr.AppendUnique(relays, targetRelays...)
|
||||||
|
|
||||||
|
// sign and publish
|
||||||
|
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
|
||||||
|
log("failed to sign: %s\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log(evt.String() + "\n")
|
||||||
|
|
||||||
|
log("publishing to %d relays... ", len(relays))
|
||||||
|
success := false
|
||||||
|
first := true
|
||||||
|
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
|
||||||
|
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
||||||
|
if !first {
|
||||||
|
log(", ")
|
||||||
|
}
|
||||||
|
first = false
|
||||||
|
|
||||||
|
if res.Error != nil {
|
||||||
|
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
||||||
|
} else {
|
||||||
|
success = true
|
||||||
|
log("%s: ok", color.GreenString(cleanUrl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log("\n")
|
||||||
|
|
||||||
|
if success {
|
||||||
|
n.RmChild("new")
|
||||||
|
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
|
||||||
|
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
now := nostr.Now()
|
||||||
|
if n.filter.Until != 0 {
|
||||||
|
now = n.filter.Until
|
||||||
|
}
|
||||||
|
aMonthAgo := now - 30*24*60*60
|
||||||
|
out.Mtime = uint64(aMonthAgo)
|
||||||
|
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
|
||||||
|
if n.fetched.CompareAndSwap(true, true) {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.paginate {
|
||||||
|
now := nostr.Now()
|
||||||
|
if n.filter.Until != 0 {
|
||||||
|
now = n.filter.Until
|
||||||
|
}
|
||||||
|
aMonthAgo := now - 30*24*60*60
|
||||||
|
n.filter.Since = aMonthAgo
|
||||||
|
|
||||||
|
filter := n.filter
|
||||||
|
filter.Until = aMonthAgo
|
||||||
|
|
||||||
|
n.AddChild("@previous", n.NewPersistentInode(
|
||||||
|
n.root.ctx,
|
||||||
|
&ViewDir{
|
||||||
|
root: n.root,
|
||||||
|
filter: filter,
|
||||||
|
relays: n.relays,
|
||||||
|
replaceable: n.replaceable,
|
||||||
|
},
|
||||||
|
fs.StableAttr{Mode: syscall.S_IFDIR},
|
||||||
|
), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replaceable {
|
||||||
|
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
|
||||||
|
Label: "nakfs",
|
||||||
|
}).Range {
|
||||||
|
name := rkey.D
|
||||||
|
if name == "" {
|
||||||
|
name = "_"
|
||||||
|
}
|
||||||
|
if n.GetChild(name) == nil {
|
||||||
|
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
|
||||||
|
nostr.SubscriptionOptions{
|
||||||
|
Label: "nakfs",
|
||||||
|
}) {
|
||||||
|
if n.GetChild(ie.Event.ID.Hex()) == nil {
|
||||||
|
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||||
|
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
|
||||||
|
return nil, syscall.ENOTSUP
|
||||||
|
}
|
||||||
|
|
||||||
|
if n.replaceable {
|
||||||
|
// create a template event that can later be modified and published as new
|
||||||
|
return n.root.CreateEntityDir(n, &nostr.Event{
|
||||||
|
PubKey: n.root.rootPubKey,
|
||||||
|
CreatedAt: 0,
|
||||||
|
Kind: n.filter.Kinds[0],
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
nostr.Tag{"d", name},
|
||||||
|
},
|
||||||
|
}), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, syscall.ENOTSUP
|
||||||
|
}
|
||||||
93
nostrfs/writeablefile.go
Normal file
93
nostrfs/writeablefile.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package nostrfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/hanwen/go-fuse/v2/fs"
|
||||||
|
"github.com/hanwen/go-fuse/v2/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WriteableFile struct {
|
||||||
|
fs.Inode
|
||||||
|
root *NostrRoot
|
||||||
|
mu sync.Mutex
|
||||||
|
data []byte
|
||||||
|
attr fuse.Attr
|
||||||
|
onWrite func(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ = (fs.NodeOpener)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeReader)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeWriter)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
|
||||||
|
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
|
||||||
|
return &WriteableFile{
|
||||||
|
root: r,
|
||||||
|
data: []byte(data),
|
||||||
|
attr: fuse.Attr{
|
||||||
|
Mode: 0666,
|
||||||
|
Ctime: ctime,
|
||||||
|
Mtime: mtime,
|
||||||
|
Size: uint64(len(data)),
|
||||||
|
},
|
||||||
|
onWrite: onWrite,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
||||||
|
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
|
||||||
|
offset := int(off)
|
||||||
|
end := offset + len(data)
|
||||||
|
if len(f.data) < end {
|
||||||
|
newData := make([]byte, offset+len(data))
|
||||||
|
copy(newData, f.data)
|
||||||
|
f.data = newData
|
||||||
|
}
|
||||||
|
copy(f.data[offset:], data)
|
||||||
|
f.data = f.data[0:end]
|
||||||
|
|
||||||
|
f.onWrite(string(f.data))
|
||||||
|
return uint32(len(data)), fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
out.Attr = f.attr
|
||||||
|
out.Attr.Size = uint64(len(f.data))
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
||||||
|
f.attr.Mtime = in.Mtime
|
||||||
|
f.attr.Atime = in.Atime
|
||||||
|
f.attr.Ctime = in.Ctime
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
||||||
|
return fs.OK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
end := int(off) + len(dest)
|
||||||
|
if end > len(f.data) {
|
||||||
|
end = len(f.data)
|
||||||
|
}
|
||||||
|
return fuse.ReadResultData(f.data[off:end]), fs.OK
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package nostrfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
@@ -17,18 +17,18 @@ import (
|
|||||||
"github.com/winfsp/cgofuse/fuse"
|
"github.com/winfsp/cgofuse/fuse"
|
||||||
)
|
)
|
||||||
|
|
||||||
type FSOptions struct {
|
type Options struct {
|
||||||
AutoPublishNotesTimeout time.Duration
|
AutoPublishNotesTimeout time.Duration
|
||||||
AutoPublishArticlesTimeout time.Duration
|
AutoPublishArticlesTimeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type FSRoot struct {
|
type NostrRoot struct {
|
||||||
fuse.FileSystemBase
|
fuse.FileSystemBase
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
sys *sdk.System
|
sys *sdk.System
|
||||||
rootPubKey nostr.PubKey
|
rootPubKey nostr.PubKey
|
||||||
signer nostr.Signer
|
signer nostr.Signer
|
||||||
opts FSOptions
|
opts Options
|
||||||
mountpoint string
|
mountpoint string
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
@@ -51,9 +51,9 @@ type FSNode struct {
|
|||||||
loaded bool
|
loaded bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ fuse.FileSystemInterface = (*FSRoot)(nil)
|
var _ fuse.FileSystemInterface = (*NostrRoot)(nil)
|
||||||
|
|
||||||
func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o FSOptions) *FSRoot {
|
func NewNostrRoot(ctx context.Context, sys interface{}, user interface{}, mountpoint string, o Options) *NostrRoot {
|
||||||
var system *sdk.System
|
var system *sdk.System
|
||||||
if sys != nil {
|
if sys != nil {
|
||||||
system = sys.(*sdk.System)
|
system = sys.(*sdk.System)
|
||||||
@@ -71,7 +71,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
|
|||||||
|
|
||||||
abs, _ := filepath.Abs(mountpoint)
|
abs, _ := filepath.Abs(mountpoint)
|
||||||
|
|
||||||
root := &FSRoot{
|
root := &NostrRoot{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
sys: system,
|
sys: system,
|
||||||
rootPubKey: pubkey,
|
rootPubKey: pubkey,
|
||||||
@@ -101,7 +101,7 @@ func NewFSRoot(ctx context.Context, sys interface{}, user interface{}, mountpoin
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) initialize() {
|
func (r *NostrRoot) initialize() {
|
||||||
if r.rootPubKey == nostr.ZeroPK {
|
if r.rootPubKey == nostr.ZeroPK {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -146,7 +146,7 @@ func (r *FSRoot) initialize() {
|
|||||||
r.nodes["/"].children["@me"] = meNode
|
r.nodes["/"].children["@me"] = meNode
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
func (r *NostrRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
||||||
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
||||||
if pm.Event == nil {
|
if pm.Event == nil {
|
||||||
return
|
return
|
||||||
@@ -175,7 +175,7 @@ func (r *FSRoot) fetchMetadata(dirPath string, pubkey nostr.PubKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
func (r *NostrRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
||||||
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
pm := r.sys.FetchProfileMetadata(r.ctx, pubkey)
|
||||||
if pm.Event == nil || pm.Picture == "" {
|
if pm.Event == nil || pm.Picture == "" {
|
||||||
return
|
return
|
||||||
@@ -256,7 +256,7 @@ func (r *FSRoot) fetchProfilePicture(dirPath string, pubkey nostr.PubKey) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
func (r *NostrRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
||||||
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
|
ctx, cancel := context.WithTimeout(r.ctx, time.Second*10)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ func (r *FSRoot) fetchEvents(dirPath string, filter nostr.Filter) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
|
func (r *NostrRoot) eventToFilename(evt *nostr.Event) string {
|
||||||
// use event ID first 8 chars + extension based on kind
|
// use event ID first 8 chars + extension based on kind
|
||||||
ext := kindToExtension(evt.Kind)
|
ext := kindToExtension(evt.Kind)
|
||||||
|
|
||||||
@@ -391,14 +391,14 @@ func (r *FSRoot) eventToFilename(evt *nostr.Event) string {
|
|||||||
return fmt.Sprintf("%s.%s", idHex, ext)
|
return fmt.Sprintf("%s.%s", idHex, ext)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) getLog() func(string, ...interface{}) {
|
func (r *NostrRoot) getLog() func(string, ...interface{}) {
|
||||||
if log := r.ctx.Value("log"); log != nil {
|
if log := r.ctx.Value("log"); log != nil {
|
||||||
return log.(func(string, ...interface{}))
|
return log.(func(string, ...interface{}))
|
||||||
}
|
}
|
||||||
return func(string, ...interface{}) {}
|
return func(string, ...interface{}) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) getNode(path string) *FSNode {
|
func (r *NostrRoot) getNode(path string) *FSNode {
|
||||||
originalPath := path
|
originalPath := path
|
||||||
|
|
||||||
// normalize path
|
// normalize path
|
||||||
@@ -451,7 +451,7 @@ func (r *FSRoot) getNode(path string) *FSNode {
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
func (r *NostrRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
|
|
||||||
// if node doesn't exist, try dynamic lookup
|
// if node doesn't exist, try dynamic lookup
|
||||||
@@ -480,7 +480,7 @@ func (r *FSRoot) Getattr(path string, stat *fuse.Stat_t, fh uint64) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
|
// dynamicLookup tries to create nodes on-demand for npub/note/nevent paths
|
||||||
func (r *FSRoot) dynamicLookup(path string) bool {
|
func (r *NostrRoot) dynamicLookup(path string) bool {
|
||||||
// normalize path
|
// normalize path
|
||||||
path = strings.ReplaceAll(path, "\\", "/")
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
@@ -535,7 +535,7 @@ func (r *FSRoot) dynamicLookup(path string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
|
func (r *NostrRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer nostr.Signer) {
|
||||||
dirPath := "/" + npub
|
dirPath := "/" + npub
|
||||||
|
|
||||||
// check if already exists
|
// check if already exists
|
||||||
@@ -628,7 +628,7 @@ func (r *FSRoot) createNpubDirLocked(npub string, pubkey nostr.PubKey, signer no
|
|||||||
go r.fetchProfilePicture(dirPath, pubkey)
|
go r.fetchProfilePicture(dirPath, pubkey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
|
func (r *NostrRoot) createViewDirLocked(parentPath, name string, filter nostr.Filter) {
|
||||||
dirPath := parentPath + "/" + name
|
dirPath := parentPath + "/" + name
|
||||||
|
|
||||||
// check if already exists
|
// check if already exists
|
||||||
@@ -656,7 +656,7 @@ func (r *FSRoot) createViewDirLocked(parentPath, name string, filter nostr.Filte
|
|||||||
go r.fetchEvents(dirPath, filter)
|
go r.fetchEvents(dirPath, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
|
func (r *NostrRoot) createEventDirLocked(name string, pointer nostr.EventPointer) bool {
|
||||||
dirPath := "/" + name
|
dirPath := "/" + name
|
||||||
|
|
||||||
// fetch the event
|
// fetch the event
|
||||||
@@ -737,7 +737,7 @@ func (r *FSRoot) createEventDirLocked(name string, pointer nostr.EventPointer) b
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Readdir(path string,
|
func (r *NostrRoot) Readdir(path string,
|
||||||
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
|
fill func(name string, stat *fuse.Stat_t, ofst int64) bool,
|
||||||
ofst int64,
|
ofst int64,
|
||||||
fh uint64,
|
fh uint64,
|
||||||
@@ -768,7 +768,7 @@ func (r *FSRoot) Readdir(path string,
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Open(path string, flags int) (int, uint64) {
|
func (r *NostrRoot) Open(path string, flags int) (int, uint64) {
|
||||||
// log the open attempt
|
// log the open attempt
|
||||||
if r.ctx.Value("logverbose") != nil {
|
if r.ctx.Value("logverbose") != nil {
|
||||||
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
|
logv := r.ctx.Value("logverbose").(func(string, ...interface{}))
|
||||||
@@ -799,7 +799,7 @@ func (r *FSRoot) Open(path string, flags int) (int, uint64) {
|
|||||||
return 0, node.ino
|
return 0, node.ino
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
func (r *NostrRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
if node == nil || node.isDir {
|
if node == nil || node.isDir {
|
||||||
return -fuse.ENOENT
|
return -fuse.ENOENT
|
||||||
@@ -818,7 +818,7 @@ func (r *FSRoot) Read(path string, buff []byte, ofst int64, fh uint64) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Opendir(path string) (int, uint64) {
|
func (r *NostrRoot) Opendir(path string) (int, uint64) {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return -fuse.ENOENT, ^uint64(0)
|
return -fuse.ENOENT, ^uint64(0)
|
||||||
@@ -829,16 +829,16 @@ func (r *FSRoot) Opendir(path string) (int, uint64) {
|
|||||||
return 0, node.ino
|
return 0, node.ino
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Release(path string, fh uint64) int {
|
func (r *NostrRoot) Release(path string, fh uint64) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) Releasedir(path string, fh uint64) int {
|
func (r *NostrRoot) Releasedir(path string, fh uint64) int {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new file
|
// Create creates a new file
|
||||||
func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
func (r *NostrRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
||||||
// parse path
|
// parse path
|
||||||
path = strings.ReplaceAll(path, "\\", "/")
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
@@ -882,7 +882,7 @@ func (r *FSRoot) Create(path string, flags int, mode uint32) (int, uint64) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Truncate truncates a file
|
// Truncate truncates a file
|
||||||
func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
|
func (r *NostrRoot) Truncate(path string, size int64, fh uint64) int {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return -fuse.ENOENT
|
return -fuse.ENOENT
|
||||||
@@ -911,7 +911,7 @@ func (r *FSRoot) Truncate(path string, size int64, fh uint64) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write writes data to a file
|
// Write writes data to a file
|
||||||
func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
func (r *NostrRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return -fuse.ENOENT
|
return -fuse.ENOENT
|
||||||
@@ -955,7 +955,7 @@ func (r *FSRoot) Write(path string, buff []byte, ofst int64, fh uint64) int {
|
|||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FSRoot) publishNote(path string) {
|
func (r *NostrRoot) publishNote(path string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
node, ok := r.nodes[path]
|
node, ok := r.nodes[path]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -1032,7 +1032,7 @@ func (r *FSRoot) publishNote(path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Unlink deletes a file
|
// Unlink deletes a file
|
||||||
func (r *FSRoot) Unlink(path string) int {
|
func (r *NostrRoot) Unlink(path string) int {
|
||||||
path = strings.ReplaceAll(path, "\\", "/")
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
@@ -1065,7 +1065,7 @@ func (r *FSRoot) Unlink(path string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mkdir creates a new directory
|
// Mkdir creates a new directory
|
||||||
func (r *FSRoot) Mkdir(path string, mode uint32) int {
|
func (r *NostrRoot) Mkdir(path string, mode uint32) int {
|
||||||
path = strings.ReplaceAll(path, "\\", "/")
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
@@ -1107,7 +1107,7 @@ func (r *FSRoot) Mkdir(path string, mode uint32) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rmdir removes a directory
|
// Rmdir removes a directory
|
||||||
func (r *FSRoot) Rmdir(path string) int {
|
func (r *NostrRoot) Rmdir(path string) int {
|
||||||
path = strings.ReplaceAll(path, "\\", "/")
|
path = strings.ReplaceAll(path, "\\", "/")
|
||||||
if !strings.HasPrefix(path, "/") {
|
if !strings.HasPrefix(path, "/") {
|
||||||
path = "/" + path
|
path = "/" + path
|
||||||
@@ -1149,7 +1149,7 @@ func (r *FSRoot) Rmdir(path string) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Utimens updates file timestamps
|
// Utimens updates file timestamps
|
||||||
func (r *FSRoot) Utimens(path string, tmsp []fuse.Timespec) int {
|
func (r *NostrRoot) Utimens(path string, tmsp []fuse.Timespec) int {
|
||||||
node := r.getNode(path)
|
node := r.getNode(path)
|
||||||
if node == nil {
|
if node == nil {
|
||||||
return -fuse.ENOENT
|
return -fuse.ENOENT
|
||||||
@@ -153,7 +153,7 @@ example:
|
|||||||
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
relayUrls = nostr.AppendUnique(relayUrls, c.Args().Slice()...)
|
||||||
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
relays := connectToAllRelays(ctx, c, relayUrls, nil,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
return authSigner(ctx, c, func(s string, args ...any) {}, authEvent)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
2
req.go
2
req.go
@@ -138,7 +138,7 @@ example:
|
|||||||
relayUrls,
|
relayUrls,
|
||||||
forcePreAuthSigner,
|
forcePreAuthSigner,
|
||||||
nostr.PoolOptions{
|
nostr.PoolOptions{
|
||||||
AuthHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
AuthRequiredHandler: func(ctx context.Context, authEvent *nostr.Event) error {
|
||||||
return authSigner(ctx, c, func(s string, args ...any) {
|
return authSigner(ctx, c, func(s string, args ...any) {
|
||||||
if strings.HasPrefix(s, "authenticating as") {
|
if strings.HasPrefix(s, "authenticating as") {
|
||||||
cleanUrl, _ := strings.CutPrefix(
|
cleanUrl, _ := strings.CutPrefix(
|
||||||
|
|||||||
Reference in New Issue
Block a user