nostr package, readme updates accordingly, matching example program (#14)
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"github.com/gorilla/websocket"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Connection struct {
|
||||
socket *websocket.Conn
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewConnection(socket *websocket.Conn) *Connection {
|
||||
return &Connection{
|
||||
socket: socket,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Connection) WriteJSON(v interface{}) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
return c.socket.WriteJSON(v)
|
||||
}
|
||||
|
||||
func (c *Connection) WriteMessage(messageType int, data []byte) error {
|
||||
c.mutex.Lock()
|
||||
defer c.mutex.Unlock()
|
||||
return c.socket.WriteMessage(messageType, data)
|
||||
}
|
||||
|
||||
func (c *Connection) Close() error {
|
||||
return c.socket.Close()
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
ID string
|
||||
PubKey string
|
||||
CreatedAt time.Time
|
||||
Kind int
|
||||
Tags Tags
|
||||
Content string
|
||||
Sig string
|
||||
}
|
||||
|
||||
const (
|
||||
KindSetMetadata int = 0
|
||||
KindTextNote int = 1
|
||||
KindRecommendServer int = 2
|
||||
KindContactList int = 3
|
||||
KindEncryptedDirectMessage int = 4
|
||||
KindDeletion int = 5
|
||||
)
|
||||
|
||||
// GetID serializes and returns the event ID as a string
|
||||
func (evt *Event) GetID() string {
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// Serialize outputs a byte array that can be hashed/signed to identify/authenticate
|
||||
func (evt *Event) Serialize() []byte {
|
||||
// the serialization process is just putting everything into a JSON array
|
||||
// so the order is kept
|
||||
var arena fastjson.Arena
|
||||
|
||||
arr := arena.NewArray()
|
||||
|
||||
// version: 0
|
||||
arr.SetArrayItem(0, arena.NewNumberInt(0))
|
||||
|
||||
// pubkey
|
||||
arr.SetArrayItem(1, arena.NewString(evt.PubKey))
|
||||
|
||||
// created_at
|
||||
arr.SetArrayItem(2, arena.NewNumberInt(int(evt.CreatedAt.Unix())))
|
||||
|
||||
// kind
|
||||
arr.SetArrayItem(3, arena.NewNumberInt(evt.Kind))
|
||||
|
||||
// tags
|
||||
arr.SetArrayItem(4, tagsToFastjsonArray(&arena, evt.Tags))
|
||||
|
||||
// content
|
||||
arr.SetArrayItem(5, arena.NewString(evt.Content))
|
||||
|
||||
return arr.MarshalTo(nil)
|
||||
}
|
||||
|
||||
// CheckSignature checks if the signature is valid for the id
|
||||
// (which is a hash of the serialized event content).
|
||||
// returns an error if the signature itself is invalid.
|
||||
func (evt Event) CheckSignature() (bool, error) {
|
||||
// read and check pubkey
|
||||
pk, err := hex.DecodeString(evt.PubKey)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("event pubkey '%s' is invalid hex: %w", evt.PubKey, err)
|
||||
}
|
||||
|
||||
pubkey, err := schnorr.ParsePubKey(pk)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("event has invalid pubkey '%s': %w", evt.PubKey, err)
|
||||
}
|
||||
|
||||
// read signature
|
||||
s, err := hex.DecodeString(evt.Sig)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("signature '%s' is invalid hex: %w", evt.Sig, err)
|
||||
}
|
||||
sig, err := schnorr.ParseSignature(s)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to parse signature: %w", err)
|
||||
}
|
||||
|
||||
// check signature
|
||||
hash := sha256.Sum256(evt.Serialize())
|
||||
return sig.Verify(hash[:], pubkey), nil
|
||||
}
|
||||
|
||||
// Sign signs an event with a given privateKey
|
||||
func (evt *Event) Sign(privateKey string) error {
|
||||
h := sha256.Sum256(evt.Serialize())
|
||||
|
||||
s, err := hex.DecodeString(privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Sign called with invalid private key '%s': %w", privateKey, err)
|
||||
}
|
||||
sk, _ := btcec.PrivKeyFromBytes(s)
|
||||
|
||||
sig, err := schnorr.Sign(sk, h[:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
evt.ID = hex.EncodeToString(h[:])
|
||||
evt.Sig = hex.EncodeToString(sig.Serialize())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
type Tags []StringList
|
||||
|
||||
func (t *Tags) Scan(src interface{}) error {
|
||||
var jtags []byte = make([]byte, 0)
|
||||
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
jtags = v
|
||||
case string:
|
||||
jtags = []byte(v)
|
||||
default:
|
||||
return errors.New("couldn't scan tags, it's not a json string")
|
||||
}
|
||||
|
||||
json.Unmarshal(jtags, &t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tags Tags) ContainsAny(tagName string, values StringList) bool {
|
||||
for _, tag := range tags {
|
||||
if len(tag) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
if tag[0] != tagName {
|
||||
continue
|
||||
}
|
||||
|
||||
if values.Contains(tag[1]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (evt *Event) UnmarshalJSON(payload []byte) error {
|
||||
var fastjsonParser fastjson.Parser
|
||||
parsed, err := fastjsonParser.ParseBytes(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse event: %w", err)
|
||||
}
|
||||
|
||||
obj, err := parsed.Object()
|
||||
if err != nil {
|
||||
return fmt.Errorf("event is not an object")
|
||||
}
|
||||
|
||||
var visiterr error
|
||||
obj.Visit(func(k []byte, v *fastjson.Value) {
|
||||
key := string(k)
|
||||
switch key {
|
||||
case "id":
|
||||
id, err := v.StringBytes()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'id' field: %w", err)
|
||||
}
|
||||
evt.ID = string(id)
|
||||
case "pubkey":
|
||||
id, err := v.StringBytes()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'pubkey' field: %w", err)
|
||||
}
|
||||
evt.PubKey = string(id)
|
||||
case "created_at":
|
||||
val, err := v.Int64()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'created_at' field: %w", err)
|
||||
}
|
||||
evt.CreatedAt = time.Unix(val, 0)
|
||||
case "kind":
|
||||
kind, err := v.Int64()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'kind' field: %w", err)
|
||||
}
|
||||
evt.Kind = int(kind)
|
||||
case "tags":
|
||||
evt.Tags, err = fastjsonArrayToTags(v)
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid '%s' field: %w", key, err)
|
||||
}
|
||||
case "content":
|
||||
id, err := v.StringBytes()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'content' field: %w", err)
|
||||
}
|
||||
evt.Content = string(id)
|
||||
case "sig":
|
||||
id, err := v.StringBytes()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'sig' field: %w", err)
|
||||
}
|
||||
evt.Sig = string(id)
|
||||
}
|
||||
})
|
||||
if visiterr != nil {
|
||||
return visiterr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (evt Event) MarshalJSON() ([]byte, error) {
|
||||
var arena fastjson.Arena
|
||||
|
||||
o := arena.NewObject()
|
||||
o.Set("id", arena.NewString(evt.ID))
|
||||
o.Set("pubkey", arena.NewString(evt.PubKey))
|
||||
o.Set("created_at", arena.NewNumberInt(int(evt.CreatedAt.Unix())))
|
||||
o.Set("kind", arena.NewNumberInt(evt.Kind))
|
||||
o.Set("tags", tagsToFastjsonArray(&arena, evt.Tags))
|
||||
o.Set("content", arena.NewString(evt.Content))
|
||||
o.Set("sig", arena.NewString(evt.Sig))
|
||||
|
||||
return o.MarshalTo(nil), nil
|
||||
}
|
||||
|
||||
func fastjsonArrayToTags(v *fastjson.Value) (Tags, error) {
|
||||
arr, err := v.Array()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sll := make([]StringList, len(arr))
|
||||
for i, v := range arr {
|
||||
subarr, err := v.Array()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sl := make(StringList, len(subarr))
|
||||
for j, subv := range subarr {
|
||||
sb, err := subv.StringBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sl[j] = string(sb)
|
||||
}
|
||||
sll[i] = sl
|
||||
}
|
||||
|
||||
return Tags(sll), nil
|
||||
}
|
||||
|
||||
func tagsToFastjsonArray(arena *fastjson.Arena, tags Tags) *fastjson.Value {
|
||||
jtags := arena.NewArray()
|
||||
for i, v := range tags {
|
||||
arr := arena.NewArray()
|
||||
for j, subv := range v {
|
||||
arr.SetArrayItem(j, arena.NewString(subv))
|
||||
}
|
||||
jtags.SetArrayItem(i, arr)
|
||||
}
|
||||
return jtags
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEventParsingAndVerifying(t *testing.T) {
|
||||
rawEvents := []string{
|
||||
`{"id":"dc90c95f09947507c1044e8f48bcf6350aa6bff1507dd4acfc755b9239b5c962","pubkey":"3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d","created_at":1644271588,"kind":1,"tags":[],"content":"now that https://blueskyweb.org/blog/2-7-2022-overview was announced we can stop working on nostr?","sig":"230e9d8f0ddaf7eb70b5f7741ccfa37e87a455c9a469282e3464e2052d3192cd63a167e196e381ef9d7e69e9ea43af2443b839974dc85d8aaab9efe1d9296524"}`,
|
||||
`{"id":"9e662bdd7d8abc40b5b15ee1ff5e9320efc87e9274d8d440c58e6eed2dddfbe2","pubkey":"373ebe3d45ec91977296a178d9f19f326c70631d2a1b0bbba5c5ecc2eb53b9e7","created_at":1644844224,"kind":3,"tags":[["p","3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"],["p","75fc5ac2487363293bd27fb0d14fb966477d0f1dbc6361d37806a6a740eda91e"],["p","46d0dfd3a724a302ca9175163bdf788f3606b3fd1bb12d5fe055d1e418cb60ea"]],"content":"{\"wss://nostr-pub.wellorder.net\":{\"read\":true,\"write\":true},\"wss://nostr.bitcoiner.social\":{\"read\":false,\"write\":true},\"wss://expensive-relay.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relayer.fiatjaf.com\":{\"read\":true,\"write\":true},\"wss://relay.bitid.nz\":{\"read\":true,\"write\":true},\"wss://nostr.rocks\":{\"read\":true,\"write\":true}}","sig":"811355d3484d375df47581cb5d66bed05002c2978894098304f20b595e571b7e01b2efd906c5650080ffe49cf1c62b36715698e9d88b9e8be43029a2f3fa66be"}`,
|
||||
}
|
||||
|
||||
for _, raw := range rawEvents {
|
||||
var ev Event
|
||||
err := json.Unmarshal([]byte(raw), &ev)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse event json: %w", err)
|
||||
}
|
||||
|
||||
if ev.GetID() != ev.ID {
|
||||
t.Errorf("error serializing event id: %s != %s", ev.GetID(), ev.ID)
|
||||
}
|
||||
|
||||
if ok, _ := ev.CheckSignature(); !ok {
|
||||
t.Error("signature verification failed when it should have succeeded")
|
||||
}
|
||||
|
||||
asjson, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
t.Errorf("failed to re marshal event as json: %w", err)
|
||||
}
|
||||
|
||||
if string(asjson) != raw {
|
||||
t.Log(string(asjson))
|
||||
t.Error("json serialization broken")
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Filters []Filter
|
||||
|
||||
type Filter struct {
|
||||
IDs StringList
|
||||
Kinds IntList
|
||||
Authors StringList
|
||||
Tags TagMap
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
}
|
||||
|
||||
type TagMap map[string]StringList
|
||||
|
||||
func (eff Filters) Match(event *Event) bool {
|
||||
for _, filter := range eff {
|
||||
if filter.Matches(event) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ef Filter) Matches(event *Event) bool {
|
||||
if event == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if ef.IDs != nil && !ef.IDs.ContainsPrefixOf(event.ID) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ef.Kinds != nil && !ef.Kinds.Contains(event.Kind) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ef.Authors != nil && !ef.Authors.ContainsPrefixOf(event.PubKey) {
|
||||
return false
|
||||
}
|
||||
|
||||
for f, v := range ef.Tags {
|
||||
if v != nil && !event.Tags.ContainsAny(f, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if ef.Since != nil && time.Time(event.CreatedAt).Before(*ef.Since) {
|
||||
return false
|
||||
}
|
||||
|
||||
if ef.Until != nil && time.Time(event.CreatedAt).After(*ef.Until) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func FilterEqual(a Filter, b Filter) bool {
|
||||
if !a.Kinds.Equals(b.Kinds) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !a.IDs.Equals(b.IDs) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !a.Authors.Equals(b.Authors) {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(a.Tags) != len(b.Tags) {
|
||||
return false
|
||||
}
|
||||
|
||||
for f, av := range a.Tags {
|
||||
if bv, ok := b.Tags[f]; !ok {
|
||||
return false
|
||||
} else {
|
||||
if !av.Equals(bv) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if a.Since != b.Since {
|
||||
return false
|
||||
}
|
||||
|
||||
if a.Until != b.Until {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/valyala/fastjson"
|
||||
)
|
||||
|
||||
func (f *Filter) UnmarshalJSON(payload []byte) error {
|
||||
var fastjsonParser fastjson.Parser
|
||||
parsed, err := fastjsonParser.ParseBytes(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse filter: %w", err)
|
||||
}
|
||||
|
||||
obj, err := parsed.Object()
|
||||
if err != nil {
|
||||
return fmt.Errorf("filter is not an object")
|
||||
}
|
||||
|
||||
f.Tags = make(TagMap)
|
||||
|
||||
var visiterr error
|
||||
obj.Visit(func(k []byte, v *fastjson.Value) {
|
||||
key := string(k)
|
||||
switch key {
|
||||
case "ids":
|
||||
f.IDs, err = fastjsonArrayToStringList(v)
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'ids' field: %w", err)
|
||||
}
|
||||
case "kinds":
|
||||
f.Kinds, err = fastjsonArrayToIntList(v)
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'kinds' field: %w", err)
|
||||
}
|
||||
case "authors":
|
||||
f.Authors, err = fastjsonArrayToStringList(v)
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'authors' field: %w", err)
|
||||
}
|
||||
case "since":
|
||||
val, err := v.Int64()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'since' field: %w", err)
|
||||
}
|
||||
tm := time.Unix(val, 0)
|
||||
f.Since = &tm
|
||||
case "until":
|
||||
val, err := v.Int64()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'until' field: %w", err)
|
||||
}
|
||||
tm := time.Unix(val, 0)
|
||||
f.Until = &tm
|
||||
case "limit":
|
||||
val, err := v.Int()
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid 'limit' field: %w", err)
|
||||
}
|
||||
f.Limit = val
|
||||
default:
|
||||
if strings.HasPrefix(key, "#") {
|
||||
f.Tags[key[1:]], err = fastjsonArrayToStringList(v)
|
||||
if err != nil {
|
||||
visiterr = fmt.Errorf("invalid '%s' field: %w", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if visiterr != nil {
|
||||
return visiterr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Filter) MarshalJSON() ([]byte, error) {
|
||||
var arena fastjson.Arena
|
||||
|
||||
o := arena.NewObject()
|
||||
|
||||
if f.IDs != nil {
|
||||
o.Set("ids", stringListToFastjsonArray(&arena, f.IDs))
|
||||
}
|
||||
if f.Kinds != nil {
|
||||
o.Set("kinds", intListToFastjsonArray(&arena, f.Kinds))
|
||||
}
|
||||
if f.Authors != nil {
|
||||
o.Set("authors", stringListToFastjsonArray(&arena, f.Authors))
|
||||
}
|
||||
if f.Since != nil {
|
||||
o.Set("since", arena.NewNumberInt(int(f.Since.Unix())))
|
||||
}
|
||||
if f.Until != nil {
|
||||
o.Set("until", arena.NewNumberInt(int(f.Until.Unix())))
|
||||
}
|
||||
if f.Tags != nil {
|
||||
for k, v := range f.Tags {
|
||||
o.Set("#"+k, stringListToFastjsonArray(&arena, v))
|
||||
}
|
||||
}
|
||||
if f.Limit != 0 {
|
||||
o.Set("limit", arena.NewNumberInt(f.Limit))
|
||||
}
|
||||
|
||||
return o.MarshalTo(nil), nil
|
||||
}
|
||||
|
||||
func stringListToFastjsonArray(arena *fastjson.Arena, sl StringList) *fastjson.Value {
|
||||
arr := arena.NewArray()
|
||||
for i, v := range sl {
|
||||
arr.SetArrayItem(i, arena.NewString(v))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func intListToFastjsonArray(arena *fastjson.Arena, il IntList) *fastjson.Value {
|
||||
arr := arena.NewArray()
|
||||
for i, v := range il {
|
||||
arr.SetArrayItem(i, arena.NewNumberInt(v))
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
func fastjsonArrayToStringList(v *fastjson.Value) (StringList, error) {
|
||||
arr, err := v.Array()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sl := make(StringList, len(arr))
|
||||
for i, v := range arr {
|
||||
sb, err := v.StringBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sl[i] = string(sb)
|
||||
}
|
||||
|
||||
return sl, nil
|
||||
}
|
||||
|
||||
func fastjsonArrayToIntList(v *fastjson.Value) (IntList, error) {
|
||||
arr, err := v.Array()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
il := make(IntList, len(arr))
|
||||
for i, v := range arr {
|
||||
il[i], err = v.Int()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return il, nil
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFilterUnmarshal(t *testing.T) {
|
||||
raw := `{"ids": ["abc"],"#e":["zzz"],"#something":["nothing","bab"],"since":1644254609}`
|
||||
var f Filter
|
||||
err := json.Unmarshal([]byte(raw), &f)
|
||||
if err != nil {
|
||||
t.Errorf("failed to parse filter json: %w", err)
|
||||
}
|
||||
|
||||
if f.Since == nil || f.Since.Format("2006-01-02") != "2022-02-07" ||
|
||||
f.Until != nil ||
|
||||
f.Tags == nil || len(f.Tags) != 2 || !f.Tags["something"].Contains("bab") {
|
||||
t.Error("failed to parse filter correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMarshal(t *testing.T) {
|
||||
tm := time.Unix(12345678, 0)
|
||||
|
||||
filterj, err := json.Marshal(Filter{
|
||||
Kinds: IntList{1, 2, 4},
|
||||
Tags: TagMap{"fruit": {"banana", "mango"}},
|
||||
Until: &tm,
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("failed to marshal filter json: %w", err)
|
||||
}
|
||||
|
||||
expected := `{"kinds":[1,2,4],"until":12345678,"#fruit":["banana","mango"]}`
|
||||
if string(filterj) != expected {
|
||||
t.Errorf("filter json was wrong: %s != %s", string(filterj), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterMatching(t *testing.T) {
|
||||
if (Filter{Kinds: IntList{4, 5}}).Matches(&Event{Kind: 6}) {
|
||||
t.Error("matched event that shouldn't have matched")
|
||||
}
|
||||
|
||||
if !(Filter{Kinds: IntList{4, 5}}).Matches(&Event{Kind: 4}) {
|
||||
t.Error("failed to match event by kind")
|
||||
}
|
||||
|
||||
if !(Filter{
|
||||
Kinds: IntList{4, 5},
|
||||
Tags: TagMap{
|
||||
"p": {"ooo"},
|
||||
},
|
||||
IDs: StringList{"prefix"},
|
||||
}).Matches(&Event{
|
||||
Kind: 4,
|
||||
Tags: Tags{{"p", "ooo", ",x,x,"}, {"m", "yywyw", "xxx"}},
|
||||
ID: "prefix123",
|
||||
}) {
|
||||
t.Error("failed to match event by kind+tags+id prefix")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterEquality(t *testing.T) {
|
||||
if !FilterEqual(
|
||||
Filter{Kinds: IntList{4, 5}},
|
||||
Filter{Kinds: IntList{4, 5}},
|
||||
) {
|
||||
t.Error("kinds filters should be equal")
|
||||
}
|
||||
|
||||
if !FilterEqual(
|
||||
Filter{Kinds: IntList{4, 5}, Tags: TagMap{"letter": {"a", "b"}}},
|
||||
Filter{Kinds: IntList{4, 5}, Tags: TagMap{"letter": {"b", "a"}}},
|
||||
) {
|
||||
t.Error("kind+tags filters should be equal")
|
||||
}
|
||||
|
||||
tm := time.Now()
|
||||
if !FilterEqual(
|
||||
Filter{
|
||||
Kinds: IntList{4, 5},
|
||||
Tags: TagMap{"letter": {"a", "b"}, "fruit": {"banana"}},
|
||||
Since: &tm,
|
||||
IDs: StringList{"aaaa", "bbbb"},
|
||||
},
|
||||
Filter{
|
||||
Kinds: IntList{5, 4},
|
||||
Tags: TagMap{"letter": {"a", "b"}, "fruit": {"banana"}},
|
||||
Since: &tm,
|
||||
IDs: StringList{"aaaa", "bbbb"},
|
||||
},
|
||||
) {
|
||||
t.Error("kind+2tags+since+ids filters should be equal")
|
||||
}
|
||||
|
||||
if FilterEqual(
|
||||
Filter{Kinds: IntList{1, 4, 5}},
|
||||
Filter{Kinds: IntList{4, 5, 6}},
|
||||
) {
|
||||
t.Error("kinds filters shouldn't be equal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StringList []string
|
||||
type IntList []int
|
||||
|
||||
func (as StringList) Equals(bs StringList) bool {
|
||||
if len(as) != len(bs) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, a := range as {
|
||||
for _, b := range bs {
|
||||
if b == a {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
// didn't find a B that corresponded to the current A
|
||||
return false
|
||||
|
||||
next:
|
||||
continue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (as IntList) Equals(bs IntList) bool {
|
||||
if len(as) != len(bs) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, a := range as {
|
||||
for _, b := range bs {
|
||||
if b == a {
|
||||
goto next
|
||||
}
|
||||
}
|
||||
// didn't find a B that corresponded to the current A
|
||||
return false
|
||||
|
||||
next:
|
||||
continue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (haystack StringList) Contains(needle string) bool {
|
||||
for _, hay := range haystack {
|
||||
if hay == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (haystack StringList) ContainsPrefixOf(needle string) bool {
|
||||
for _, hay := range haystack {
|
||||
if strings.HasPrefix(needle, hay) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (haystack IntList) Contains(needle int) bool {
|
||||
for _, hay := range haystack {
|
||||
if hay == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2"
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr"
|
||||
)
|
||||
|
||||
func GeneratePrivateKey() string {
|
||||
params := btcec.S256().Params()
|
||||
one := new(big.Int).SetInt64(1)
|
||||
|
||||
b := make([]byte, params.BitSize/8+8)
|
||||
_, err := io.ReadFull(rand.Reader, b)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
k := new(big.Int).SetBytes(b)
|
||||
n := new(big.Int).Sub(params.N, one)
|
||||
k.Mod(k, n)
|
||||
k.Add(k, one)
|
||||
|
||||
return hex.EncodeToString(k.Bytes())
|
||||
}
|
||||
|
||||
func GetPublicKey(sk string) (string, error) {
|
||||
b, err := hex.DecodeString(sk)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
_, pk := btcec.PrivKeyFromBytes(b)
|
||||
return hex.EncodeToString(schnorr.SerializePubKey(pk)), nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeURL(u string) string {
|
||||
if !strings.HasPrefix(u, "http") && !strings.HasPrefix(u, "ws") {
|
||||
u = "wss://" + u
|
||||
}
|
||||
p, err := url.Parse(u)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
if p.Scheme == "http" {
|
||||
p.Scheme = "ws"
|
||||
} else if p.Scheme == "https" {
|
||||
p.Scheme = "wss"
|
||||
}
|
||||
|
||||
if strings.HasSuffix(p.RawPath, "/") {
|
||||
p.RawPath = p.RawPath[0 : len(p.RawPath)-1]
|
||||
}
|
||||
|
||||
return p.String()
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
s "github.com/SaveTheRbtz/generic-sync-map-go"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
PublishStatusSent Status = 0
|
||||
PublishStatusFailed Status = -1
|
||||
PublishStatusSucceeded Status = 1
|
||||
)
|
||||
|
||||
func (s Status) String() string {
|
||||
switch s {
|
||||
case PublishStatusSent:
|
||||
return "sent"
|
||||
case PublishStatusFailed:
|
||||
return "failed"
|
||||
case PublishStatusSucceeded:
|
||||
return "success"
|
||||
}
|
||||
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
type PublishStatus struct {
|
||||
Relay string
|
||||
Status Status
|
||||
}
|
||||
|
||||
type RelayPool struct {
|
||||
SecretKey *string
|
||||
|
||||
Relays s.MapOf[string, RelayPoolPolicy]
|
||||
websockets s.MapOf[string, *Connection]
|
||||
subscriptions s.MapOf[string, *Subscription]
|
||||
|
||||
Notices chan *NoticeMessage
|
||||
}
|
||||
|
||||
type RelayPoolPolicy interface {
|
||||
ShouldRead(Filters) bool
|
||||
ShouldWrite(*Event) bool
|
||||
}
|
||||
|
||||
type SimplePolicy struct {
|
||||
Read bool
|
||||
Write bool
|
||||
}
|
||||
|
||||
func (s SimplePolicy) ShouldRead(_ Filters) bool {
|
||||
return s.Read
|
||||
}
|
||||
|
||||
func (s SimplePolicy) ShouldWrite(_ *Event) bool {
|
||||
return s.Write
|
||||
}
|
||||
|
||||
type NoticeMessage struct {
|
||||
Message string
|
||||
Relay string
|
||||
}
|
||||
|
||||
// New creates a new RelayPool with no relays in it
|
||||
func NewRelayPool() *RelayPool {
|
||||
return &RelayPool{
|
||||
Relays: s.MapOf[string, RelayPoolPolicy]{},
|
||||
websockets: s.MapOf[string, *Connection]{},
|
||||
subscriptions: s.MapOf[string, *Subscription]{},
|
||||
|
||||
Notices: make(chan *NoticeMessage),
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a new relay to the pool, if policy is nil, it will be a simple
|
||||
// read+write policy.
|
||||
func (r *RelayPool) Add(url string, policy RelayPoolPolicy) error {
|
||||
if policy == nil {
|
||||
policy = SimplePolicy{Read: true, Write: true}
|
||||
}
|
||||
|
||||
nm := NormalizeURL(url)
|
||||
if nm == "" {
|
||||
return fmt.Errorf("invalid relay URL '%s'", url)
|
||||
}
|
||||
|
||||
socket, _, err := websocket.DefaultDialer.Dial(NormalizeURL(url), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening websocket to '%s': %w", nm, err)
|
||||
}
|
||||
|
||||
conn := NewConnection(socket)
|
||||
|
||||
r.Relays.Store(nm, policy)
|
||||
r.websockets.Store(nm, conn)
|
||||
|
||||
r.subscriptions.Range(func(_ string, sub *Subscription) bool {
|
||||
sub.addRelay(nm, conn)
|
||||
return true
|
||||
})
|
||||
|
||||
go func() {
|
||||
for {
|
||||
typ, message, err := conn.socket.ReadMessage()
|
||||
if err != nil {
|
||||
log.Println("read error: ", err)
|
||||
return
|
||||
}
|
||||
if typ == websocket.PingMessage {
|
||||
conn.WriteMessage(websocket.PongMessage, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
if typ != websocket.TextMessage || len(message) == 0 || message[0] != '[' {
|
||||
continue
|
||||
}
|
||||
|
||||
var jsonMessage []json.RawMessage
|
||||
err = json.Unmarshal(message, &jsonMessage)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(jsonMessage) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
var label string
|
||||
json.Unmarshal(jsonMessage[0], &label)
|
||||
|
||||
switch label {
|
||||
case "NOTICE":
|
||||
var content string
|
||||
json.Unmarshal(jsonMessage[1], &content)
|
||||
r.Notices <- &NoticeMessage{
|
||||
Relay: nm,
|
||||
Message: content,
|
||||
}
|
||||
case "EVENT":
|
||||
if len(jsonMessage) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
var channel string
|
||||
json.Unmarshal(jsonMessage[1], &channel)
|
||||
if subscription, ok := r.subscriptions.Load(channel); ok {
|
||||
var event Event
|
||||
json.Unmarshal(jsonMessage[2], &event)
|
||||
|
||||
// check signature of all received events, ignore invalid
|
||||
ok, err := event.CheckSignature()
|
||||
if !ok {
|
||||
errmsg := ""
|
||||
if err != nil {
|
||||
errmsg = err.Error()
|
||||
}
|
||||
log.Printf("bad signature: %s", errmsg)
|
||||
continue
|
||||
}
|
||||
|
||||
// check if the event matches the desired filter, ignore otherwise
|
||||
if !subscription.filters.Match(&event) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !subscription.stopped {
|
||||
subscription.Events <- EventMessage{
|
||||
Relay: nm,
|
||||
Event: event,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a relay from the pool.
|
||||
func (r *RelayPool) Remove(url string) {
|
||||
nm := NormalizeURL(url)
|
||||
|
||||
r.subscriptions.Range(func(_ string, sub *Subscription) bool {
|
||||
sub.removeRelay(nm)
|
||||
return true
|
||||
})
|
||||
|
||||
if conn, ok := r.websockets.Load(nm); ok {
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
r.Relays.Delete(nm)
|
||||
r.websockets.Delete(nm)
|
||||
}
|
||||
|
||||
func (r *RelayPool) Sub(filters Filters) *Subscription {
|
||||
random := make([]byte, 7)
|
||||
rand.Read(random)
|
||||
|
||||
subscription := Subscription{}
|
||||
subscription.channel = hex.EncodeToString(random)
|
||||
subscription.relays = s.MapOf[string, *Connection]{}
|
||||
|
||||
r.Relays.Range(func(relay string, policy RelayPoolPolicy) bool {
|
||||
if policy.ShouldRead(filters) {
|
||||
if ws, ok := r.websockets.Load(relay); ok {
|
||||
subscription.relays.Store(relay, ws)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
subscription.Events = make(chan EventMessage)
|
||||
subscription.UniqueEvents = make(chan Event)
|
||||
r.subscriptions.Store(subscription.channel, &subscription)
|
||||
|
||||
subscription.Sub(filters)
|
||||
return &subscription
|
||||
}
|
||||
|
||||
func (r *RelayPool) PublishEvent(evt *Event) (*Event, chan PublishStatus, error) {
|
||||
status := make(chan PublishStatus, 1)
|
||||
|
||||
if r.SecretKey == nil && (evt.PubKey == "" || evt.Sig == "") {
|
||||
return nil, status, errors.New("PublishEvent needs either a signed event to publish or to have been configured with a .SecretKey.")
|
||||
}
|
||||
|
||||
if evt.PubKey == "" {
|
||||
sk, err := GetPublicKey(*r.SecretKey)
|
||||
if err != nil {
|
||||
return nil, status, fmt.Errorf("The pool's global SecretKey is invalid: %w", err)
|
||||
}
|
||||
evt.PubKey = sk
|
||||
}
|
||||
|
||||
if evt.Sig == "" {
|
||||
err := evt.Sign(*r.SecretKey)
|
||||
if err != nil {
|
||||
return nil, status, fmt.Errorf("Error signing event: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
r.websockets.Range(func(relay string, conn *Connection) bool {
|
||||
if r, ok := r.Relays.Load(relay); !ok || !r.ShouldWrite(evt) {
|
||||
return true
|
||||
}
|
||||
|
||||
go func(relay string, conn *Connection) {
|
||||
err := conn.WriteJSON([]interface{}{"EVENT", evt})
|
||||
if err != nil {
|
||||
log.Printf("error sending event to '%s': %s", relay, err.Error())
|
||||
status <- PublishStatus{relay, PublishStatusFailed}
|
||||
}
|
||||
status <- PublishStatus{relay, PublishStatusSent}
|
||||
|
||||
subscription := r.Sub(Filters{Filter{IDs: []string{evt.ID}}})
|
||||
for {
|
||||
select {
|
||||
case event := <-subscription.UniqueEvents:
|
||||
if event.ID == evt.ID {
|
||||
status <- PublishStatus{relay, PublishStatusSucceeded}
|
||||
break
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
break
|
||||
}
|
||||
break
|
||||
}
|
||||
}(relay, conn)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return evt, status, nil
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package nostr
|
||||
|
||||
import (
|
||||
s "github.com/SaveTheRbtz/generic-sync-map-go"
|
||||
)
|
||||
|
||||
type Subscription struct {
|
||||
channel string
|
||||
relays s.MapOf[string, *Connection]
|
||||
|
||||
filters Filters
|
||||
Events chan EventMessage
|
||||
|
||||
started bool
|
||||
UniqueEvents chan Event
|
||||
|
||||
stopped bool
|
||||
}
|
||||
|
||||
type EventMessage struct {
|
||||
Event Event
|
||||
Relay string
|
||||
}
|
||||
|
||||
func (subscription Subscription) Unsub() {
|
||||
subscription.relays.Range(func(_ string, conn *Connection) bool {
|
||||
conn.WriteJSON([]interface{}{
|
||||
"CLOSE",
|
||||
subscription.channel,
|
||||
})
|
||||
return true
|
||||
})
|
||||
|
||||
subscription.stopped = true
|
||||
if subscription.Events != nil {
|
||||
close(subscription.Events)
|
||||
}
|
||||
if subscription.UniqueEvents != nil {
|
||||
close(subscription.UniqueEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func (subscription *Subscription) Sub(filters Filters) {
|
||||
subscription.filters = filters
|
||||
|
||||
subscription.relays.Range(func(_ string, conn *Connection) bool {
|
||||
message := []interface{}{
|
||||
"REQ",
|
||||
subscription.channel,
|
||||
}
|
||||
for _, filter := range subscription.filters {
|
||||
message = append(message, filter)
|
||||
}
|
||||
|
||||
conn.WriteJSON(message)
|
||||
return true
|
||||
})
|
||||
|
||||
if !subscription.started {
|
||||
go subscription.startHandlingUnique()
|
||||
}
|
||||
}
|
||||
|
||||
func (subscription Subscription) startHandlingUnique() {
|
||||
seen := make(map[string]struct{})
|
||||
for em := range subscription.Events {
|
||||
if _, ok := seen[em.Event.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[em.Event.ID] = struct{}{}
|
||||
if !subscription.stopped {
|
||||
subscription.UniqueEvents <- em.Event
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (subscription Subscription) removeRelay(relay string) {
|
||||
if conn, ok := subscription.relays.Load(relay); ok {
|
||||
subscription.relays.Delete(relay)
|
||||
conn.WriteJSON([]interface{}{
|
||||
"CLOSE",
|
||||
subscription.channel,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (subscription Subscription) addRelay(relay string, conn *Connection) {
|
||||
subscription.relays.Store(relay, conn)
|
||||
|
||||
message := []interface{}{
|
||||
"REQ",
|
||||
subscription.channel,
|
||||
}
|
||||
for _, filter := range subscription.filters {
|
||||
message = append(message, filter)
|
||||
}
|
||||
|
||||
conn.WriteJSON(message)
|
||||
}
|
||||
Reference in New Issue
Block a user