mirror of
https://github.com/fiatjaf/nak.git
synced 2026-01-30 14:08:50 +00:00
Compare commits
23 Commits
v0.17.3
...
test-v1.0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2969ba503 | ||
|
|
235e16d34b | ||
|
|
e44dd08527 | ||
|
|
d856f54394 | ||
|
|
d015e979aa | ||
|
|
120a92920e | ||
|
|
c6da13649d | ||
|
|
acd6227dd0 | ||
|
|
00fbda9af7 | ||
|
|
e838de9b72 | ||
|
|
6dfbed4413 | ||
|
|
0e283368ed | ||
|
|
38775e0d93 | ||
|
|
fabcad3f61 | ||
|
|
69e4895e48 | ||
|
|
81524de04f | ||
|
|
8334474f96 | ||
|
|
87f27e214e | ||
|
|
32999917b4 | ||
|
|
a19a179548 | ||
|
|
9b684f2c65 | ||
|
|
6d87887855 | ||
|
|
e9c4deaf6d |
155
.github/workflows/release-cli.yml
vendored
155
.github/workflows/release-cli.yml
vendored
@@ -9,41 +9,150 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
make-release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/create-release@latest
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: ${{ github.ref }}
|
|
||||||
build-all-for-all:
|
build-all-for-all:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs:
|
|
||||||
- make-release
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
goos: [linux, freebsd, darwin, windows]
|
goos: [linux, freebsd, 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:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v3
|
||||||
- uses: wangyoucao577/go-release-action@v1.40
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
go-version: 'stable'
|
||||||
goos: ${{ matrix.goos }}
|
- name: Install FUSE dependencies
|
||||||
goarch: ${{ matrix.goarch }}
|
run: |
|
||||||
ldflags: -X main.version=${{ github.ref_name }}
|
sudo apt-get update
|
||||||
overwrite: true
|
sudo apt-get install -y libfuse-dev
|
||||||
md5sum: false
|
- name: Build binary
|
||||||
sha256sum: false
|
env:
|
||||||
compress_assets: false
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
run: |
|
||||||
|
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
- name: Upload Release Asset
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: ./nak-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
|
|
||||||
|
build-darwin:
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
goarch: [amd64, arm64]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: 'stable'
|
||||||
|
- name: Install macFUSE
|
||||||
|
run: brew install --cask macfuse
|
||||||
|
- name: Build binary
|
||||||
|
env:
|
||||||
|
GOOS: darwin
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
run: |
|
||||||
|
go build -ldflags "-X main.version=${{ github.ref_name }}" -o nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
||||||
|
- name: Upload Release Asset
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: ./nak-${{ github.ref_name }}-darwin-${{ matrix.goarch }}
|
||||||
|
|
||||||
|
smoke-test-linux-amd64:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- build-all-for-all
|
||||||
|
steps:
|
||||||
|
- name: download and smoke test latest binary
|
||||||
|
run: |
|
||||||
|
set -eo pipefail # exit on error, and on pipe failures
|
||||||
|
|
||||||
|
echo "downloading nak binary from releases"
|
||||||
|
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
|
||||||
|
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
|
||||||
|
chmod +x nak
|
||||||
|
|
||||||
|
echo "printing version..."
|
||||||
|
./nak --version
|
||||||
|
|
||||||
|
# generate and manipulate keys
|
||||||
|
echo "testing key operations..."
|
||||||
|
SECRET_KEY=$(./nak key generate)
|
||||||
|
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
|
||||||
|
echo "generated key pair: $SECRET_KEY => $PUBLIC_KEY"
|
||||||
|
|
||||||
|
# create events
|
||||||
|
echo "testing event creation..."
|
||||||
|
./nak event -c "hello world"
|
||||||
|
HELLOWORLD=$(./nak event -c "hello world")
|
||||||
|
echo " hello world again: $HELLOWORLD"
|
||||||
|
./nak event --ts "2 days ago" -c "event with timestamp"
|
||||||
|
./nak event -k 1 -t "t=test" -c "event with tag"
|
||||||
|
|
||||||
|
# test NIP-19 encoding/decoding
|
||||||
|
echo "testing NIP-19 encoding/decoding..."
|
||||||
|
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
|
||||||
|
echo "encoded nsec: $NSEC"
|
||||||
|
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
||||||
|
EVENT_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
|
||||||
|
NEVENT1=$(./nak encode nevent $EVENT_ID)
|
||||||
|
echo "encoded nevent1: $NEVENT1"
|
||||||
|
./nak decode $NEVENT1
|
||||||
|
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
|
||||||
|
|
||||||
|
# test event verification
|
||||||
|
echo "testing event verification..."
|
||||||
|
# create an event and verify it
|
||||||
|
VERIFY_EVENT=$(./nak event -c "verify me")
|
||||||
|
echo $VERIFY_EVENT | ./nak verify
|
||||||
|
|
||||||
|
# test PoW
|
||||||
|
echo "testing pow..."
|
||||||
|
./nak event -c "testing pow" --pow 8
|
||||||
|
|
||||||
|
# test NIP-49 key encryption/decryption
|
||||||
|
echo "testing NIP-49 key encryption/decryption..."
|
||||||
|
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
||||||
|
echo "encrypted key: ${ENCRYPTED_KEY: 0:20}..."
|
||||||
|
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
||||||
|
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
||||||
|
echo "nip-49 encryption/decryption test failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# test multi-value tags
|
||||||
|
echo "testing multi-value tags..."
|
||||||
|
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "testing multi-value tags"
|
||||||
|
|
||||||
|
# test relay operations (with a public relay)
|
||||||
|
echo "testing publishing..."
|
||||||
|
# publish a simple event to a public relay
|
||||||
|
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "test from nak smoke test" nos.lol)
|
||||||
|
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
||||||
|
echo "published event ID: $EVENT_ID"
|
||||||
|
|
||||||
|
# wait a moment for propagation
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# fetch the event we just published
|
||||||
|
./nak req -i $EVENT_ID nos.lol
|
||||||
|
|
||||||
|
# test serving (just start and immediately kill)
|
||||||
|
echo "testing serve command..."
|
||||||
|
timeout 2s ./nak serve || true
|
||||||
|
|
||||||
|
# test filesystem mount (just start and immediately kill)
|
||||||
|
echo "testing fs mount command..."
|
||||||
|
mkdir -p /tmp/nostr-mount
|
||||||
|
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
|
||||||
|
|
||||||
|
echo "all tests passed"
|
||||||
|
|||||||
97
.github/workflows/smoke-test-release.yml
vendored
97
.github/workflows/smoke-test-release.yml
vendored
@@ -1,97 +0,0 @@
|
|||||||
name: Smoke test the binary
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_run:
|
|
||||||
workflows: ["build cli for all platforms"]
|
|
||||||
types:
|
|
||||||
- completed
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
smoke-test-linux-amd64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
||||||
steps:
|
|
||||||
- name: Download and smoke test latest binary
|
|
||||||
run: |
|
|
||||||
set -eo pipefail # Exit on error, and on pipe failures
|
|
||||||
|
|
||||||
echo "Downloading nak binary from releases"
|
|
||||||
RELEASE_URL="https://api.github.com/repos/fiatjaf/nak/releases/latest"
|
|
||||||
wget $(wget -q -O - ${RELEASE_URL} | jq -r '.assets[] | select(.name | contains("linux-amd64")) | .browser_download_url') -O nak -nv
|
|
||||||
chmod +x nak
|
|
||||||
|
|
||||||
echo "Running basic tests..."
|
|
||||||
./nak --version
|
|
||||||
|
|
||||||
# Generate and manipulate keys
|
|
||||||
echo "Testing key operations..."
|
|
||||||
SECRET_KEY=$(./nak key generate)
|
|
||||||
PUBLIC_KEY=$(echo $SECRET_KEY | ./nak key public)
|
|
||||||
echo "Generated key pair: $PUBLIC_KEY"
|
|
||||||
|
|
||||||
# Create events
|
|
||||||
echo "Testing event creation..."
|
|
||||||
./nak event -c "hello world"
|
|
||||||
./nak event --ts "2 days ago" -c "event with timestamp"
|
|
||||||
./nak event -k 1 -t "t=test" -c "event with tag"
|
|
||||||
|
|
||||||
# Test NIP-19 encoding/decoding
|
|
||||||
echo "Testing NIP-19 encoding/decoding..."
|
|
||||||
NSEC=$(echo $SECRET_KEY | ./nak encode nsec)
|
|
||||||
echo "Encoded nsec: $NSEC"
|
|
||||||
./nak encode npub 79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798
|
|
||||||
NOTE_ID="5ae731bbc7711f78513da14927c48cc7143a91e6cad0565fdc4d73b8967a7d59"
|
|
||||||
NOTE1=$(./nak encode note $NOTE_ID)
|
|
||||||
echo "Encoded note1: $NOTE1"
|
|
||||||
./nak decode $NOTE1
|
|
||||||
./nak decode npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6
|
|
||||||
|
|
||||||
# Test event verification
|
|
||||||
echo "Testing event verification..."
|
|
||||||
# Create an event and verify it
|
|
||||||
VERIFY_EVENT=$(./nak event -c "verify me")
|
|
||||||
echo $VERIFY_EVENT | ./nak verify
|
|
||||||
|
|
||||||
# Test PoW
|
|
||||||
echo "Testing PoW..."
|
|
||||||
./nak event -c "testing pow" --pow 8
|
|
||||||
|
|
||||||
# Test NIP-49 key encryption/decryption
|
|
||||||
echo "Testing NIP-49 key encryption/decryption..."
|
|
||||||
ENCRYPTED_KEY=$(./nak key encrypt $SECRET_KEY "testpassword")
|
|
||||||
echo "Encrypted key: ${ENCRYPTED_KEY:0:20}..."
|
|
||||||
DECRYPTED_KEY=$(./nak key decrypt $ENCRYPTED_KEY "testpassword")
|
|
||||||
if [ "$DECRYPTED_KEY" != "$SECRET_KEY" ]; then
|
|
||||||
echo "NIP-49 encryption/decryption test failed!"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test multi-value tags
|
|
||||||
echo "Testing multi-value tags..."
|
|
||||||
./nak event --ts "yesterday" -t "e=f59911b561c37c90b01e9e5c2557307380835c83399756f4d62d8167227e420a;wss://relay.example.com;root" -c "Testing multi-value tags"
|
|
||||||
|
|
||||||
# Test relay operations (with a public relay)
|
|
||||||
echo "Testing relay operations..."
|
|
||||||
# Publish a simple event to a public relay
|
|
||||||
EVENT_JSON=$(./nak event --sec $SECRET_KEY -c "Test from nak smoke test" nos.lol)
|
|
||||||
EVENT_ID=$(echo $EVENT_JSON | jq -r .id)
|
|
||||||
echo "Published event ID: $EVENT_ID"
|
|
||||||
|
|
||||||
# Wait a moment for propagation
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
# Fetch the event we just published
|
|
||||||
./nak req -i $EVENT_ID nos.lol
|
|
||||||
|
|
||||||
# Test serving (just start and immediately kill)
|
|
||||||
echo "Testing serve command..."
|
|
||||||
timeout 2s ./nak serve || true
|
|
||||||
|
|
||||||
# Test filesystem mount (just start and immediately kill)
|
|
||||||
echo "Testing fs mount command..."
|
|
||||||
mkdir -p /tmp/nostr-mount
|
|
||||||
timeout 2s ./nak fs --sec $SECRET_KEY /tmp/nostr-mount || true
|
|
||||||
|
|
||||||
echo "All tests passed"
|
|
||||||
@@ -329,6 +329,10 @@ var bunker = &cli.Command{
|
|||||||
|
|
||||||
// asking user for authorization
|
// 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) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
if secret == newSecret {
|
if secret == newSecret {
|
||||||
// store this key
|
// store this key
|
||||||
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
config.AuthorizedKeys = appendUnique(config.AuthorizedKeys, from)
|
||||||
@@ -343,9 +347,11 @@ var bunker = &cli.Command{
|
|||||||
if persist != nil {
|
if persist != nil {
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return slices.Contains(config.AuthorizedKeys, from) || slices.Contains(authorizedSecrets, secret)
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
for ie := range events {
|
for ie := range events {
|
||||||
|
|||||||
310
dekey.go
310
dekey.go
@@ -8,7 +8,9 @@ import (
|
|||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/nip19"
|
||||||
"fiatjaf.com/nostr/nip44"
|
"fiatjaf.com/nostr/nip44"
|
||||||
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
@@ -20,7 +22,7 @@ var dekey = &cli.Command{
|
|||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Flags: append(defaultKeyFlags,
|
Flags: append(defaultKeyFlags,
|
||||||
&cli.StringFlag{
|
&cli.StringFlag{
|
||||||
Name: "device-name",
|
Name: "device",
|
||||||
Usage: "name of this device that will be published and displayed on other clients",
|
Usage: "name of this device that will be published and displayed on other clients",
|
||||||
Value: func() string {
|
Value: func() string {
|
||||||
if hostname, err := os.Hostname(); err == nil {
|
if hostname, err := os.Hostname(); err == nil {
|
||||||
@@ -29,24 +31,38 @@ var dekey = &cli.Command{
|
|||||||
return "nak@unknown"
|
return "nak@unknown"
|
||||||
}(),
|
}(),
|
||||||
},
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "rotate",
|
||||||
|
Usage: "force the creation of a new decoupled encryption key, effectively invalidating any previous ones",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "authorize-all",
|
||||||
|
Aliases: []string{"yolo"},
|
||||||
|
Usage: "do not ask for confirmation, just automatically send the decoupled encryption key to all devices that exist",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "reject-all",
|
||||||
|
Usage: "do not ask for confirmation, just not send the decoupled encryption key to any device",
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
log(color.CyanString("gathering keyer from arguments...\n"))
|
|
||||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
log(color.CyanString("getting user public key...\n"))
|
|
||||||
userPub, err := kr.GetPublicKey(ctx)
|
userPub, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get user public key: %w", err)
|
return fmt.Errorf("failed to get user public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := c.String("config-path")
|
configPath := c.String("config-path")
|
||||||
deviceName := c.String("device-name")
|
deviceName := c.String("device")
|
||||||
|
|
||||||
log(color.YellowString("handling device key for %s...\n"), deviceName)
|
log("handling device key for %s as %s\n",
|
||||||
|
color.YellowString(deviceName),
|
||||||
|
color.CyanString(nip19.EncodeNpub(userPub)),
|
||||||
|
)
|
||||||
// check if we already have a local-device secret key
|
// check if we already have a local-device secret key
|
||||||
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
|
deviceKeyPath := filepath.Join(configPath, "dekey", "device-key")
|
||||||
var deviceSec nostr.SecretKey
|
var deviceSec nostr.SecretKey
|
||||||
@@ -57,7 +73,7 @@ var dekey = &cli.Command{
|
|||||||
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
|
return fmt.Errorf("invalid device key in %s: %w", deviceKeyPath, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(color.YellowString("generating new device key...\n"))
|
log(color.YellowString("generating new device key\n"))
|
||||||
// create one
|
// create one
|
||||||
deviceSec = nostr.Generate()
|
deviceSec = nostr.Generate()
|
||||||
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
|
os.MkdirAll(filepath.Dir(deviceKeyPath), 0700)
|
||||||
@@ -69,80 +85,65 @@ var dekey = &cli.Command{
|
|||||||
devicePub := deviceSec.Public()
|
devicePub := deviceSec.Public()
|
||||||
|
|
||||||
// get relays for the user
|
// get relays for the user
|
||||||
log(color.CyanString("fetching write relays for user...\n"))
|
log("fetching write relays for %s\n", color.CyanString(nip19.EncodeNpub(userPub)))
|
||||||
relays := sys.FetchWriteRelays(ctx, userPub)
|
relays := sys.FetchWriteRelays(ctx, userPub)
|
||||||
log(color.CyanString("connecting to %d relays...\n"), len(relays))
|
|
||||||
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
|
relayList := connectToAllRelays(ctx, c, relays, nil, nostr.PoolOptions{})
|
||||||
if len(relayList) == 0 {
|
if len(relayList) == 0 {
|
||||||
return fmt.Errorf("no relays to use")
|
return fmt.Errorf("no relays to use")
|
||||||
}
|
}
|
||||||
log(color.GreenString("connected to %d relays\n"), len(relayList))
|
|
||||||
|
|
||||||
// check if kind:4454 is already published
|
|
||||||
log(color.CyanString("checking for existing device registration (kind:4454)...\n"))
|
|
||||||
events := sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{4454},
|
|
||||||
Authors: []nostr.PubKey{userPub},
|
|
||||||
Tags: nostr.TagMap{
|
|
||||||
"pubkey": []string{devicePub.Hex()},
|
|
||||||
},
|
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
|
||||||
if len(events) == 0 {
|
|
||||||
log(color.YellowString("no device registration found, publishing kind:4454...\n"))
|
|
||||||
// publish kind:4454
|
|
||||||
evt := nostr.Event{
|
|
||||||
Kind: 4454,
|
|
||||||
Content: "",
|
|
||||||
CreatedAt: nostr.Now(),
|
|
||||||
Tags: nostr.Tags{
|
|
||||||
{"client", deviceName},
|
|
||||||
{"pubkey", devicePub.Hex()},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// sign with main key
|
|
||||||
if err := kr.SignEvent(ctx, &evt); err != nil {
|
|
||||||
return fmt.Errorf("failed to sign device event: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// publish
|
|
||||||
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log(color.GreenString("device registration published\n"))
|
|
||||||
} else {
|
|
||||||
log(color.GreenString("device already registered\n"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for kind:10044
|
// check for kind:10044
|
||||||
log(color.CyanString("checking for user encryption key (kind:10044)...\n"))
|
log("- checking for decoupled encryption key (kind:10044)\n")
|
||||||
userKeyEventDate := nostr.Now()
|
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||||
userKeyResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{10044},
|
Kinds: []nostr.Kind{10044},
|
||||||
Authors: []nostr.PubKey{userPub},
|
Authors: []nostr.PubKey{userPub},
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"})
|
||||||
var eSec nostr.SecretKey
|
var eSec nostr.SecretKey
|
||||||
var ePub nostr.PubKey
|
var ePub nostr.PubKey
|
||||||
if userKeyEvent, ok := userKeyResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""}); !ok {
|
|
||||||
log(color.YellowString("no user encryption key found, generating new one...\n"))
|
var generateNewEncryptionKey bool
|
||||||
|
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: userPub, D: ""})
|
||||||
|
if !ok {
|
||||||
|
log("- no decoupled encryption key found, generating new one\n")
|
||||||
|
generateNewEncryptionKey = true
|
||||||
|
} else {
|
||||||
|
// get the pub from the tag
|
||||||
|
for _, tag := range keyAnnouncementEvent.Tags {
|
||||||
|
if len(tag) >= 2 && tag[0] == "n" {
|
||||||
|
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ePub == nostr.ZeroPK {
|
||||||
|
return fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
log(". a decoupled encryption public key already exists: %s\n", color.CyanString(ePub.Hex()))
|
||||||
|
if c.Bool("rotate") {
|
||||||
|
log(color.GreenString("rotating it by generating a new one\n"))
|
||||||
|
generateNewEncryptionKey = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if generateNewEncryptionKey {
|
||||||
// generate main secret key
|
// generate main secret key
|
||||||
eSec = nostr.Generate()
|
eSec = nostr.Generate()
|
||||||
ePub := eSec.Public()
|
ePub = eSec.Public()
|
||||||
|
|
||||||
// store it
|
// store it
|
||||||
eKeyPath := filepath.Join(configPath, "dekey", "e", ePub.Hex())
|
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||||
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
|
if err := os.WriteFile(eKeyPath, []byte(eSec.Hex()), 0600); err != nil {
|
||||||
return fmt.Errorf("failed to write user encryption key: %w", err)
|
return fmt.Errorf("failed to write decoupled encryption key: %w", err)
|
||||||
}
|
}
|
||||||
log(color.GreenString("user encryption key generated and stored\n"))
|
log("decoupled encryption key generated and stored, public key: %s\n", color.CyanString(ePub.Hex()))
|
||||||
|
|
||||||
// publish kind:10044
|
// publish kind:10044
|
||||||
log(color.YellowString("publishing user encryption key (kind:10044)...\n"))
|
log("publishing decoupled encryption public key (kind:10044)\n")
|
||||||
evt10044 := nostr.Event{
|
evt10044 := nostr.Event{
|
||||||
Kind: 10044,
|
Kind: 10044,
|
||||||
Content: "",
|
Content: "",
|
||||||
CreatedAt: userKeyEventDate,
|
CreatedAt: nostr.Now(),
|
||||||
Tags: nostr.Tags{
|
Tags: nostr.Tags{
|
||||||
{"n", ePub.Hex()},
|
{"n", ePub.Hex()},
|
||||||
},
|
},
|
||||||
@@ -150,45 +151,72 @@ var dekey = &cli.Command{
|
|||||||
if err := kr.SignEvent(ctx, &evt10044); err != nil {
|
if err := kr.SignEvent(ctx, &evt10044); err != nil {
|
||||||
return fmt.Errorf("failed to sign kind:10044: %w", err)
|
return fmt.Errorf("failed to sign kind:10044: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
|
if err := publishFlow(ctx, c, kr, evt10044, relayList); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
log(color.GreenString("user encryption key published\n"))
|
|
||||||
} else {
|
} else {
|
||||||
log(color.GreenString("found existing user encryption key\n"))
|
|
||||||
userKeyEventDate = userKeyEvent.CreatedAt
|
|
||||||
|
|
||||||
// get the pub from the tag
|
|
||||||
for _, tag := range userKeyEvent.Tags {
|
|
||||||
if len(tag) >= 2 && tag[0] == "n" {
|
|
||||||
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ePub == nostr.ZeroPK {
|
|
||||||
return fmt.Errorf("invalid kind:10044 event, no 'n' tag")
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if we have the key
|
// check if we have the key
|
||||||
eKeyPath := filepath.Join(configPath, "dekey", "e", ePub.Hex())
|
eKeyPath := filepath.Join(configPath, "dekey", "p", userPub.Hex(), "e", ePub.Hex())
|
||||||
if data, err := os.ReadFile(eKeyPath); err == nil {
|
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||||
log(color.GreenString("found stored user encryption key\n"))
|
log(color.GreenString("- and we have it locally already\n"))
|
||||||
eSec, err = nostr.SecretKeyFromHex(string(data))
|
eSec, err = nostr.SecretKeyFromHex(string(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid main key: %w", err)
|
return fmt.Errorf("invalid main key: %w", err)
|
||||||
}
|
}
|
||||||
if eSec.Public() != ePub {
|
if eSec.Public() != ePub {
|
||||||
return fmt.Errorf("stored user encryption key is corrupted: %w", err)
|
return fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
log(color.YellowString("user encryption key not stored locally, attempting to decrypt from other devices...\n"))
|
log("- decoupled encryption key not found locally, attempting to fetch the key from other devices\n")
|
||||||
// try to decrypt from kind:4455
|
|
||||||
|
// check if our kind:4454 is already published
|
||||||
|
log("- checking for existing device announcement (kind:4454)\n")
|
||||||
|
ourDeviceAnnouncementEvents := make([]nostr.Event, 0, 1)
|
||||||
|
for evt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{4454},
|
||||||
|
Authors: []nostr.PubKey{userPub},
|
||||||
|
Tags: nostr.TagMap{
|
||||||
|
"P": []string{devicePub.Hex()},
|
||||||
|
},
|
||||||
|
Limit: 1,
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||||
|
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt.Event)
|
||||||
|
}
|
||||||
|
if len(ourDeviceAnnouncementEvents) == 0 {
|
||||||
|
log(". no device announcement found, publishing kind:4454 for %s\n", color.YellowString(deviceName))
|
||||||
|
// publish kind:4454
|
||||||
|
evt := nostr.Event{
|
||||||
|
Kind: 4454,
|
||||||
|
Content: "",
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"client", deviceName},
|
||||||
|
{"P", devicePub.Hex()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign with main key
|
||||||
|
if err := kr.SignEvent(ctx, &evt); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign device event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish
|
||||||
|
if err := publishFlow(ctx, c, kr, evt, relayList); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log(color.GreenString(". device announcement published\n"))
|
||||||
|
ourDeviceAnnouncementEvents = append(ourDeviceAnnouncementEvents, evt)
|
||||||
|
} else {
|
||||||
|
log(color.GreenString(". device already registered\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// see if some other device has shared the key with us from kind:4455
|
||||||
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
for eKeyMsg := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
Kinds: []nostr.Kind{4455},
|
Kinds: []nostr.Kind{4455},
|
||||||
Tags: nostr.TagMap{
|
Tags: nostr.TagMap{
|
||||||
"p": []string{devicePub.Hex()},
|
"p": []string{devicePub.Hex()},
|
||||||
},
|
},
|
||||||
|
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||||
var senderPub nostr.PubKey
|
var senderPub nostr.PubKey
|
||||||
for _, tag := range eKeyMsg.Tags {
|
for _, tag := range eKeyMsg.Tags {
|
||||||
@@ -214,10 +242,47 @@ var dekey = &cli.Command{
|
|||||||
}
|
}
|
||||||
// check if it matches mainPub
|
// check if it matches mainPub
|
||||||
if eSec.Public() == ePub {
|
if eSec.Public() == ePub {
|
||||||
log(color.GreenString("successfully decrypted user encryption key from another device\n"))
|
log(color.GreenString("successfully received decoupled encryption key from another device\n"))
|
||||||
// store it
|
// store it
|
||||||
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
os.MkdirAll(filepath.Dir(eKeyPath), 0700)
|
||||||
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
os.WriteFile(eKeyPath, []byte(eSecHex), 0600)
|
||||||
|
|
||||||
|
// delete our 4454 if we had one, since we received the key
|
||||||
|
if len(ourDeviceAnnouncementEvents) > 0 {
|
||||||
|
log("deleting our device announcement (kind:4454) since we received the decoupled encryption key\n")
|
||||||
|
deletion4454 := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: 5,
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"e", ourDeviceAnnouncementEvents[0].ID.Hex()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &deletion4454); err != nil {
|
||||||
|
log(color.RedString("failed to sign 4454 deletion: %v\n"), err)
|
||||||
|
} else if err := publishFlow(ctx, c, kr, deletion4454, relayList); err != nil {
|
||||||
|
log(color.RedString("failed to publish 4454 deletion: %v\n"), err)
|
||||||
|
} else {
|
||||||
|
log(color.GreenString("- device announcement deleted\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete the 4455 we just decrypted
|
||||||
|
log("deleting the key message (kind:4455) we just decrypted\n")
|
||||||
|
deletion4455 := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: 5,
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"e", eKeyMsg.ID.Hex()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &deletion4455); err != nil {
|
||||||
|
log(color.RedString("failed to sign 4455 deletion: %v\n"), err)
|
||||||
|
} else if err := publishFlow(ctx, c, kr, deletion4455, relayList); err != nil {
|
||||||
|
log(color.RedString("failed to publish 4455 deletion: %v\n"), err)
|
||||||
|
} else {
|
||||||
|
log(color.GreenString("- key message deleted\n"))
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -225,61 +290,115 @@ var dekey = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
if eSec == [32]byte{} {
|
if eSec == [32]byte{} {
|
||||||
log(color.RedString("main secret key not available, must authorize on another device\n"))
|
log("decoupled encryption secret key not available, must be sent from another device to %s first\n",
|
||||||
|
color.YellowString(deviceName))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
log(color.GreenString("user encryption key ready\n"))
|
log(color.GreenString("- decoupled encryption key ready\n"))
|
||||||
|
|
||||||
// now we have mainSec, check for other kind:4454 events newer than the 10044
|
// now we have mainSec, check for other kind:4454 events newer than the 10044
|
||||||
log(color.CyanString("checking for other devices and key messages...\n"))
|
log("- checking for other devices and key messages so we can send the key\n")
|
||||||
keyMsgs := make([]string, 0, 5)
|
keyMsgs := make([]string, 0, 5)
|
||||||
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
for keyOrDeviceEvt := range sys.Pool.FetchMany(ctx, relays, nostr.Filter{
|
||||||
Kinds: []nostr.Kind{4454, 4455},
|
Kinds: []nostr.Kind{4454, 4455},
|
||||||
Authors: []nostr.PubKey{userPub},
|
Authors: []nostr.PubKey{userPub},
|
||||||
Since: userKeyEventDate,
|
Since: keyAnnouncementEvent.CreatedAt + 1,
|
||||||
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e"}) {
|
||||||
if keyOrDeviceEvt.Kind == 4455 {
|
if keyOrDeviceEvt.Kind == 4455 {
|
||||||
// key event
|
// got key event
|
||||||
log(color.BlueString("received key message (kind:4455)\n"))
|
keyEvent := keyOrDeviceEvt
|
||||||
|
|
||||||
// skip ourselves
|
|
||||||
if keyOrDeviceEvt.Tags.FindWithValue("p", devicePub.Hex()) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// assume a key msg will always come before its associated devicemsg
|
// assume a key msg will always come before its associated devicemsg
|
||||||
// so just store them here:
|
// so just store them here:
|
||||||
pubkeyTag := keyOrDeviceEvt.Tags.Find("p")
|
pubkeyTag := keyEvent.Tags.Find("p")
|
||||||
if pubkeyTag == nil {
|
if pubkeyTag == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keyMsgs = append(keyMsgs, pubkeyTag[1])
|
keyMsgs = append(keyMsgs, pubkeyTag[1])
|
||||||
} else if keyOrDeviceEvt.Kind == 4454 {
|
} else if keyOrDeviceEvt.Kind == 4454 {
|
||||||
// device event
|
// device event
|
||||||
log(color.BlueString("received device registration (kind:4454)\n"))
|
deviceEvt := keyOrDeviceEvt
|
||||||
|
|
||||||
// skip ourselves
|
// skip ourselves
|
||||||
if keyOrDeviceEvt.Tags.FindWithValue("pubkey", devicePub.Hex()) != nil {
|
if deviceEvt.Tags.FindWithValue("P", devicePub.Hex()) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// if there is a clock skew (current time is earlier than the time of this device's announcement) skip it
|
||||||
|
if nostr.Now() < deviceEvt.CreatedAt {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// if this already has a corresponding keyMsg then skip it
|
// if this already has a corresponding keyMsg then skip it
|
||||||
pubkeyTag := keyOrDeviceEvt.Tags.Find("pubkey")
|
pubkeyTag := deviceEvt.Tags.Find("P")
|
||||||
if pubkeyTag == nil {
|
if pubkeyTag == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
if slices.Contains(keyMsgs, pubkeyTag[1]) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deviceTag := deviceEvt.Tags.Find("client")
|
||||||
|
if deviceTag == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
|
// here we know we're dealing with a deviceMsg without a corresponding keyMsg
|
||||||
// so we have to build a keyMsg for them
|
// so we have to build a keyMsg for them
|
||||||
log(color.YellowString("sending encryption key to new device...\n"))
|
|
||||||
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
|
theirDevice, err := nostr.PubKeyFromHex(pubkeyTag[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Bool("authorize-all") {
|
||||||
|
// will proceed
|
||||||
|
} else if c.Bool("reject-all") {
|
||||||
|
log(" - skipping %s\n", color.YellowString(deviceTag[1]))
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
var proceed bool
|
||||||
|
if err := survey.AskOne(&survey.Confirm{
|
||||||
|
Message: fmt.Sprintf("share decoupled encryption key with %s"+colors.bold("?"),
|
||||||
|
color.YellowString(deviceTag[1])),
|
||||||
|
}, &proceed); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if proceed {
|
||||||
|
// will proceed
|
||||||
|
} else {
|
||||||
|
// won't proceed
|
||||||
|
var deleteDevice bool
|
||||||
|
if err := survey.AskOne(&survey.Confirm{
|
||||||
|
Message: fmt.Sprintf(" delete %s"+colors.bold("'s announcement?"), color.YellowString(deviceTag[1])),
|
||||||
|
}, &deleteDevice); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleteDevice {
|
||||||
|
log(" - deleting %s\n", color.YellowString(deviceTag[1]))
|
||||||
|
deletion := nostr.Event{
|
||||||
|
CreatedAt: nostr.Now(),
|
||||||
|
Kind: 5,
|
||||||
|
Tags: nostr.Tags{
|
||||||
|
{"e", deviceEvt.ID.Hex()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := kr.SignEvent(ctx, &deletion); err != nil {
|
||||||
|
return fmt.Errorf("failed to sign deletion '%s': %w", deletion.GetID().Hex(), err)
|
||||||
|
}
|
||||||
|
if err := publishFlow(ctx, c, kr, deletion, relayList); err != nil {
|
||||||
|
return fmt.Errorf("publish flow failed: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log(" - skipped\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("- sending decoupled encryption key to new device %s\n", color.YellowString(deviceTag[1]))
|
||||||
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
|
ss, err := nip44.GenerateConversationKey(theirDevice, deviceSec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -305,11 +424,12 @@ var dekey = &cli.Command{
|
|||||||
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
|
if err := publishFlow(ctx, c, kr, evt4455, relayList); err != nil {
|
||||||
log(color.RedString("failed to publish key message: %v\n"), err)
|
log(color.RedString("failed to publish key message: %v\n"), err)
|
||||||
} else {
|
} else {
|
||||||
log(color.GreenString("encryption key sent to device\n"))
|
log(" - decoupled encryption key sent to %s\n", color.GreenString(deviceTag[1]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stdout(ePub.Hex())
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
55
encode.go
55
encode.go
@@ -25,13 +25,6 @@ var encode = &cli.Command{
|
|||||||
"relays":["wss://nada.zero"],
|
"relays":["wss://nada.zero"],
|
||||||
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
"author":"ebb6ff85430705651b311ed51328767078fd790b14f02d22efba68d5513376bc"
|
||||||
} | nak encode`,
|
} | nak encode`,
|
||||||
Flags: []cli.Flag{
|
|
||||||
&cli.StringSliceFlag{
|
|
||||||
Name: "relay",
|
|
||||||
Aliases: []string{"r"},
|
|
||||||
Usage: "attach relay hints to naddr code",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
if c.Args().Len() != 0 {
|
if c.Args().Len() != 0 {
|
||||||
@@ -126,7 +119,12 @@ var encode = &cli.Command{
|
|||||||
&cli.StringSliceFlag{
|
&cli.StringSliceFlag{
|
||||||
Name: "relay",
|
Name: "relay",
|
||||||
Aliases: []string{"r"},
|
Aliases: []string{"r"},
|
||||||
Usage: "attach relay hints to nprofile code",
|
Usage: "attach relay hints to the code",
|
||||||
|
},
|
||||||
|
&BoolIntFlag{
|
||||||
|
Name: "outbox",
|
||||||
|
Usage: "automatically appends outbox relays to the code",
|
||||||
|
Value: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
@@ -139,6 +137,13 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, pk, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -159,6 +164,16 @@ var encode = &cli.Command{
|
|||||||
Aliases: []string{"a"},
|
Aliases: []string{"a"},
|
||||||
Usage: "attach an author pubkey as a hint to the nevent code",
|
Usage: "attach an author pubkey as a hint to the nevent code",
|
||||||
},
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "relay",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "attach relay hints to the code",
|
||||||
|
},
|
||||||
|
&BoolIntFlag{
|
||||||
|
Name: "outbox",
|
||||||
|
Usage: "automatically appends outbox relays to the code",
|
||||||
|
Value: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -171,6 +186,13 @@ var encode = &cli.Command{
|
|||||||
|
|
||||||
author := getPubKey(c, "author")
|
author := getPubKey(c, "author")
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 && author != nostr.ZeroPK {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, author, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -204,6 +226,16 @@ var encode = &cli.Command{
|
|||||||
Usage: "kind of referred replaceable event",
|
Usage: "kind of referred replaceable event",
|
||||||
Required: true,
|
Required: true,
|
||||||
},
|
},
|
||||||
|
&cli.StringSliceFlag{
|
||||||
|
Name: "relay",
|
||||||
|
Aliases: []string{"r"},
|
||||||
|
Usage: "attach relay hints to the code",
|
||||||
|
},
|
||||||
|
&BoolIntFlag{
|
||||||
|
Name: "outbox",
|
||||||
|
Usage: "automatically appends outbox relays to the code",
|
||||||
|
Value: 3,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
@@ -224,6 +256,13 @@ var encode = &cli.Command{
|
|||||||
}
|
}
|
||||||
|
|
||||||
relays := c.StringSlice("relay")
|
relays := c.StringSlice("relay")
|
||||||
|
|
||||||
|
if getBoolInt(c, "outbox") > 0 {
|
||||||
|
for _, r := range sys.FetchOutboxRelays(ctx, pubkey, int(getBoolInt(c, "outbox"))) {
|
||||||
|
relays = appendUnique(relays, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
if err := normalizeAndValidateRelayURLs(relays); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
1
event.go
1
event.go
@@ -155,6 +155,7 @@ example:
|
|||||||
os.Exit(3)
|
os.Exit(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
kr, sec, err := gatherKeyerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
56
flags.go
56
flags.go
@@ -11,6 +11,62 @@ import (
|
|||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
BoolIntFlag = cli.FlagBase[int, struct{}, boolIntValue]
|
||||||
|
)
|
||||||
|
|
||||||
|
type boolIntValue struct {
|
||||||
|
int int
|
||||||
|
defaultWhenSet int
|
||||||
|
hasDefault bool
|
||||||
|
hasBeenSet bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ cli.ValueCreator[int, struct{}] = boolIntValue{}
|
||||||
|
|
||||||
|
func (t boolIntValue) Create(val int, p *int, c struct{}) cli.Value {
|
||||||
|
*p = val
|
||||||
|
|
||||||
|
return &boolIntValue{
|
||||||
|
defaultWhenSet: val,
|
||||||
|
hasDefault: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t boolIntValue) IsBoolFlag() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t boolIntValue) ToString(b int) string { return "<<>>" }
|
||||||
|
|
||||||
|
func (t *boolIntValue) Set(value string) error {
|
||||||
|
t.hasBeenSet = true
|
||||||
|
if value == "true" {
|
||||||
|
if t.hasDefault {
|
||||||
|
t.int = t.defaultWhenSet
|
||||||
|
} else {
|
||||||
|
t.int = 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
t.int, err = strconv.Atoi(value)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *boolIntValue) String() string { return fmt.Sprintf("%#v", t.int) }
|
||||||
|
func (t *boolIntValue) Value() int { return t.int }
|
||||||
|
func (t *boolIntValue) Get() any { return t.int }
|
||||||
|
|
||||||
|
func getBoolInt(cmd *cli.Command, name string) int {
|
||||||
|
return cmd.Value(name).(int)
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
type NaturalTimeFlag = cli.FlagBase[nostr.Timestamp, struct{}, naturalTimeValue]
|
||||||
|
|
||||||
type naturalTimeValue struct {
|
type naturalTimeValue struct {
|
||||||
|
|||||||
50
fs.go
50
fs.go
@@ -13,10 +13,8 @@ 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{
|
||||||
@@ -64,7 +62,7 @@ var fsCmd = &cli.Command{
|
|||||||
apat = time.Hour * 24 * 365 * 3
|
apat = time.Hour * 24 * 365 * 3
|
||||||
}
|
}
|
||||||
|
|
||||||
root := nostrfs.NewNostrRoot(
|
root := NewFSRoot(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
context.WithValue(
|
context.WithValue(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -75,7 +73,7 @@ var fsCmd = &cli.Command{
|
|||||||
sys,
|
sys,
|
||||||
kr,
|
kr,
|
||||||
mountpoint,
|
mountpoint,
|
||||||
nostrfs.Options{
|
FSOptions{
|
||||||
AutoPublishNotesTimeout: apnt,
|
AutoPublishNotesTimeout: apnt,
|
||||||
AutoPublishArticlesTimeout: apat,
|
AutoPublishArticlesTimeout: apat,
|
||||||
},
|
},
|
||||||
@@ -83,21 +81,22 @@ 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
|
|
||||||
server, err := fs.Mount(mountpoint, root, &fs.Options{
|
// create cgofuse host
|
||||||
MountOptions: fuse.MountOptions{
|
host := fuse.NewFileSystemHost(root)
|
||||||
Debug: isVerbose,
|
host.SetCapReaddirPlus(true)
|
||||||
Name: "nak",
|
host.SetUseIno(true)
|
||||||
FsName: "nak",
|
|
||||||
RememberInodes: true,
|
// mount the filesystem
|
||||||
},
|
mountArgs := []string{"-s", mountpoint}
|
||||||
AttrTimeout: &timeout,
|
if isVerbose {
|
||||||
EntryTimeout: &timeout,
|
mountArgs = append([]string{"-d"}, mountArgs...)
|
||||||
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
|
||||||
@@ -107,17 +106,12 @@ var fsCmd = &cli.Command{
|
|||||||
go func() {
|
go func() {
|
||||||
<-ch
|
<-ch
|
||||||
log("- unmounting... ")
|
log("- unmounting... ")
|
||||||
err := server.Unmount()
|
// cgofuse doesn't have explicit unmount, it unmounts on process exit
|
||||||
if err != nil {
|
log("ok\n")
|
||||||
chErr <- fmt.Errorf("unmount failed: %w", err)
|
chErr <- nil
|
||||||
} else {
|
|
||||||
log("ok\n")
|
|
||||||
chErr <- nil
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// serve the filesystem until unmounted
|
// wait for signals
|
||||||
server.Wait()
|
|
||||||
return <-chErr
|
return <-chErr
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//go:build windows || openbsd
|
//go:build openbsd
|
||||||
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -15,6 +15,6 @@ var fsCmd = &cli.Command{
|
|||||||
Description: `doesn't work on Windows and OpenBSD.`,
|
Description: `doesn't work on Windows and OpenBSD.`,
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
return fmt.Errorf("this doesn't work on Windows and OpenBSD.")
|
return fmt.Errorf("this doesn't work on OpenBSD.")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
1177
fs_root.go
Normal file
1177
fs_root.go
Normal file
File diff suppressed because it is too large
Load Diff
139
fs_windows.go
Normal file
139
fs_windows.go
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/keyer"
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/urfave/cli/v3"
|
||||||
|
"github.com/winfsp/cgofuse/fuse"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fsCmd = &cli.Command{
|
||||||
|
Name: "fs",
|
||||||
|
Usage: "mount a FUSE filesystem that exposes Nostr events as files.",
|
||||||
|
Description: `(experimental)`,
|
||||||
|
ArgsUsage: "<mountpoint>",
|
||||||
|
Flags: append(defaultKeyFlags,
|
||||||
|
&PubKeyFlag{
|
||||||
|
Name: "pubkey",
|
||||||
|
Usage: "public key from where to to prepopulate directories",
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "auto-publish-notes",
|
||||||
|
Usage: "delay after which new notes will be auto-published, set to -1 to not publish.",
|
||||||
|
Value: time.Second * 30,
|
||||||
|
},
|
||||||
|
&cli.DurationFlag{
|
||||||
|
Name: "auto-publish-articles",
|
||||||
|
Usage: "delay after which edited articles will be auto-published.",
|
||||||
|
Value: time.Hour * 24 * 365 * 2,
|
||||||
|
DefaultText: "basically infinite",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
DisableSliceFlagSeparator: true,
|
||||||
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
|
mountpoint := c.Args().First()
|
||||||
|
if mountpoint == "" {
|
||||||
|
return fmt.Errorf("must be called with a directory path to serve as the mountpoint as an argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
var kr nostr.User
|
||||||
|
if signer, _, err := gatherKeyerFromArguments(ctx, c); err == nil {
|
||||||
|
kr = signer
|
||||||
|
} else {
|
||||||
|
kr = keyer.NewReadOnlyUser(getPubKey(c, "pubkey"))
|
||||||
|
}
|
||||||
|
|
||||||
|
apnt := c.Duration("auto-publish-notes")
|
||||||
|
if apnt < 0 {
|
||||||
|
apnt = time.Hour * 24 * 365 * 3
|
||||||
|
}
|
||||||
|
apat := c.Duration("auto-publish-articles")
|
||||||
|
if apat < 0 {
|
||||||
|
apat = time.Hour * 24 * 365 * 3
|
||||||
|
}
|
||||||
|
|
||||||
|
root := NewFSRoot(
|
||||||
|
context.WithValue(
|
||||||
|
context.WithValue(
|
||||||
|
ctx,
|
||||||
|
"log", log,
|
||||||
|
),
|
||||||
|
"logverbose", logverbose,
|
||||||
|
),
|
||||||
|
sys,
|
||||||
|
kr,
|
||||||
|
mountpoint,
|
||||||
|
FSOptions{
|
||||||
|
AutoPublishNotesTimeout: apnt,
|
||||||
|
AutoPublishArticlesTimeout: apat,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// create the server
|
||||||
|
log("- mounting at %s... ", color.HiCyanString(mountpoint))
|
||||||
|
|
||||||
|
// create cgofuse host
|
||||||
|
host := fuse.NewFileSystemHost(root)
|
||||||
|
host.SetCapReaddirPlus(true)
|
||||||
|
host.SetUseIno(true)
|
||||||
|
|
||||||
|
// mount the filesystem - Windows/WinFsp version
|
||||||
|
// based on rclone cmount implementation
|
||||||
|
mountArgs := []string{
|
||||||
|
"-o", "uid=-1",
|
||||||
|
"-o", "gid=-1",
|
||||||
|
"--FileSystemName=nak",
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if mountpoint is a drive letter or directory
|
||||||
|
isDriveLetter := len(mountpoint) == 2 && mountpoint[1] == ':'
|
||||||
|
|
||||||
|
if !isDriveLetter {
|
||||||
|
// winFsp primarily supports drive letters on Windows
|
||||||
|
// directory mounting may not work reliably
|
||||||
|
log("WARNING: directory mounting may not work on Windows (WinFsp limitation)\n")
|
||||||
|
log(" consider using a drive letter instead (e.g., 'nak fs Z:')\n")
|
||||||
|
|
||||||
|
// for directory mounts, follow rclone's approach:
|
||||||
|
// 1. check that mountpoint doesn't already exist
|
||||||
|
if _, err := os.Stat(mountpoint); err == nil {
|
||||||
|
return fmt.Errorf("mountpoint path already exists: %s (must not exist before mounting)", mountpoint)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to check mountpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. check that parent directory exists
|
||||||
|
parent := filepath.Join(mountpoint, "..")
|
||||||
|
if _, err := os.Stat(parent); err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("parent of mountpoint directory does not exist: %s", parent)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to check parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. use network mode for directory mounts
|
||||||
|
mountArgs = append(mountArgs, "--VolumePrefix=\\nak\\"+filepath.Base(mountpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
if isVerbose {
|
||||||
|
mountArgs = append(mountArgs, "-o", "debug")
|
||||||
|
}
|
||||||
|
mountArgs = append(mountArgs, mountpoint)
|
||||||
|
|
||||||
|
log("ok.\n")
|
||||||
|
|
||||||
|
if !host.Mount("", mountArgs) {
|
||||||
|
return fmt.Errorf("failed to mount filesystem")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
241
gift.go
241
gift.go
@@ -4,10 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
"fiatjaf.com/nostr"
|
||||||
|
"fiatjaf.com/nostr/keyer"
|
||||||
"fiatjaf.com/nostr/nip44"
|
"fiatjaf.com/nostr/nip44"
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/mailru/easyjson"
|
"github.com/mailru/easyjson"
|
||||||
"github.com/urfave/cli/v3"
|
"github.com/urfave/cli/v3"
|
||||||
)
|
)
|
||||||
@@ -16,19 +20,29 @@ var gift = &cli.Command{
|
|||||||
Name: "gift",
|
Name: "gift",
|
||||||
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
Usage: "gift-wraps (or unwraps) an event according to NIP-59",
|
||||||
Description: `example:
|
Description: `example:
|
||||||
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>`,
|
nak event | nak gift wrap --sec <sec-a> -p <sec-b> | nak gift unwrap --sec <sec-b> --from <pub-a>
|
||||||
|
|
||||||
|
a decoupled key (if it has been created or received with "nak dekey" previously) will be used by default.`,
|
||||||
DisableSliceFlagSeparator: true,
|
DisableSliceFlagSeparator: true,
|
||||||
|
Flags: defaultKeyFlags,
|
||||||
Commands: []*cli.Command{
|
Commands: []*cli.Command{
|
||||||
{
|
{
|
||||||
Name: "wrap",
|
Name: "wrap",
|
||||||
Flags: append(
|
Flags: []cli.Flag{
|
||||||
defaultKeyFlags,
|
|
||||||
&PubKeyFlag{
|
&PubKeyFlag{
|
||||||
Name: "recipient-pubkey",
|
Name: "recipient-pubkey",
|
||||||
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
Aliases: []string{"p", "tgt", "target", "pubkey", "to"},
|
||||||
Required: true,
|
Required: true,
|
||||||
},
|
},
|
||||||
),
|
&cli.BoolFlag{
|
||||||
|
Name: "use-our-identity-key",
|
||||||
|
Usage: "Encrypt with the key given to --sec directly even when a decoupled key exists for the sender.",
|
||||||
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "use-their-identity-key",
|
||||||
|
Usage: "Encrypt to the public key given as --recipient-pubkey directly even when a decoupled key exists for the receiver.",
|
||||||
|
},
|
||||||
|
},
|
||||||
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
|
Usage: "turns an event into a rumor (unsigned) then gift-wraps it to the recipient",
|
||||||
Description: `example:
|
Description: `example:
|
||||||
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
|
nak event -c 'hello' | nak gift wrap --sec <my-secret-key> -p <target-public-key>`,
|
||||||
@@ -38,14 +52,46 @@ var gift = &cli.Command{
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
recipient := getPubKey(c, "recipient-pubkey")
|
// get sender pubkey (ourselves)
|
||||||
|
|
||||||
// get sender pubkey
|
|
||||||
sender, err := kr.GetPublicKey(ctx)
|
sender, err := kr.GetPublicKey(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
return fmt.Errorf("failed to get sender pubkey: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var using bool
|
||||||
|
|
||||||
|
var cipher nostr.Cipher = kr
|
||||||
|
// use decoupled key if it exists
|
||||||
|
using = false
|
||||||
|
if !c.Bool("use-our-identity-key") {
|
||||||
|
configPath := c.String("config-path")
|
||||||
|
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, sender)
|
||||||
|
if has {
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --encrypt-with-our-identity-key to bypass", err)
|
||||||
|
}
|
||||||
|
cipher = keyer.NewPlainKeySigner(eSec)
|
||||||
|
log("- using our decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||||
|
using = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !using {
|
||||||
|
log("- using our identity key %s\n", color.CyanString(sender.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient := getPubKey(c, "recipient-pubkey")
|
||||||
|
using = false
|
||||||
|
if !c.Bool("use-their-identity-key") {
|
||||||
|
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, recipient); exists {
|
||||||
|
recipient = theirEPub
|
||||||
|
using = true
|
||||||
|
log("- using their decoupled encryption public key %s\n", color.CyanString(theirEPub.Hex()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !using {
|
||||||
|
log("- using their identity public key %s\n", color.CyanString(recipient.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
// read event from stdin
|
// read event from stdin
|
||||||
for eventJSON := range getJsonsOrBlank() {
|
for eventJSON := range getJsonsOrBlank() {
|
||||||
if eventJSON == "{}" {
|
if eventJSON == "{}" {
|
||||||
@@ -65,7 +111,7 @@ var gift = &cli.Command{
|
|||||||
|
|
||||||
// create seal
|
// create seal
|
||||||
rumorJSON, _ := easyjson.Marshal(rumor)
|
rumorJSON, _ := easyjson.Marshal(rumor)
|
||||||
encryptedRumor, err := kr.Encrypt(ctx, string(rumorJSON), recipient)
|
encryptedRumor, err := cipher.Encrypt(ctx, string(rumorJSON), recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
return fmt.Errorf("failed to encrypt rumor: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,22 +160,30 @@ var gift = &cli.Command{
|
|||||||
Name: "unwrap",
|
Name: "unwrap",
|
||||||
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
Usage: "decrypts a gift-wrap event sent by the sender to us and exposes its internal rumor (unsigned event).",
|
||||||
Description: `example:
|
Description: `example:
|
||||||
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key> --from <sender-public-key>`,
|
nak req -p <my-public-key> -k 1059 dmrelay.com | nak gift unwrap --sec <my-secret-key>`,
|
||||||
Flags: append(
|
|
||||||
defaultKeyFlags,
|
|
||||||
&PubKeyFlag{
|
|
||||||
Name: "sender-pubkey",
|
|
||||||
Aliases: []string{"p", "src", "source", "pubkey", "from"},
|
|
||||||
Required: true,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Action: func(ctx context.Context, c *cli.Command) error {
|
Action: func(ctx context.Context, c *cli.Command) error {
|
||||||
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
sender := getPubKey(c, "sender-pubkey")
|
// get receiver public key (ourselves)
|
||||||
|
receiver, err := kr.GetPublicKey(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ciphers := []nostr.Cipher{kr}
|
||||||
|
// use decoupled key if it exists
|
||||||
|
configPath := c.String("config-path")
|
||||||
|
eSec, has, err := getDecoupledEncryptionSecretKey(ctx, configPath, receiver)
|
||||||
|
if has {
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("our decoupled encryption key exists, but we failed to get it: %w; call `nak dekey` to attempt a fix or call this again with --use-direct to bypass", err)
|
||||||
|
}
|
||||||
|
ciphers = append(ciphers, kr)
|
||||||
|
ciphers[0] = keyer.NewPlainKeySigner(eSec) // pub decoupled key first
|
||||||
|
}
|
||||||
|
|
||||||
// read gift-wrapped event from stdin
|
// read gift-wrapped event from stdin
|
||||||
for wrapJSON := range getJsonsOrBlank() {
|
for wrapJSON := range getJsonsOrBlank() {
|
||||||
@@ -146,36 +200,79 @@ var gift = &cli.Command{
|
|||||||
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
return fmt.Errorf("not a gift wrap event (kind %d)", wrap.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
ephemeralPubkey := wrap.PubKey
|
// decrypt seal (in the process also find out if they encrypted it to our identity key or to our decoupled key)
|
||||||
|
var cipher nostr.Cipher
|
||||||
// decrypt seal
|
|
||||||
sealJSON, err := kr.Decrypt(ctx, wrap.Content, ephemeralPubkey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to decrypt seal: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var seal nostr.Event
|
var seal nostr.Event
|
||||||
if err := easyjson.Unmarshal([]byte(sealJSON), &seal); err != nil {
|
|
||||||
return fmt.Errorf("invalid seal JSON: %w", err)
|
// try both the receiver identity key and decoupled key
|
||||||
|
err = nil
|
||||||
|
for c, potentialCipher := range ciphers {
|
||||||
|
switch c {
|
||||||
|
case 0:
|
||||||
|
log("- trying the receiver's decoupled encryption key %s\n", color.CyanString(eSec.Public().Hex()))
|
||||||
|
case 1:
|
||||||
|
log("- trying the receiver's identity key %s\n", color.CyanString(receiver.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
sealj, thisErr := potentialCipher.Decrypt(ctx, wrap.Content, wrap.PubKey)
|
||||||
|
if thisErr != nil {
|
||||||
|
err = thisErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if thisErr := easyjson.Unmarshal([]byte(sealj), &seal); thisErr != nil {
|
||||||
|
err = fmt.Errorf("invalid seal JSON: %w", thisErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cipher = potentialCipher
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if seal.ID == nostr.ZeroID {
|
||||||
|
// if both ciphers failed above we'll reach here
|
||||||
|
return fmt.Errorf("failed to decrypt seal: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if seal.Kind != 13 {
|
if seal.Kind != 13 {
|
||||||
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
return fmt.Errorf("not a seal event (kind %d)", seal.Kind)
|
||||||
}
|
}
|
||||||
|
|
||||||
// decrypt rumor
|
senderEncryptionPublicKeys := []nostr.PubKey{seal.PubKey}
|
||||||
rumorJSON, err := kr.Decrypt(ctx, seal.Content, sender)
|
if theirEPub, exists := getDecoupledEncryptionPublicKey(ctx, seal.PubKey); exists {
|
||||||
if err != nil {
|
senderEncryptionPublicKeys = append(senderEncryptionPublicKeys, seal.PubKey)
|
||||||
|
senderEncryptionPublicKeys[0] = theirEPub // put decoupled key first
|
||||||
|
}
|
||||||
|
|
||||||
|
// decrypt rumor (at this point we know what cipher is the one they encrypted to)
|
||||||
|
// (but we don't know if they have encrypted with their identity key or their decoupled key, so try both)
|
||||||
|
var rumor nostr.Event
|
||||||
|
err = nil
|
||||||
|
for s, senderEncryptionPublicKey := range senderEncryptionPublicKeys {
|
||||||
|
switch s {
|
||||||
|
case 0:
|
||||||
|
log("- trying the sender's decoupled encryption public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||||
|
case 1:
|
||||||
|
log("- trying the sender's identity public key %s\n", color.CyanString(senderEncryptionPublicKey.Hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
rumorj, thisErr := cipher.Decrypt(ctx, seal.Content, senderEncryptionPublicKey)
|
||||||
|
if thisErr != nil {
|
||||||
|
err = fmt.Errorf("failed to decrypt rumor: %w", thisErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if thisErr := easyjson.Unmarshal([]byte(rumorj), &rumor); thisErr != nil {
|
||||||
|
err = fmt.Errorf("invalid rumor JSON: %w", thisErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if rumor.ID == nostr.ZeroID {
|
||||||
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
return fmt.Errorf("failed to decrypt rumor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var rumor nostr.Event
|
|
||||||
if err := easyjson.Unmarshal([]byte(rumorJSON), &rumor); err != nil {
|
|
||||||
return fmt.Errorf("invalid rumor JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// output the unwrapped event (rumor)
|
// output the unwrapped event (rumor)
|
||||||
stdout(rumorJSON)
|
stdout(rumor.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -190,3 +287,73 @@ func randomNow() nostr.Timestamp {
|
|||||||
randomOffset := rand.Int63n(twoDays)
|
randomOffset := rand.Int63n(twoDays)
|
||||||
return nostr.Timestamp(now - randomOffset)
|
return nostr.Timestamp(now - randomOffset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDecoupledEncryptionSecretKey(ctx context.Context, configPath string, pubkey nostr.PubKey) (nostr.SecretKey, bool, error) {
|
||||||
|
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||||
|
|
||||||
|
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{10044},
|
||||||
|
Authors: []nostr.PubKey{pubkey},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||||
|
|
||||||
|
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||||
|
if ok {
|
||||||
|
var ePub nostr.PubKey
|
||||||
|
|
||||||
|
// get the pub from the tag
|
||||||
|
for _, tag := range keyAnnouncementEvent.Tags {
|
||||||
|
if len(tag) >= 2 && tag[0] == "n" {
|
||||||
|
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ePub == nostr.ZeroPK {
|
||||||
|
return [32]byte{}, true, fmt.Errorf("got invalid kind:10044 event, no 'n' tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if we have the key
|
||||||
|
eKeyPath := filepath.Join(configPath, "dekey", "p", pubkey.Hex(), "e", ePub.Hex())
|
||||||
|
if data, err := os.ReadFile(eKeyPath); err == nil {
|
||||||
|
eSec, err := nostr.SecretKeyFromHex(string(data))
|
||||||
|
if err != nil {
|
||||||
|
return [32]byte{}, true, fmt.Errorf("invalid main key: %w", err)
|
||||||
|
}
|
||||||
|
if eSec.Public() != ePub {
|
||||||
|
return [32]byte{}, true, fmt.Errorf("stored decoupled encryption key is corrupted: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return eSec, true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [32]byte{}, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getDecoupledEncryptionPublicKey(ctx context.Context, pubkey nostr.PubKey) (nostr.PubKey, bool) {
|
||||||
|
relays := sys.FetchWriteRelays(ctx, pubkey)
|
||||||
|
|
||||||
|
keyAnnouncementResult := sys.Pool.FetchManyReplaceable(ctx, relays, nostr.Filter{
|
||||||
|
Kinds: []nostr.Kind{10044},
|
||||||
|
Authors: []nostr.PubKey{pubkey},
|
||||||
|
}, nostr.SubscriptionOptions{Label: "nak-nip4e-gift"})
|
||||||
|
|
||||||
|
keyAnnouncementEvent, ok := keyAnnouncementResult.Load(nostr.ReplaceableKey{PubKey: pubkey, D: ""})
|
||||||
|
if ok {
|
||||||
|
var ePub nostr.PubKey
|
||||||
|
|
||||||
|
// get the pub from the tag
|
||||||
|
for _, tag := range keyAnnouncementEvent.Tags {
|
||||||
|
if len(tag) >= 2 && tag[0] == "n" {
|
||||||
|
ePub, _ = nostr.PubKeyFromHex(tag[1])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ePub == nostr.ZeroPK {
|
||||||
|
return nostr.ZeroPK, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return ePub, true
|
||||||
|
}
|
||||||
|
|
||||||
|
return nostr.ZeroPK, false
|
||||||
|
}
|
||||||
|
|||||||
4
git.go
4
git.go
@@ -277,6 +277,10 @@ aside from those, there is also:
|
|||||||
|
|
||||||
// prompt for web URLs
|
// prompt for web URLs
|
||||||
webURLs, err := promptForStringList("web URLs", config.Web, []string{
|
webURLs, err := promptForStringList("web URLs", config.Web, []string{
|
||||||
|
fmt.Sprintf("https://viewsource.win/%s/%s",
|
||||||
|
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
||||||
|
config.Identifier,
|
||||||
|
),
|
||||||
fmt.Sprintf("https://gitworkshop.dev/%s/%s",
|
fmt.Sprintf("https://gitworkshop.dev/%s/%s",
|
||||||
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
nip19.EncodeNpub(nostr.MustPubKeyFromHex(config.Owner)),
|
||||||
config.Identifier,
|
config.Identifier,
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -4,7 +4,7 @@ go 1.25
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
fiatjaf.com/lib v0.3.1
|
fiatjaf.com/lib v0.3.1
|
||||||
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb
|
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 +12,7 @@ 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/hanwen/go-fuse/v2 v2.7.2
|
|
||||||
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
|
||||||
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
github.com/puzpuzpuz/xsync/v3 v3.5.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
github.com/urfave/cli/v3 v3.0.0-beta1
|
github.com/urfave/cli/v3 v3.0.0-beta1
|
||||||
|
github.com/winfsp/cgofuse v1.6.0
|
||||||
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
|
||||||
golang.org/x/sync v0.18.0
|
golang.org/x/sync v0.18.0
|
||||||
golang.org/x/term v0.32.0
|
golang.org/x/term v0.32.0
|
||||||
@@ -69,7 +70,6 @@ require (
|
|||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/magefile/mage v1.14.0 // indirect
|
github.com/magefile/mage v1.14.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
|||||||
14
go.sum
14
go.sum
@@ -1,7 +1,7 @@
|
|||||||
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
fiatjaf.com/lib v0.3.1 h1:/oFQwNtFRfV+ukmOCxfBEAuayoLwXp4wu2/fz5iHpwA=
|
||||||
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
fiatjaf.com/lib v0.3.1/go.mod h1:Ycqq3+mJ9jAWu7XjbQI1cVr+OFgnHn79dQR5oTII47g=
|
||||||
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb h1:GuqPn1g0JRD/dGxFRxEwEFxvbcT3vyvMjP3OoeLIIh0=
|
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6 h1:yH+cU9ZNgUdMCRa5eS3pmqTPP/QdZtSmQAIrN/U5nEc=
|
||||||
fiatjaf.com/nostr v0.0.0-20251222025842-099569ea4feb/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
fiatjaf.com/nostr v0.0.0-20251230181913-e52ffa631bd6/go.mod h1:ue7yw0zHfZj23Ml2kVSdBx0ENEaZiuvGxs/8VEN93FU=
|
||||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
github.com/AlecAivazis/survey/v2 v2.3.7 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=
|
||||||
@@ -144,8 +144,8 @@ github.com/hablullah/go-hijri v1.0.2 h1:drT/MZpSZJQXo7jftf5fthArShcaMtsal0Zf/dnm
|
|||||||
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
github.com/hablullah/go-hijri v1.0.2/go.mod h1:OS5qyYLDjORXzK4O1adFw9Q5WfhOcMdAKglDkcTxgWQ=
|
||||||
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
github.com/hablullah/go-juliandays v1.0.0 h1:A8YM7wIj16SzlKT0SRJc9CD29iiaUzpBLzh5hr0/5p0=
|
||||||
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
github.com/hablullah/go-juliandays v1.0.0/go.mod h1:0JOYq4oFOuDja+oospuc61YoX+uNEn7Z6uHYTbBzdGc=
|
||||||
github.com/hanwen/go-fuse/v2 v2.7.2 h1:SbJP1sUP+n1UF8NXBA14BuojmTez+mDgOk0bC057HQw=
|
github.com/hanwen/go-fuse/v2 v2.9.0 h1:0AOGUkHtbOVeyGLr0tXupiid1Vg7QB7M6YUcdmVdC58=
|
||||||
github.com/hanwen/go-fuse/v2 v2.7.2/go.mod h1:ugNaD/iv5JYyS1Rcvi57Wz7/vrLQJo10mmketmoef48=
|
github.com/hanwen/go-fuse/v2 v2.9.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
@@ -200,8 +200,8 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f
|
|||||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI=
|
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -269,6 +269,8 @@ github.com/wasilibs/go-re2 v1.3.0 h1:LFhBNzoStM3wMie6rN2slD1cuYH2CGiHpvNL3UtcsMw
|
|||||||
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
github.com/wasilibs/go-re2 v1.3.0/go.mod h1:AafrCXVvGRJJOImMajgJ2M7rVmWyisVK7sFshbxnVrg=
|
||||||
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
github.com/wasilibs/nottinygc v0.4.0 h1:h1TJMihMC4neN6Zq+WKpLxgd9xCFMw7O9ETLwY2exJQ=
|
||||||
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
github.com/wasilibs/nottinygc v0.4.0/go.mod h1:oDcIotskuYNMpqMF23l7Z8uzD4TC0WXHK8jetlB3HIo=
|
||||||
|
github.com/winfsp/cgofuse v1.6.0 h1:re3W+HTd0hj4fISPBqfsrwyvPFpzqhDu8doJ9nOPDB0=
|
||||||
|
github.com/winfsp/cgofuse v1.6.0/go.mod h1:uxjoF2jEYT3+x+vC2KJddEGdk/LU8pRowXmyVMHSV5I=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
|||||||
12
helpers.go
12
helpers.go
@@ -46,8 +46,14 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func isPiped() bool {
|
func isPiped() bool {
|
||||||
stat, _ := os.Stdin.Stat()
|
stat, err := os.Stdin.Stat()
|
||||||
return stat.Mode()&os.ModeCharDevice == 0
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := stat.Mode()
|
||||||
|
is := mode&os.ModeCharDevice == 0
|
||||||
|
return is
|
||||||
}
|
}
|
||||||
|
|
||||||
func getJsonsOrBlank() iter.Seq[string] {
|
func getJsonsOrBlank() iter.Seq[string] {
|
||||||
@@ -76,7 +82,7 @@ func getJsonsOrBlank() iter.Seq[string] {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
if !hasStdin && !isPiped() {
|
if !hasStdin {
|
||||||
yield("{}")
|
yield("{}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
key.go
13
key.go
@@ -279,12 +279,13 @@ func getSecretKeysFromStdinLinesOrSlice(ctx context.Context, _ *cli.Command, key
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sk = data.(nostr.SecretKey)
|
sk = data.(nostr.SecretKey)
|
||||||
}
|
} else {
|
||||||
|
var err error
|
||||||
sk, err := nostr.SecretKeyFromHex(sec)
|
sk, err = nostr.SecretKeyFromHex(sec)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
ctx = lineProcessingError(ctx, "invalid hex key: %s", err)
|
||||||
continue
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ch <- sk
|
ch <- sk
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
)
|
|
||||||
|
|
||||||
type AsyncFile struct {
|
|
||||||
fs.Inode
|
|
||||||
ctx context.Context
|
|
||||||
fetched atomic.Bool
|
|
||||||
data []byte
|
|
||||||
ts nostr.Timestamp
|
|
||||||
load func() ([]byte, nostr.Timestamp)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ = (fs.NodeOpener)((*AsyncFile)(nil))
|
|
||||||
_ = (fs.NodeGetattrer)((*AsyncFile)(nil))
|
|
||||||
)
|
|
||||||
|
|
||||||
func (af *AsyncFile) Getattr(ctx context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
if af.fetched.CompareAndSwap(false, true) {
|
|
||||||
af.data, af.ts = af.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
out.Size = uint64(len(af.data))
|
|
||||||
out.Mtime = uint64(af.ts)
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (af *AsyncFile) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
|
||||||
if af.fetched.CompareAndSwap(false, true) {
|
|
||||||
af.data, af.ts = af.load()
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fuse.FOPEN_KEEP_CACHE, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (af *AsyncFile) Read(
|
|
||||||
ctx context.Context,
|
|
||||||
f fs.FileHandle,
|
|
||||||
dest []byte,
|
|
||||||
off int64,
|
|
||||||
) (fuse.ReadResult, syscall.Errno) {
|
|
||||||
end := int(off) + len(dest)
|
|
||||||
if end > len(af.data) {
|
|
||||||
end = len(af.data)
|
|
||||||
}
|
|
||||||
return fuse.ReadResultData(af.data[off:end]), 0
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"syscall"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DeterministicFile struct {
|
|
||||||
fs.Inode
|
|
||||||
get func() (ctime, mtime uint64, data string)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ = (fs.NodeOpener)((*DeterministicFile)(nil))
|
|
||||||
_ = (fs.NodeReader)((*DeterministicFile)(nil))
|
|
||||||
_ = (fs.NodeGetattrer)((*DeterministicFile)(nil))
|
|
||||||
)
|
|
||||||
|
|
||||||
func (r *NostrRoot) NewDeterministicFile(get func() (ctime, mtime uint64, data string)) *DeterministicFile {
|
|
||||||
return &DeterministicFile{
|
|
||||||
get: get,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *DeterministicFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
|
||||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *DeterministicFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
var content string
|
|
||||||
out.Mode = 0444
|
|
||||||
out.Ctime, out.Mtime, content = f.get()
|
|
||||||
out.Size = uint64(len(content))
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *DeterministicFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
|
||||||
_, _, content := f.get()
|
|
||||||
data := unsafe.Slice(unsafe.StringData(content), len(content))
|
|
||||||
|
|
||||||
end := int(off) + len(dest)
|
|
||||||
if end > len(data) {
|
|
||||||
end = len(data)
|
|
||||||
}
|
|
||||||
return fuse.ReadResultData(data[off:end]), fs.OK
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"fiatjaf.com/lib/debouncer"
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/nip19"
|
|
||||||
"fiatjaf.com/nostr/nip27"
|
|
||||||
"fiatjaf.com/nostr/nip73"
|
|
||||||
"fiatjaf.com/nostr/nip92"
|
|
||||||
sdk "fiatjaf.com/nostr/sdk"
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EntityDir struct {
|
|
||||||
fs.Inode
|
|
||||||
root *NostrRoot
|
|
||||||
|
|
||||||
publisher *debouncer.Debouncer
|
|
||||||
event *nostr.Event
|
|
||||||
updating struct {
|
|
||||||
title string
|
|
||||||
content string
|
|
||||||
publishedAt uint64
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ = (fs.NodeOnAdder)((*EntityDir)(nil))
|
|
||||||
_ = (fs.NodeGetattrer)((*EntityDir)(nil))
|
|
||||||
_ = (fs.NodeSetattrer)((*EntityDir)(nil))
|
|
||||||
_ = (fs.NodeCreater)((*EntityDir)(nil))
|
|
||||||
_ = (fs.NodeUnlinker)((*EntityDir)(nil))
|
|
||||||
)
|
|
||||||
|
|
||||||
func (e *EntityDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
out.Ctime = uint64(e.event.CreatedAt)
|
|
||||||
if e.updating.publishedAt != 0 {
|
|
||||||
out.Mtime = e.updating.publishedAt
|
|
||||||
} else {
|
|
||||||
out.Mtime = e.PublishedAt()
|
|
||||||
}
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) Create(
|
|
||||||
_ context.Context,
|
|
||||||
name string,
|
|
||||||
flags uint32,
|
|
||||||
mode uint32,
|
|
||||||
out *fuse.EntryOut,
|
|
||||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
|
||||||
if name == "publish" && e.publisher.IsRunning() {
|
|
||||||
// this causes the publish process to be triggered faster
|
|
||||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
log("publishing now!\n")
|
|
||||||
e.publisher.Flush()
|
|
||||||
return nil, nil, 0, syscall.ENOTDIR
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil, 0, syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
|
||||||
switch name {
|
|
||||||
case "content" + kindToExtension(e.event.Kind):
|
|
||||||
e.updating.content = e.event.Content
|
|
||||||
return syscall.ENOTDIR
|
|
||||||
case "title":
|
|
||||||
e.updating.title = e.Title()
|
|
||||||
return syscall.ENOTDIR
|
|
||||||
default:
|
|
||||||
return syscall.EINTR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
|
||||||
e.updating.publishedAt = in.Mtime
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) OnAdd(_ context.Context) {
|
|
||||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
|
|
||||||
e.AddChild("@author", e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(e.root.wd + "/" + nip19.EncodeNpub(e.event.PubKey)),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
e.AddChild("event.json", e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&DeterministicFile{
|
|
||||||
get: func() (ctime uint64, mtime uint64, data string) {
|
|
||||||
eventj, _ := json.MarshalIndent(e.event, "", " ")
|
|
||||||
return uint64(e.event.CreatedAt),
|
|
||||||
uint64(e.event.CreatedAt),
|
|
||||||
unsafe.String(unsafe.SliceData(eventj), len(eventj))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
e.AddChild("identifier", e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: []byte(e.event.Tags.GetD()),
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mode: 0444,
|
|
||||||
Ctime: uint64(e.event.CreatedAt),
|
|
||||||
Mtime: uint64(e.event.CreatedAt),
|
|
||||||
Size: uint64(len(e.event.Tags.GetD())),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
if e.root.signer == nil || e.root.rootPubKey != e.event.PubKey {
|
|
||||||
// read-only
|
|
||||||
e.AddChild("title", e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&DeterministicFile{
|
|
||||||
get: func() (ctime uint64, mtime uint64, data string) {
|
|
||||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.Title()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&DeterministicFile{
|
|
||||||
get: func() (ctime uint64, mtime uint64, data string) {
|
|
||||||
return uint64(e.event.CreatedAt), e.PublishedAt(), e.event.Content
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
} else {
|
|
||||||
// writeable
|
|
||||||
e.updating.title = e.Title()
|
|
||||||
e.updating.publishedAt = e.PublishedAt()
|
|
||||||
e.updating.content = e.event.Content
|
|
||||||
|
|
||||||
e.AddChild("title", e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
e.root.NewWriteableFile(e.updating.title, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
|
||||||
log("title updated")
|
|
||||||
e.updating.title = strings.TrimSpace(s)
|
|
||||||
e.handleWrite()
|
|
||||||
}),
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
e.AddChild("content."+kindToExtension(e.event.Kind), e.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
e.root.NewWriteableFile(e.updating.content, uint64(e.event.CreatedAt), e.updating.publishedAt, func(s string) {
|
|
||||||
log("content updated")
|
|
||||||
e.updating.content = strings.TrimSpace(s)
|
|
||||||
e.handleWrite()
|
|
||||||
}),
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
var refsdir *fs.Inode
|
|
||||||
i := 0
|
|
||||||
for ref := range nip27.Parse(e.event.Content) {
|
|
||||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
|
|
||||||
if refsdir == nil {
|
|
||||||
refsdir = e.NewPersistentInode(e.root.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
|
||||||
e.root.AddChild("references", refsdir, true)
|
|
||||||
}
|
|
||||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(e.root.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
var imagesdir *fs.Inode
|
|
||||||
addImage := func(url string) {
|
|
||||||
if imagesdir == nil {
|
|
||||||
in := &fs.Inode{}
|
|
||||||
imagesdir = e.NewPersistentInode(e.root.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
|
||||||
e.AddChild("images", imagesdir, true)
|
|
||||||
}
|
|
||||||
imagesdir.AddChild(filepath.Base(url), imagesdir.NewPersistentInode(
|
|
||||||
e.root.ctx,
|
|
||||||
&AsyncFile{
|
|
||||||
ctx: e.root.ctx,
|
|
||||||
load: func() ([]byte, nostr.Timestamp) {
|
|
||||||
ctx, cancel := context.WithTimeout(e.root.ctx, time.Second*20)
|
|
||||||
defer cancel()
|
|
||||||
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
||||||
if err != nil {
|
|
||||||
log("failed to load image %s: %s\n", url, err)
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(r)
|
|
||||||
if err != nil {
|
|
||||||
log("failed to load image %s: %s\n", url, err)
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
log("failed to load image %s: %s\n", url, err)
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
w := &bytes.Buffer{}
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
return w.Bytes(), 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
images := nip92.ParseTags(e.event.Tags)
|
|
||||||
for _, imeta := range images {
|
|
||||||
if imeta.URL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
addImage(imeta.URL)
|
|
||||||
}
|
|
||||||
|
|
||||||
if tag := e.event.Tags.Find("image"); tag != nil {
|
|
||||||
addImage(tag[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) IsNew() bool {
|
|
||||||
return e.event.CreatedAt == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) PublishedAt() uint64 {
|
|
||||||
if tag := e.event.Tags.Find("published_at"); tag != nil {
|
|
||||||
publishedAt, _ := strconv.ParseUint(tag[1], 10, 64)
|
|
||||||
return publishedAt
|
|
||||||
}
|
|
||||||
return uint64(e.event.CreatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) Title() string {
|
|
||||||
if tag := e.event.Tags.Find("title"); tag != nil {
|
|
||||||
return tag[1]
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EntityDir) handleWrite() {
|
|
||||||
log := e.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
logverbose := e.root.ctx.Value("logverbose").(func(msg string, args ...any))
|
|
||||||
|
|
||||||
if e.root.opts.AutoPublishArticlesTimeout.Hours() < 24*365 {
|
|
||||||
if e.publisher.IsRunning() {
|
|
||||||
log(", timer reset")
|
|
||||||
}
|
|
||||||
log(", publishing the ")
|
|
||||||
if e.IsNew() {
|
|
||||||
log("new")
|
|
||||||
} else {
|
|
||||||
log("updated")
|
|
||||||
}
|
|
||||||
log(" event in %d seconds...\n", int(e.root.opts.AutoPublishArticlesTimeout.Seconds()))
|
|
||||||
} else {
|
|
||||||
log(".\n")
|
|
||||||
}
|
|
||||||
if !e.publisher.IsRunning() {
|
|
||||||
log("- `touch publish` to publish immediately\n")
|
|
||||||
log("- `rm title content." + kindToExtension(e.event.Kind) + "` to erase and cancel the edits\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
e.publisher.Call(func() {
|
|
||||||
if e.Title() == e.updating.title && e.event.Content == e.updating.content {
|
|
||||||
log("not modified, publish canceled.\n")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
evt := nostr.Event{
|
|
||||||
Kind: e.event.Kind,
|
|
||||||
Content: e.updating.content,
|
|
||||||
Tags: make(nostr.Tags, len(e.event.Tags)),
|
|
||||||
CreatedAt: nostr.Now(),
|
|
||||||
}
|
|
||||||
copy(evt.Tags, e.event.Tags) // copy tags because that's the rule
|
|
||||||
if e.updating.title != "" {
|
|
||||||
if titleTag := evt.Tags.Find("title"); titleTag != nil {
|
|
||||||
titleTag[1] = e.updating.title
|
|
||||||
} else {
|
|
||||||
evt.Tags = append(evt.Tags, nostr.Tag{"title", e.updating.title})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "published_at" tag
|
|
||||||
publishedAtStr := strconv.FormatUint(e.updating.publishedAt, 10)
|
|
||||||
if publishedAtStr != "0" {
|
|
||||||
if publishedAtTag := evt.Tags.Find("published_at"); publishedAtTag != nil {
|
|
||||||
publishedAtTag[1] = publishedAtStr
|
|
||||||
} else {
|
|
||||||
evt.Tags = append(evt.Tags, nostr.Tag{"published_at", publishedAtStr})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// add "p" tags from people mentioned and "q" tags from events mentioned
|
|
||||||
for ref := range nip27.Parse(evt.Content) {
|
|
||||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
tag := ref.Pointer.AsTag()
|
|
||||||
key := tag[0]
|
|
||||||
val := tag[1]
|
|
||||||
if key == "e" || key == "a" {
|
|
||||||
key = "q"
|
|
||||||
}
|
|
||||||
if existing := evt.Tags.FindWithValue(key, val); existing == nil {
|
|
||||||
evt.Tags = append(evt.Tags, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sign and publish
|
|
||||||
if err := e.root.signer.SignEvent(e.root.ctx, &evt); err != nil {
|
|
||||||
log("failed to sign: '%s'.\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logverbose("%s\n", evt)
|
|
||||||
|
|
||||||
relays := e.root.sys.FetchWriteRelays(e.root.ctx, e.root.rootPubKey)
|
|
||||||
if len(relays) == 0 {
|
|
||||||
relays = e.root.sys.FetchOutboxRelays(e.root.ctx, e.root.rootPubKey, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
log("publishing to %d relays... ", len(relays))
|
|
||||||
success := false
|
|
||||||
first := true
|
|
||||||
for res := range e.root.sys.Pool.PublishMany(e.root.ctx, relays, evt) {
|
|
||||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
|
||||||
if !first {
|
|
||||||
log(", ")
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
|
|
||||||
if res.Error != nil {
|
|
||||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
|
||||||
} else {
|
|
||||||
success = true
|
|
||||||
log("%s: ok", color.GreenString(cleanUrl))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("\n")
|
|
||||||
|
|
||||||
if success {
|
|
||||||
e.event = &evt
|
|
||||||
log("event updated locally.\n")
|
|
||||||
e.updating.publishedAt = uint64(evt.CreatedAt) // set this so subsequent edits get the correct value
|
|
||||||
} else {
|
|
||||||
log("failed.\n")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) FetchAndCreateEntityDir(
|
|
||||||
parent fs.InodeEmbedder,
|
|
||||||
extension string,
|
|
||||||
pointer nostr.EntityPointer,
|
|
||||||
) (*fs.Inode, error) {
|
|
||||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
|
||||||
WithRelays: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.CreateEntityDir(parent, event), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) CreateEntityDir(
|
|
||||||
parent fs.InodeEmbedder,
|
|
||||||
event *nostr.Event,
|
|
||||||
) *fs.Inode {
|
|
||||||
return parent.EmbeddedInode().NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&EntityDir{root: r, event: event, publisher: debouncer.New(r.opts.AutoPublishArticlesTimeout)},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/nip10"
|
|
||||||
"fiatjaf.com/nostr/nip19"
|
|
||||||
"fiatjaf.com/nostr/nip22"
|
|
||||||
"fiatjaf.com/nostr/nip27"
|
|
||||||
"fiatjaf.com/nostr/nip73"
|
|
||||||
"fiatjaf.com/nostr/nip92"
|
|
||||||
sdk "fiatjaf.com/nostr/sdk"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type EventDir struct {
|
|
||||||
fs.Inode
|
|
||||||
ctx context.Context
|
|
||||||
wd string
|
|
||||||
evt *nostr.Event
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeGetattrer)((*EventDir)(nil))
|
|
||||||
|
|
||||||
func (e *EventDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
out.Mtime = uint64(e.evt.CreatedAt)
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) FetchAndCreateEventDir(
|
|
||||||
parent fs.InodeEmbedder,
|
|
||||||
pointer nostr.EventPointer,
|
|
||||||
) (*fs.Inode, error) {
|
|
||||||
event, _, err := r.sys.FetchSpecificEvent(r.ctx, pointer, sdk.FetchSpecificEventParameters{
|
|
||||||
WithRelays: false,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to fetch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.CreateEventDir(parent, event), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) CreateEventDir(
|
|
||||||
parent fs.InodeEmbedder,
|
|
||||||
event *nostr.Event,
|
|
||||||
) *fs.Inode {
|
|
||||||
h := parent.EmbeddedInode().NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&EventDir{ctx: r.ctx, wd: r.wd, evt: event},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(event.ID[8:16])},
|
|
||||||
)
|
|
||||||
|
|
||||||
h.AddChild("@author", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nip19.EncodeNpub(event.PubKey)),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
eventj, _ := json.MarshalIndent(event, "", " ")
|
|
||||||
h.AddChild("event.json", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: eventj,
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mode: 0444,
|
|
||||||
Ctime: uint64(event.CreatedAt),
|
|
||||||
Mtime: uint64(event.CreatedAt),
|
|
||||||
Size: uint64(len(event.Content)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
h.AddChild("id", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: []byte(event.ID.Hex()),
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mode: 0444,
|
|
||||||
Ctime: uint64(event.CreatedAt),
|
|
||||||
Mtime: uint64(event.CreatedAt),
|
|
||||||
Size: uint64(64),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
h.AddChild("content.txt", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: []byte(event.Content),
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mode: 0444,
|
|
||||||
Ctime: uint64(event.CreatedAt),
|
|
||||||
Mtime: uint64(event.CreatedAt),
|
|
||||||
Size: uint64(len(event.Content)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
var refsdir *fs.Inode
|
|
||||||
i := 0
|
|
||||||
for ref := range nip27.Parse(event.Content) {
|
|
||||||
if _, isExternal := ref.Pointer.(nip73.ExternalPointer); isExternal {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
|
|
||||||
if refsdir == nil {
|
|
||||||
refsdir = h.NewPersistentInode(r.ctx, &fs.Inode{}, fs.StableAttr{Mode: syscall.S_IFDIR})
|
|
||||||
h.AddChild("references", refsdir, true)
|
|
||||||
}
|
|
||||||
refsdir.AddChild(fmt.Sprintf("ref_%02d", i), refsdir.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nip19.EncodePointer(ref.Pointer)),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
var imagesdir *fs.Inode
|
|
||||||
images := nip92.ParseTags(event.Tags)
|
|
||||||
for _, imeta := range images {
|
|
||||||
if imeta.URL == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if imagesdir == nil {
|
|
||||||
in := &fs.Inode{}
|
|
||||||
imagesdir = h.NewPersistentInode(r.ctx, in, fs.StableAttr{Mode: syscall.S_IFDIR})
|
|
||||||
h.AddChild("images", imagesdir, true)
|
|
||||||
}
|
|
||||||
imagesdir.AddChild(filepath.Base(imeta.URL), imagesdir.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&AsyncFile{
|
|
||||||
ctx: r.ctx,
|
|
||||||
load: func() ([]byte, nostr.Timestamp) {
|
|
||||||
ctx, cancel := context.WithTimeout(r.ctx, time.Second*20)
|
|
||||||
defer cancel()
|
|
||||||
r, err := http.NewRequestWithContext(ctx, "GET", imeta.URL, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
resp, err := http.DefaultClient.Do(r)
|
|
||||||
if err != nil {
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode >= 300 {
|
|
||||||
return nil, 0
|
|
||||||
}
|
|
||||||
w := &bytes.Buffer{}
|
|
||||||
io.Copy(w, resp.Body)
|
|
||||||
return w.Bytes(), 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if event.Kind == 1 {
|
|
||||||
if pointer := nip10.GetThreadRoot(event.Tags); pointer != nil {
|
|
||||||
nevent := nip19.EncodePointer(pointer)
|
|
||||||
h.AddChild("@root", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nevent),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
if pointer := nip10.GetImmediateParent(event.Tags); pointer != nil {
|
|
||||||
nevent := nip19.EncodePointer(pointer)
|
|
||||||
h.AddChild("@parent", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nevent),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
} else if event.Kind == 1111 {
|
|
||||||
if pointer := nip22.GetThreadRoot(event.Tags); pointer != nil {
|
|
||||||
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
|
||||||
h.AddChild("@root", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
} else {
|
|
||||||
nevent := nip19.EncodePointer(pointer)
|
|
||||||
h.AddChild("@parent", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nevent),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pointer := nip22.GetImmediateParent(event.Tags); pointer != nil {
|
|
||||||
if xp, ok := pointer.(nip73.ExternalPointer); ok {
|
|
||||||
h.AddChild("@parent", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: []byte(`<!doctype html><meta http-equiv="refresh" content="0; url=` + xp.Thing + `" />`),
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
} else {
|
|
||||||
nevent := nip19.EncodePointer(pointer)
|
|
||||||
h.AddChild("@parent", h.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{
|
|
||||||
Data: []byte(r.wd + "/" + nevent),
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
)
|
|
||||||
|
|
||||||
func kindToExtension(kind nostr.Kind) string {
|
|
||||||
switch kind {
|
|
||||||
case 30023:
|
|
||||||
return "md"
|
|
||||||
case 30818:
|
|
||||||
return "adoc"
|
|
||||||
default:
|
|
||||||
return "txt"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/nip19"
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
"github.com/liamg/magic"
|
|
||||||
)
|
|
||||||
|
|
||||||
type NpubDir struct {
|
|
||||||
fs.Inode
|
|
||||||
root *NostrRoot
|
|
||||||
pointer nostr.ProfilePointer
|
|
||||||
fetched atomic.Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeOnAdder)((*NpubDir)(nil))
|
|
||||||
|
|
||||||
func (r *NostrRoot) CreateNpubDir(
|
|
||||||
parent fs.InodeEmbedder,
|
|
||||||
pointer nostr.ProfilePointer,
|
|
||||||
signer nostr.Signer,
|
|
||||||
) *fs.Inode {
|
|
||||||
npubdir := &NpubDir{root: r, pointer: pointer}
|
|
||||||
return parent.EmbeddedInode().NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
npubdir,
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR, Ino: binary.BigEndian.Uint64(pointer.PublicKey[8:16])},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *NpubDir) OnAdd(_ context.Context) {
|
|
||||||
log := h.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
|
|
||||||
relays := h.root.sys.FetchOutboxRelays(h.root.ctx, h.pointer.PublicKey, 2)
|
|
||||||
log("- adding folder for %s with relays %s\n",
|
|
||||||
color.HiYellowString(nip19.EncodePointer(h.pointer)), color.HiGreenString("%v", relays))
|
|
||||||
|
|
||||||
h.AddChild("pubkey", h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&fs.MemRegularFile{Data: []byte(h.pointer.PublicKey.Hex() + "\n"), Attr: fuse.Attr{Mode: 0444}},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
pm := h.root.sys.FetchProfileMetadata(h.root.ctx, h.pointer.PublicKey)
|
|
||||||
if pm.Event == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataj, _ := json.MarshalIndent(pm, "", " ")
|
|
||||||
h.AddChild(
|
|
||||||
"metadata.json",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: metadataj,
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mtime: uint64(pm.Event.CreatedAt),
|
|
||||||
Mode: 0444,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(h.root.ctx, time.Second*20)
|
|
||||||
defer cancel()
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", pm.Picture, nil)
|
|
||||||
if err == nil {
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
|
||||||
if err == nil {
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode < 300 {
|
|
||||||
b := &bytes.Buffer{}
|
|
||||||
io.Copy(b, resp.Body)
|
|
||||||
|
|
||||||
ext := "png"
|
|
||||||
if ft, err := magic.Lookup(b.Bytes()); err == nil {
|
|
||||||
ext = ft.Extension
|
|
||||||
}
|
|
||||||
|
|
||||||
h.AddChild("picture."+ext, h.NewPersistentInode(
|
|
||||||
ctx,
|
|
||||||
&fs.MemRegularFile{
|
|
||||||
Data: b.Bytes(),
|
|
||||||
Attr: fuse.Attr{
|
|
||||||
Mtime: uint64(pm.Event.CreatedAt),
|
|
||||||
Mode: 0444,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs.StableAttr{},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if h.GetChild("notes") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"notes",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{1},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: true,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: false,
|
|
||||||
createable: true,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("comments") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"comments",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{1111},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: true,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: false,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("photos") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"photos",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{20},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: true,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: false,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("videos") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"videos",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{21, 22},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: false,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: false,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("highlights") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"highlights",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{9802},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: false,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: false,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("articles") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"articles",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{30023},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: false,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: true,
|
|
||||||
createable: true,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.GetChild("wiki") == nil {
|
|
||||||
h.AddChild(
|
|
||||||
"wiki",
|
|
||||||
h.NewPersistentInode(
|
|
||||||
h.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: h.root,
|
|
||||||
filter: nostr.Filter{
|
|
||||||
Kinds: []nostr.Kind{30818},
|
|
||||||
Authors: []nostr.PubKey{h.pointer.PublicKey},
|
|
||||||
},
|
|
||||||
paginate: false,
|
|
||||||
relays: relays,
|
|
||||||
replaceable: true,
|
|
||||||
createable: true,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
130
nostrfs/root.go
130
nostrfs/root.go
@@ -1,130 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"path/filepath"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"fiatjaf.com/nostr/nip05"
|
|
||||||
"fiatjaf.com/nostr/nip19"
|
|
||||||
"fiatjaf.com/nostr/sdk"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Options struct {
|
|
||||||
AutoPublishNotesTimeout time.Duration
|
|
||||||
AutoPublishArticlesTimeout time.Duration
|
|
||||||
}
|
|
||||||
|
|
||||||
type NostrRoot struct {
|
|
||||||
fs.Inode
|
|
||||||
|
|
||||||
ctx context.Context
|
|
||||||
wd string
|
|
||||||
sys *sdk.System
|
|
||||||
rootPubKey nostr.PubKey
|
|
||||||
signer nostr.Signer
|
|
||||||
|
|
||||||
opts Options
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = (fs.NodeOnAdder)((*NostrRoot)(nil))
|
|
||||||
|
|
||||||
func NewNostrRoot(ctx context.Context, sys *sdk.System, user nostr.User, mountpoint string, o Options) *NostrRoot {
|
|
||||||
pubkey, _ := user.GetPublicKey(ctx)
|
|
||||||
abs, _ := filepath.Abs(mountpoint)
|
|
||||||
|
|
||||||
var signer nostr.Signer
|
|
||||||
if user != nil {
|
|
||||||
signer, _ = user.(nostr.Signer)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &NostrRoot{
|
|
||||||
ctx: ctx,
|
|
||||||
sys: sys,
|
|
||||||
rootPubKey: pubkey,
|
|
||||||
signer: signer,
|
|
||||||
wd: abs,
|
|
||||||
|
|
||||||
opts: o,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) OnAdd(_ context.Context) {
|
|
||||||
if r.rootPubKey == nostr.ZeroPK {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
time.Sleep(time.Millisecond * 100)
|
|
||||||
|
|
||||||
// add our contacts
|
|
||||||
fl := r.sys.FetchFollowList(r.ctx, r.rootPubKey)
|
|
||||||
for _, f := range fl.Items {
|
|
||||||
pointer := nostr.ProfilePointer{PublicKey: f.Pubkey, Relays: []string{f.Relay}}
|
|
||||||
r.AddChild(
|
|
||||||
nip19.EncodeNpub(f.Pubkey),
|
|
||||||
r.CreateNpubDir(r, pointer, nil),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// add ourselves
|
|
||||||
npub := nip19.EncodeNpub(r.rootPubKey)
|
|
||||||
if r.GetChild(npub) == nil {
|
|
||||||
pointer := nostr.ProfilePointer{PublicKey: r.rootPubKey}
|
|
||||||
|
|
||||||
r.AddChild(
|
|
||||||
npub,
|
|
||||||
r.CreateNpubDir(r, pointer, r.signer),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// add a link to ourselves
|
|
||||||
r.AddChild("@me", r.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{Data: []byte(r.wd + "/" + npub)},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), true)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *NostrRoot) Lookup(_ context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
|
||||||
out.SetEntryTimeout(time.Minute * 5)
|
|
||||||
|
|
||||||
child := r.GetChild(name)
|
|
||||||
if child != nil {
|
|
||||||
return child, fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
if pp, err := nip05.QueryIdentifier(r.ctx, name); err == nil {
|
|
||||||
return r.NewPersistentInode(
|
|
||||||
r.ctx,
|
|
||||||
&fs.MemSymlink{Data: []byte(r.wd + "/" + nip19.EncodePointer(*pp))},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFLNK},
|
|
||||||
), fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
pointer, err := nip19.ToPointer(name)
|
|
||||||
if err != nil {
|
|
||||||
return nil, syscall.ENOENT
|
|
||||||
}
|
|
||||||
|
|
||||||
switch p := pointer.(type) {
|
|
||||||
case nostr.ProfilePointer:
|
|
||||||
npubdir := r.CreateNpubDir(r, p, nil)
|
|
||||||
return npubdir, fs.OK
|
|
||||||
case nostr.EventPointer:
|
|
||||||
eventdir, err := r.FetchAndCreateEventDir(r, p)
|
|
||||||
if err != nil {
|
|
||||||
return nil, syscall.ENOENT
|
|
||||||
}
|
|
||||||
return eventdir, fs.OK
|
|
||||||
default:
|
|
||||||
return nil, syscall.ENOENT
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"fiatjaf.com/lib/debouncer"
|
|
||||||
"fiatjaf.com/nostr"
|
|
||||||
"github.com/fatih/color"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ViewDir struct {
|
|
||||||
fs.Inode
|
|
||||||
root *NostrRoot
|
|
||||||
fetched atomic.Bool
|
|
||||||
filter nostr.Filter
|
|
||||||
paginate bool
|
|
||||||
relays []string
|
|
||||||
replaceable bool
|
|
||||||
createable bool
|
|
||||||
publisher *debouncer.Debouncer
|
|
||||||
publishing struct {
|
|
||||||
note string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ = (fs.NodeOpendirer)((*ViewDir)(nil))
|
|
||||||
_ = (fs.NodeGetattrer)((*ViewDir)(nil))
|
|
||||||
_ = (fs.NodeMkdirer)((*ViewDir)(nil))
|
|
||||||
_ = (fs.NodeSetattrer)((*ViewDir)(nil))
|
|
||||||
_ = (fs.NodeCreater)((*ViewDir)(nil))
|
|
||||||
_ = (fs.NodeUnlinker)((*ViewDir)(nil))
|
|
||||||
)
|
|
||||||
|
|
||||||
func (f *ViewDir) Setattr(_ context.Context, _ fs.FileHandle, _ *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) Create(
|
|
||||||
_ context.Context,
|
|
||||||
name string,
|
|
||||||
flags uint32,
|
|
||||||
mode uint32,
|
|
||||||
out *fuse.EntryOut,
|
|
||||||
) (node *fs.Inode, fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
|
||||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
|
||||||
return nil, nil, 0, syscall.EPERM
|
|
||||||
}
|
|
||||||
if n.publisher == nil {
|
|
||||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
|
||||||
}
|
|
||||||
if n.filter.Kinds[0] != 1 {
|
|
||||||
return nil, nil, 0, syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "new":
|
|
||||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
|
|
||||||
if n.publisher.IsRunning() {
|
|
||||||
log("pending note updated, timer reset.")
|
|
||||||
} else {
|
|
||||||
log("new note detected")
|
|
||||||
if n.root.opts.AutoPublishNotesTimeout.Hours() < 24*365 {
|
|
||||||
log(", publishing it in %d seconds...\n", int(n.root.opts.AutoPublishNotesTimeout.Seconds()))
|
|
||||||
} else {
|
|
||||||
log(".\n")
|
|
||||||
}
|
|
||||||
log("- `touch publish` to publish immediately\n")
|
|
||||||
log("- `rm new` to erase and cancel the publication.\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
n.publisher.Call(n.publishNote)
|
|
||||||
|
|
||||||
first := true
|
|
||||||
|
|
||||||
return n.NewPersistentInode(
|
|
||||||
n.root.ctx,
|
|
||||||
n.root.NewWriteableFile(n.publishing.note, uint64(nostr.Now()), uint64(nostr.Now()), func(s string) {
|
|
||||||
if !first {
|
|
||||||
log("pending note updated, timer reset.\n")
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
n.publishing.note = strings.TrimSpace(s)
|
|
||||||
n.publisher.Call(n.publishNote)
|
|
||||||
}),
|
|
||||||
fs.StableAttr{},
|
|
||||||
), nil, 0, fs.OK
|
|
||||||
case "publish":
|
|
||||||
if n.publisher.IsRunning() {
|
|
||||||
// this causes the publish process to be triggered faster
|
|
||||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
log("publishing now!\n")
|
|
||||||
n.publisher.Flush()
|
|
||||||
return nil, nil, 0, syscall.ENOTDIR
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, nil, 0, syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) Unlink(ctx context.Context, name string) syscall.Errno {
|
|
||||||
if !n.createable || n.root.rootPubKey != n.filter.Authors[0] {
|
|
||||||
return syscall.EPERM
|
|
||||||
}
|
|
||||||
if n.publisher == nil {
|
|
||||||
n.publisher = debouncer.New(n.root.opts.AutoPublishNotesTimeout)
|
|
||||||
}
|
|
||||||
if n.filter.Kinds[0] != 1 {
|
|
||||||
return syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "new":
|
|
||||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
log("publishing canceled.\n")
|
|
||||||
n.publisher.Stop()
|
|
||||||
n.publishing.note = ""
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
return syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) publishNote() {
|
|
||||||
log := n.root.ctx.Value("log").(func(msg string, args ...any))
|
|
||||||
|
|
||||||
log("publishing note...\n")
|
|
||||||
evt := nostr.Event{
|
|
||||||
Kind: 1,
|
|
||||||
CreatedAt: nostr.Now(),
|
|
||||||
Content: n.publishing.note,
|
|
||||||
Tags: make(nostr.Tags, 0, 2),
|
|
||||||
}
|
|
||||||
|
|
||||||
// our write relays
|
|
||||||
relays := n.root.sys.FetchWriteRelays(n.root.ctx, n.root.rootPubKey)
|
|
||||||
if len(relays) == 0 {
|
|
||||||
relays = n.root.sys.FetchOutboxRelays(n.root.ctx, n.root.rootPubKey, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
// massage and extract tags from raw text
|
|
||||||
targetRelays := n.root.sys.PrepareNoteEvent(n.root.ctx, &evt)
|
|
||||||
relays = nostr.AppendUnique(relays, targetRelays...)
|
|
||||||
|
|
||||||
// sign and publish
|
|
||||||
if err := n.root.signer.SignEvent(n.root.ctx, &evt); err != nil {
|
|
||||||
log("failed to sign: %s\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log(evt.String() + "\n")
|
|
||||||
|
|
||||||
log("publishing to %d relays... ", len(relays))
|
|
||||||
success := false
|
|
||||||
first := true
|
|
||||||
for res := range n.root.sys.Pool.PublishMany(n.root.ctx, relays, evt) {
|
|
||||||
cleanUrl, _ := strings.CutPrefix(res.RelayURL, "wss://")
|
|
||||||
if !first {
|
|
||||||
log(", ")
|
|
||||||
}
|
|
||||||
first = false
|
|
||||||
|
|
||||||
if res.Error != nil {
|
|
||||||
log("%s: %s", color.RedString(cleanUrl), res.Error)
|
|
||||||
} else {
|
|
||||||
success = true
|
|
||||||
log("%s: ok", color.GreenString(cleanUrl))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log("\n")
|
|
||||||
|
|
||||||
if success {
|
|
||||||
n.RmChild("new")
|
|
||||||
n.AddChild(evt.ID.Hex(), n.root.CreateEventDir(n, &evt), true)
|
|
||||||
log("event published as %s and updated locally.\n", color.BlueString(evt.ID.Hex()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) Getattr(_ context.Context, f fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
now := nostr.Now()
|
|
||||||
if n.filter.Until != 0 {
|
|
||||||
now = n.filter.Until
|
|
||||||
}
|
|
||||||
aMonthAgo := now - 30*24*60*60
|
|
||||||
out.Mtime = uint64(aMonthAgo)
|
|
||||||
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) Opendir(ctx context.Context) syscall.Errno {
|
|
||||||
if n.fetched.CompareAndSwap(true, true) {
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
if n.paginate {
|
|
||||||
now := nostr.Now()
|
|
||||||
if n.filter.Until != 0 {
|
|
||||||
now = n.filter.Until
|
|
||||||
}
|
|
||||||
aMonthAgo := now - 30*24*60*60
|
|
||||||
n.filter.Since = aMonthAgo
|
|
||||||
|
|
||||||
filter := n.filter
|
|
||||||
filter.Until = aMonthAgo
|
|
||||||
|
|
||||||
n.AddChild("@previous", n.NewPersistentInode(
|
|
||||||
n.root.ctx,
|
|
||||||
&ViewDir{
|
|
||||||
root: n.root,
|
|
||||||
filter: filter,
|
|
||||||
relays: n.relays,
|
|
||||||
replaceable: n.replaceable,
|
|
||||||
},
|
|
||||||
fs.StableAttr{Mode: syscall.S_IFDIR},
|
|
||||||
), true)
|
|
||||||
}
|
|
||||||
|
|
||||||
if n.replaceable {
|
|
||||||
for rkey, evt := range n.root.sys.Pool.FetchManyReplaceable(n.root.ctx, n.relays, n.filter, nostr.SubscriptionOptions{
|
|
||||||
Label: "nakfs",
|
|
||||||
}).Range {
|
|
||||||
name := rkey.D
|
|
||||||
if name == "" {
|
|
||||||
name = "_"
|
|
||||||
}
|
|
||||||
if n.GetChild(name) == nil {
|
|
||||||
n.AddChild(name, n.root.CreateEntityDir(n, &evt), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for ie := range n.root.sys.Pool.FetchMany(n.root.ctx, n.relays, n.filter,
|
|
||||||
nostr.SubscriptionOptions{
|
|
||||||
Label: "nakfs",
|
|
||||||
}) {
|
|
||||||
if n.GetChild(ie.Event.ID.Hex()) == nil {
|
|
||||||
n.AddChild(ie.Event.ID.Hex(), n.root.CreateEventDir(n, &ie.Event), true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (n *ViewDir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
|
||||||
if !n.createable || n.root.signer == nil || n.root.rootPubKey != n.filter.Authors[0] {
|
|
||||||
return nil, syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
|
|
||||||
if n.replaceable {
|
|
||||||
// create a template event that can later be modified and published as new
|
|
||||||
return n.root.CreateEntityDir(n, &nostr.Event{
|
|
||||||
PubKey: n.root.rootPubKey,
|
|
||||||
CreatedAt: 0,
|
|
||||||
Kind: n.filter.Kinds[0],
|
|
||||||
Tags: nostr.Tags{
|
|
||||||
nostr.Tag{"d", name},
|
|
||||||
},
|
|
||||||
}), fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, syscall.ENOTSUP
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
package nostrfs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/hanwen/go-fuse/v2/fs"
|
|
||||||
"github.com/hanwen/go-fuse/v2/fuse"
|
|
||||||
)
|
|
||||||
|
|
||||||
type WriteableFile struct {
|
|
||||||
fs.Inode
|
|
||||||
root *NostrRoot
|
|
||||||
mu sync.Mutex
|
|
||||||
data []byte
|
|
||||||
attr fuse.Attr
|
|
||||||
onWrite func(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
_ = (fs.NodeOpener)((*WriteableFile)(nil))
|
|
||||||
_ = (fs.NodeReader)((*WriteableFile)(nil))
|
|
||||||
_ = (fs.NodeWriter)((*WriteableFile)(nil))
|
|
||||||
_ = (fs.NodeGetattrer)((*WriteableFile)(nil))
|
|
||||||
_ = (fs.NodeSetattrer)((*WriteableFile)(nil))
|
|
||||||
_ = (fs.NodeFlusher)((*WriteableFile)(nil))
|
|
||||||
)
|
|
||||||
|
|
||||||
func (r *NostrRoot) NewWriteableFile(data string, ctime, mtime uint64, onWrite func(string)) *WriteableFile {
|
|
||||||
return &WriteableFile{
|
|
||||||
root: r,
|
|
||||||
data: []byte(data),
|
|
||||||
attr: fuse.Attr{
|
|
||||||
Mode: 0666,
|
|
||||||
Ctime: ctime,
|
|
||||||
Mtime: mtime,
|
|
||||||
Size: uint64(len(data)),
|
|
||||||
},
|
|
||||||
onWrite: onWrite,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Open(ctx context.Context, flags uint32) (fh fs.FileHandle, fuseFlags uint32, errno syscall.Errno) {
|
|
||||||
return nil, fuse.FOPEN_KEEP_CACHE, fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
|
|
||||||
offset := int(off)
|
|
||||||
end := offset + len(data)
|
|
||||||
if len(f.data) < end {
|
|
||||||
newData := make([]byte, offset+len(data))
|
|
||||||
copy(newData, f.data)
|
|
||||||
f.data = newData
|
|
||||||
}
|
|
||||||
copy(f.data[offset:], data)
|
|
||||||
f.data = f.data[0:end]
|
|
||||||
|
|
||||||
f.onWrite(string(f.data))
|
|
||||||
return uint32(len(data)), fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
out.Attr = f.attr
|
|
||||||
out.Attr.Size = uint64(len(f.data))
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Setattr(_ context.Context, _ fs.FileHandle, in *fuse.SetAttrIn, _ *fuse.AttrOut) syscall.Errno {
|
|
||||||
f.attr.Mtime = in.Mtime
|
|
||||||
f.attr.Atime = in.Atime
|
|
||||||
f.attr.Ctime = in.Ctime
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Flush(ctx context.Context, fh fs.FileHandle) syscall.Errno {
|
|
||||||
return fs.OK
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *WriteableFile) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
end := int(off) + len(dest)
|
|
||||||
if end > len(f.data) {
|
|
||||||
end = len(f.data)
|
|
||||||
}
|
|
||||||
return fuse.ReadResultData(f.data[off:end]), fs.OK
|
|
||||||
}
|
|
||||||
30
req.go
30
req.go
@@ -92,6 +92,10 @@ example:
|
|||||||
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
Usage: "after connecting, for a nip42 \"AUTH\" message to be received, act on it and only then send the \"REQ\"",
|
||||||
Category: CATEGORY_SIGNER,
|
Category: CATEGORY_SIGNER,
|
||||||
},
|
},
|
||||||
|
&cli.BoolFlag{
|
||||||
|
Name: "spell",
|
||||||
|
Usage: "output a spell event (kind 777) instead of a filter",
|
||||||
|
},
|
||||||
)...,
|
)...,
|
||||||
),
|
),
|
||||||
ArgsUsage: "[relay...]",
|
ArgsUsage: "[relay...]",
|
||||||
@@ -111,7 +115,16 @@ example:
|
|||||||
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
return fmt.Errorf("incompatible flags --paginate and --outbox")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Bool("bare") && c.Bool("spell") {
|
||||||
|
return fmt.Errorf("incompatible flags --bare and --spell")
|
||||||
|
}
|
||||||
|
|
||||||
relayUrls := c.Args().Slice()
|
relayUrls := c.Args().Slice()
|
||||||
|
|
||||||
|
if len(relayUrls) > 0 && (c.Bool("bare") || c.Bool("spell")) {
|
||||||
|
return fmt.Errorf("relay URLs are incompatible with --bare or --spell")
|
||||||
|
}
|
||||||
|
|
||||||
if len(relayUrls) > 0 && !negentropy {
|
if len(relayUrls) > 0 && !negentropy {
|
||||||
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
|
// this is used both for the normal AUTH (after "auth-required:" is received) or forced pre-auth
|
||||||
// connect to all relays we expect to use in this call in parallel
|
// connect to all relays we expect to use in this call in parallel
|
||||||
@@ -225,15 +238,26 @@ example:
|
|||||||
performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req")
|
performReq(ctx, filter, relayUrls, c.Bool("stream"), c.Bool("outbox"), c.Uint("outbox-relays-per-pubkey"), c.Bool("paginate"), c.Duration("paginate-interval"), "nak-req")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// no relays given, will just print the filter
|
// no relays given, will just print the filter or spell
|
||||||
var result string
|
var result string
|
||||||
if c.Bool("bare") {
|
if c.Bool("spell") {
|
||||||
|
// output a spell event instead of a filter
|
||||||
|
kr, _, err := gatherKeyerFromArguments(ctx, c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
spellEvent := createSpellEvent(ctx, filter, kr)
|
||||||
|
j, _ := json.Marshal(spellEvent)
|
||||||
|
result = string(j)
|
||||||
|
} else if c.Bool("bare") {
|
||||||
|
// bare filter output
|
||||||
result = filter.String()
|
result = filter.String()
|
||||||
} else {
|
} else {
|
||||||
|
// normal filter
|
||||||
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
|
j, _ := json.Marshal(nostr.ReqEnvelope{SubscriptionID: "nak", Filters: []nostr.Filter{filter}})
|
||||||
result = string(j)
|
result = string(j)
|
||||||
}
|
|
||||||
|
|
||||||
|
}
|
||||||
stdout(result)
|
stdout(result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
76
spell.go
76
spell.go
@@ -120,7 +120,7 @@ var spell = &cli.Command{
|
|||||||
} else {
|
} else {
|
||||||
// search our history
|
// search our history
|
||||||
for _, entry := range history {
|
for _, entry := range history {
|
||||||
if entry.Identifier == identifier {
|
if entry.Identifier == identifier || entry.Name == identifier {
|
||||||
pointer = entry.Pointer
|
pointer = entry.Pointer
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -471,3 +471,77 @@ func logSpellDetails(spell nostr.Event) {
|
|||||||
desc,
|
desc,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createSpellEvent(ctx context.Context, filter nostr.Filter, kr nostr.Keyer) nostr.Event {
|
||||||
|
spell := nostr.Event{
|
||||||
|
Kind: 777,
|
||||||
|
Tags: make(nostr.Tags, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// add cmd tag
|
||||||
|
spell.Tags = append(spell.Tags, nostr.Tag{"cmd", "REQ"})
|
||||||
|
|
||||||
|
// add kinds
|
||||||
|
if len(filter.Kinds) > 0 {
|
||||||
|
kindTag := nostr.Tag{"k"}
|
||||||
|
for _, kind := range filter.Kinds {
|
||||||
|
kindTag = append(kindTag, strconv.Itoa(int(kind)))
|
||||||
|
}
|
||||||
|
spell.Tags = append(spell.Tags, kindTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add authors
|
||||||
|
if len(filter.Authors) > 0 {
|
||||||
|
authorsTag := nostr.Tag{"authors"}
|
||||||
|
for _, author := range filter.Authors {
|
||||||
|
authorsTag = append(authorsTag, author.Hex())
|
||||||
|
}
|
||||||
|
spell.Tags = append(spell.Tags, authorsTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add ids
|
||||||
|
if len(filter.IDs) > 0 {
|
||||||
|
idsTag := nostr.Tag{"ids"}
|
||||||
|
for _, id := range filter.IDs {
|
||||||
|
idsTag = append(idsTag, id.Hex())
|
||||||
|
}
|
||||||
|
spell.Tags = append(spell.Tags, idsTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add tags
|
||||||
|
for tagName, values := range filter.Tags {
|
||||||
|
if len(values) > 0 {
|
||||||
|
tag := nostr.Tag{"tag", tagName}
|
||||||
|
for _, value := range values {
|
||||||
|
tag = append(tag, value)
|
||||||
|
}
|
||||||
|
spell.Tags = append(spell.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// add limit
|
||||||
|
if filter.Limit > 0 {
|
||||||
|
spell.Tags = append(spell.Tags, nostr.Tag{"limit", strconv.Itoa(filter.Limit)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// add since
|
||||||
|
if filter.Since > 0 {
|
||||||
|
spell.Tags = append(spell.Tags, nostr.Tag{"since", strconv.FormatInt(int64(filter.Since), 10)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// add until
|
||||||
|
if filter.Until > 0 {
|
||||||
|
spell.Tags = append(spell.Tags, nostr.Tag{"until", strconv.FormatInt(int64(filter.Until), 10)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// add search
|
||||||
|
if filter.Search != "" {
|
||||||
|
spell.Tags = append(spell.Tags, nostr.Tag{"search", filter.Search})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := kr.SignEvent(ctx, &spell); err != nil {
|
||||||
|
log("failed to sign spell: %s\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spell
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user