first commit
This commit is contained in:
72
internal/nostr/keys.go
Normal file
72
internal/nostr/keys.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
gn "github.com/nbd-wtf/go-nostr"
|
||||
"github.com/nbd-wtf/go-nostr/nip19"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidPubkey = errors.New("invalid pubkey")
|
||||
ErrInvalidNsec = errors.New("invalid nsec")
|
||||
)
|
||||
|
||||
// NormalizePubkey accepts npub bech32 or 64-char hex and returns lowercase hex.
|
||||
func NormalizePubkey(in string) (string, error) {
|
||||
in = strings.TrimSpace(in)
|
||||
if in == "" {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
if strings.HasPrefix(in, "npub1") {
|
||||
prefix, data, err := nip19.Decode(in)
|
||||
if err != nil {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
if prefix != "npub" {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
s, ok := data.(string)
|
||||
if !ok {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
return strings.ToLower(s), nil
|
||||
}
|
||||
if len(in) != 64 {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
if _, err := hex.DecodeString(in); err != nil {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
if !gn.IsValidPublicKey(in) {
|
||||
return "", ErrInvalidPubkey
|
||||
}
|
||||
return strings.ToLower(in), nil
|
||||
}
|
||||
|
||||
// HexToNpub converts hex pubkey to npub bech32. Empty string on error.
|
||||
func HexToNpub(hexpk string) string {
|
||||
npub, err := nip19.EncodePublicKey(hexpk)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return npub
|
||||
}
|
||||
|
||||
// NsecToHex decodes nsec1... to hex private key.
|
||||
func NsecToHex(nsec string) (string, error) {
|
||||
prefix, data, err := nip19.Decode(strings.TrimSpace(nsec))
|
||||
if err != nil {
|
||||
return "", ErrInvalidNsec
|
||||
}
|
||||
if prefix != "nsec" {
|
||||
return "", ErrInvalidNsec
|
||||
}
|
||||
s, ok := data.(string)
|
||||
if !ok {
|
||||
return "", ErrInvalidNsec
|
||||
}
|
||||
return strings.ToLower(s), nil
|
||||
}
|
||||
41
internal/nostr/keys_test.go
Normal file
41
internal/nostr/keys_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePubkey_Hex(t *testing.T) {
|
||||
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
|
||||
got, err := NormalizePubkey(hex)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if got != strings.ToLower(hex) {
|
||||
t.Fatalf("got %q want %q", got, hex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePubkey_BadInput(t *testing.T) {
|
||||
cases := []string{"", "abc", "not-an-npub", "npub1invalid", strings.Repeat("z", 64)}
|
||||
for _, c := range cases {
|
||||
if _, err := NormalizePubkey(c); err == nil {
|
||||
t.Errorf("expected error for %q", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePubkey_NpubRoundtrip(t *testing.T) {
|
||||
hex := "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c"
|
||||
npub := HexToNpub(hex)
|
||||
if npub == "" || !strings.HasPrefix(npub, "npub1") {
|
||||
t.Fatalf("HexToNpub failed: %q", npub)
|
||||
}
|
||||
got, err := NormalizePubkey(npub)
|
||||
if err != nil {
|
||||
t.Fatalf("decode npub: %v", err)
|
||||
}
|
||||
if got != hex {
|
||||
t.Fatalf("roundtrip got %q want %q", got, hex)
|
||||
}
|
||||
}
|
||||
81
internal/nostr/profile.go
Normal file
81
internal/nostr/profile.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
gn "github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// Metadata mirrors the kind:0 profile JSON. We accept both snake_case
|
||||
// (`display_name`, NIP-24) and the deprecated camelCase `displayName` since
|
||||
// some clients still publish only the latter. `Username` is also deprecated
|
||||
// but appears in older profiles; per NIP-24 it should be ignored in favor of
|
||||
// `Name`, but we surface it as a last-resort fallback.
|
||||
type Metadata struct {
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
DisplayNameAlt string `json:"displayName"`
|
||||
Username string `json:"username"`
|
||||
NIP05 string `json:"nip05"`
|
||||
About string `json:"about"`
|
||||
Picture string `json:"picture"`
|
||||
}
|
||||
|
||||
// ParseMetadata decodes a kind:0 content payload.
|
||||
func ParseMetadata(content string) (*Metadata, error) {
|
||||
var md Metadata
|
||||
if err := json.Unmarshal([]byte(content), &md); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &md, nil
|
||||
}
|
||||
|
||||
// FetchMetadata pulls the most recent kind:0 event for a hex pubkey across the pool.
|
||||
// Each relay may return multiple replacement events; we keep the one with the
|
||||
// highest CreatedAt across every relay reached before timeout.
|
||||
func FetchMetadata(ctx context.Context, p *Pool, hexpk string) (*Metadata, error) {
|
||||
filter := gn.Filter{
|
||||
Kinds: []int{0},
|
||||
Authors: []string{hexpk},
|
||||
Limit: 100,
|
||||
}
|
||||
|
||||
var newest *gn.Event
|
||||
for _, url := range p.URLs() {
|
||||
r, err := p.Connect(ctx, url)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
subCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||
sub, err := r.Subscribe(subCtx, gn.Filters{filter})
|
||||
if err != nil {
|
||||
cancel()
|
||||
continue
|
||||
}
|
||||
loop:
|
||||
for {
|
||||
select {
|
||||
case ev, ok := <-sub.Events:
|
||||
if !ok {
|
||||
break loop
|
||||
}
|
||||
if newest == nil || ev.CreatedAt > newest.CreatedAt {
|
||||
newest = ev
|
||||
}
|
||||
case <-sub.EndOfStoredEvents:
|
||||
break loop
|
||||
case <-subCtx.Done():
|
||||
break loop
|
||||
}
|
||||
}
|
||||
sub.Unsub()
|
||||
cancel()
|
||||
}
|
||||
|
||||
if newest == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return ParseMetadata(newest.Content)
|
||||
}
|
||||
59
internal/nostr/profile_test.go
Normal file
59
internal/nostr/profile_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package nostr
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseMetadata(t *testing.T) {
|
||||
cases := []struct {
|
||||
desc, in string
|
||||
name, dn, dnAlt string
|
||||
username, nip05 string
|
||||
}{
|
||||
{
|
||||
desc: "snake_case display_name",
|
||||
in: `{"name":"alice","display_name":"Alice","nip05":"alice@azzamo.net"}`,
|
||||
name: "alice", dn: "Alice", nip05: "alice@azzamo.net",
|
||||
},
|
||||
{
|
||||
desc: "camelCase displayName preserved separately",
|
||||
in: `{"displayName":"Alice"}`,
|
||||
dnAlt: "Alice",
|
||||
},
|
||||
{
|
||||
desc: "deprecated username field exposed",
|
||||
in: `{"username":"legacy"}`,
|
||||
username: "legacy",
|
||||
},
|
||||
{
|
||||
desc: "all fields together",
|
||||
in: `{"name":"a","display_name":"A","displayName":"AA","username":"u","nip05":"a@x"}`,
|
||||
name: "a", dn: "A", dnAlt: "AA", username: "u", nip05: "a@x",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
md, err := ParseMetadata(tc.in)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: parse error: %v", tc.desc, err)
|
||||
}
|
||||
if md.Name != tc.name {
|
||||
t.Errorf("%s: Name=%q want %q", tc.desc, md.Name, tc.name)
|
||||
}
|
||||
if md.DisplayName != tc.dn {
|
||||
t.Errorf("%s: DisplayName=%q want %q", tc.desc, md.DisplayName, tc.dn)
|
||||
}
|
||||
if md.DisplayNameAlt != tc.dnAlt {
|
||||
t.Errorf("%s: DisplayNameAlt=%q want %q", tc.desc, md.DisplayNameAlt, tc.dnAlt)
|
||||
}
|
||||
if md.Username != tc.username {
|
||||
t.Errorf("%s: Username=%q want %q", tc.desc, md.Username, tc.username)
|
||||
}
|
||||
if md.NIP05 != tc.nip05 {
|
||||
t.Errorf("%s: NIP05=%q want %q", tc.desc, md.NIP05, tc.nip05)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMetadataInvalidJSON(t *testing.T) {
|
||||
if _, err := ParseMetadata("not json"); err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
}
|
||||
36
internal/nostr/publish.go
Normal file
36
internal/nostr/publish.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
gn "github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
var ErrNoRelayAccepted = errors.New("no relay accepted event")
|
||||
|
||||
// Publish sends an already-signed event to all relays in the pool.
|
||||
// Success if at least one relay accepts.
|
||||
func Publish(ctx context.Context, p *Pool, ev *gn.Event) error {
|
||||
var lastErr error
|
||||
accepted := 0
|
||||
for _, url := range p.URLs() {
|
||||
r, err := p.Connect(ctx, url)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if err := r.Publish(ctx, *ev); err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
accepted++
|
||||
}
|
||||
if accepted == 0 {
|
||||
if lastErr != nil {
|
||||
return lastErr
|
||||
}
|
||||
return ErrNoRelayAccepted
|
||||
}
|
||||
return nil
|
||||
}
|
||||
51
internal/nostr/relay.go
Normal file
51
internal/nostr/relay.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gn "github.com/nbd-wtf/go-nostr"
|
||||
)
|
||||
|
||||
// Pool is a small relay-connection pool.
|
||||
type Pool struct {
|
||||
mu sync.Mutex
|
||||
relays []string
|
||||
active map[string]*gn.Relay
|
||||
}
|
||||
|
||||
func NewPool(urls []string) *Pool {
|
||||
return &Pool{relays: urls, active: map[string]*gn.Relay{}}
|
||||
}
|
||||
|
||||
func (p *Pool) URLs() []string { return p.relays }
|
||||
|
||||
func (p *Pool) Connect(ctx context.Context, url string) (*gn.Relay, error) {
|
||||
p.mu.Lock()
|
||||
if r, ok := p.active[url]; ok && r.IsConnected() {
|
||||
p.mu.Unlock()
|
||||
return r, nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
r, err := gn.RelayConnect(dialCtx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.mu.Lock()
|
||||
p.active[url] = r
|
||||
p.mu.Unlock()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (p *Pool) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for _, r := range p.active {
|
||||
_ = r.Close()
|
||||
}
|
||||
p.active = map[string]*gn.Relay{}
|
||||
}
|
||||
Reference in New Issue
Block a user