From 529c031c41c76b307bfbc3725a88544479b51dc1 Mon Sep 17 00:00:00 2001 From: Iris Lightshard Date: Sun, 19 May 2024 14:42:28 -0600 Subject: [PATCH] bootstrap backend nostr adapter --- adapter/adapter.go | 127 ++++++++++++++++++++++--------- dist/index.html | 2 +- go.mod | 25 ++++++ go.sum | 43 +++++++++++ models/msg.go | 23 ++++-- models/settings.go | 14 ++-- server/server.go | 184 +++++++++++++++++++++++++-------------------- ts/index.ts | 5 +- underbbs.go | 9 ++- 9 files changed, 294 insertions(+), 138 deletions(-) create mode 100644 go.sum diff --git a/adapter/adapter.go b/adapter/adapter.go index e77caf1..0d1c832 100644 --- a/adapter/adapter.go +++ b/adapter/adapter.go @@ -1,15 +1,18 @@ package adapter import ( + "context" + "encoding/json" + "errors" + "fmt" . "forge.lightcrystal.systems/lightcrystal/underbbs/models" nostr "github.com/nbd-wtf/go-nostr" "strings" - "context" ) type Adapter interface { - Init(Settings, chan Message) error - Subscribe(string) error + Init(Settings, chan SocketData) error + Subscribe(string) []error SendMessage(Message) error Follow(Author) error Unfollow(Author) error @@ -18,46 +21,98 @@ type Adapter interface { } type NostrAdapter struct { - msgChan chan Message - nickname string - privkey string - relays []*nostr.Relay + msgChan chan SocketData + nickname string + privkey string + relays []*nostr.Relay } -func (self *NostrAdapter) Init(settings Settings, msgChan chan Message) error { - self.nickname = settings.Nickname - self.privkey = *settings.PrivKey - self.msgChan = msgChan - - ctx := context.Background() - - relays := strings.Split(*settings.Relays, ",") - - for _, r := range relays { - pr := nostr.RelayConnect(ctx, strings.Trim(r) - if pr == nil { - return errors.New("Relay connection could not be completed") - } - self.relays = append(self.relays, pr) - } - return nil +func (self *NostrAdapter) Init(settings Settings, msgChan chan SocketData) error { + self.nickname = settings.Nickname + self.privkey = *settings.PrivKey + self.msgChan = msgChan + + ctx := context.Background() + + relays := strings.Split(*settings.Relays, ",") + + for _, r := range relays { + pr, _ := nostr.RelayConnect(ctx, strings.Trim(r, " ")) + if pr == nil { + return errors.New("Relay connection could not be completed") + } + self.relays = append(self.relays, pr) + } + return nil } -func (self *NostrAdapter) Subscribe(filter string) error { - return nil +func (self *NostrAdapter) Subscribe(filter string) []error { + var filters nostr.Filters + err := json.Unmarshal([]byte(filter), &filters) + if err != nil { + return []error{err} + } + + errs := make([]error, 1) + + for _, r := range self.relays { + + sub, err := r.Subscribe(context.Background(), filters) + if err != nil { + errs = append(errs, err) + } else { + go func() { + for ev := range sub.Events { + // try sequentially to encode into an underbbs object + // and send it to the appropriate channel + m, err := nostrEventToMsg(ev) + if err == nil { + self.msgChan <- m + } + + } + }() + } + } + + if len(errs) > 0 { + return errs + } + return nil } -func (self *NostrAdapter) SendMessage(msg Message) error { - return nil +func (self *NostrAdapter) SendMessage(msg Message) error { + return nil } -func (self *NostrAdapter) Follow(author Author) error { - return nil +func (self *NostrAdapter) Follow(author Author) error { + return nil } -func (self *NostrAdapter) Unfollow(author Author) error { - return nil +func (self *NostrAdapter) Unfollow(author Author) error { + return nil } -func (self *NostrAdapter) GetFollowers() error { - return nil +func (self *NostrAdapter) GetFollowers() error { + return nil } func (self *NostrAdapter) UpdateMetadata(data interface{}) error { - return nil -} \ No newline at end of file + return nil +} + +func nostrEventToMsg(evt *nostr.Event) (Message, error) { + m := Message{ + Protocol: "nostr", + } + if evt == nil { + return m, errors.New("no event") + } + switch evt.Kind { + case nostr.KindTextNote: + m.Uri = evt.ID + m.Author = Author{ + Id: evt.PubKey, + } + m.Created = evt.CreatedAt.Time() + m.Content = evt.Content + return m, nil + default: + return m, errors.New(fmt.Sprintf("unsupported event kind: %d", evt.Kind)) + } +} diff --git a/dist/index.html b/dist/index.html index 60057ab..e61f3ba 100644 --- a/dist/index.html +++ b/dist/index.html @@ -22,7 +22,7 @@
-
diff --git a/go.mod b/go.mod index 41dbfeb..e278905 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ module forge.lightcrystal.systems/lightcrystal/underbbs go 1.22.0 + +require ( + github.com/nbd-wtf/go-nostr v0.31.2 + golang.org/x/time v0.5.0 + hacklab.nilfm.cc/quartzgun v0.3.2 + nhooyr.io/websocket v1.8.11 +) + +require ( + github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 // indirect + github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.2.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/puzpuzpuz/xsync/v3 v3.0.2 // indirect + github.com/tidwall/gjson v1.14.4 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect + golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 // indirect + golang.org/x/sys v0.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9ec49f8 --- /dev/null +++ b/go.sum @@ -0,0 +1,43 @@ +github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= +github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.2.0 h1:u0p9s3xLYpZCA1z5JgCkMeB34CKCMMQbM+G8Ii7YD0I= +github.com/gobwas/ws v1.2.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/nbd-wtf/go-nostr v0.31.2 h1:PkHCAsSzG0Ce8tfF7LKyvZOjYtCdC+hPh5KfO/Rl1b4= +github.com/nbd-wtf/go-nostr v0.31.2/go.mod h1:vHKtHyLXDXzYBN0fi/9Y/Q5AD0p+hk8TQVKlldAi0gI= +github.com/puzpuzpuz/xsync/v3 v3.0.2 h1:3yESHrRFYr6xzkz61LLkvNiPFXxJEAABanTQpKbAaew= +github.com/puzpuzpuz/xsync/v3 v3.0.2/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM= +github.com/tidwall/gjson v1.14.4/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53 h1:5llv2sWeaMSnA3w2kS57ouQQ4pudlXrR0dCgw51QK9o= +golang.org/x/exp v0.0.0-20230425010034-47ecfdc1ba53/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +hacklab.nilfm.cc/quartzgun v0.3.2 h1:PmRFZ/IgsXVWyNn1iOsQ/ZeMnOQIQy0PzFakhXBdZoU= +hacklab.nilfm.cc/quartzgun v0.3.2/go.mod h1:P6qK4HB0CD/xfyRq8wdEGevAPFDDmv0KCaESSvv93LU= +nhooyr.io/websocket v1.8.11 h1:f/qXNc2/3DpoSZkHt1DQu6rj4zGC8JmkkLkWss0MgN0= +nhooyr.io/websocket v1.8.11/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= diff --git a/models/msg.go b/models/msg.go index 9bcb433..f122f5e 100644 --- a/models/msg.go +++ b/models/msg.go @@ -1,20 +1,17 @@ package models import ( + "encoding/json" "time" ) -type MessageFactory interface { - FromNostr(interface{}) Message - FromMasto(interface{}) Message -} - type Message struct { + Uri string Author Author Protocol string Content string Attachments []Attachment - ReplyTo Message + ReplyTo *Message Replies []Message Mentions []Author Created time.Time @@ -26,6 +23,8 @@ type Author struct { Name string ProfileData interface{} Messages []Message + ProfileUri string + ProfilePic string } type Attachment struct { @@ -33,3 +32,15 @@ type Attachment struct { Data []uint8 Desc string } + +type SocketData interface { + ToDatagram() []byte +} + +func (self Message) ToDatagram() []byte { + data, err := json.Marshal(self) + if err != nil { + panic(err.Error()) + } + return data +} diff --git a/models/settings.go b/models/settings.go index 69e51f3..d8640eb 100644 --- a/models/settings.go +++ b/models/settings.go @@ -1,10 +1,10 @@ package models type Settings struct { - Nickname string - Protocol string - PrivKey *string `json:"privkey",omitempty` - Relays *string `json:"relays",omitempty` - Server *string `json:"server",omitempty` - ApiKey *string `json:"apiKey",omitempty` -} \ No newline at end of file + Nickname string + Protocol string + PrivKey *string `json:"privkey",omitempty` + Relays *string `json:"relays",omitempty` + Server *string `json:"server",omitempty` + ApiKey *string `json:"apiKey",omitempty` +} diff --git a/server/server.go b/server/server.go index 4a564a6..2f2bd17 100644 --- a/server/server.go +++ b/server/server.go @@ -1,93 +1,132 @@ package server import ( - "forge.lightcrystal.systems/lightcrystal/underbbs/models" + "context" + "encoding/base64" + "encoding/json" + "errors" + "forge.lightcrystal.systems/lightcrystal/underbbs/adapter" + "forge.lightcrystal.systems/lightcrystal/underbbs/models" + "golang.org/x/time/rate" "hacklab.nilfm.cc/quartzgun/renderer" + "io/ioutil" + "log" "net/http" - "nhooyr.io/websocket" - "encoding/base64" - "encoding/json" + "nhooyr.io/websocket" + "sync" + "time" ) - type Subscriber struct { msgs chan []byte closeSlow func() } - type BBSServer struct { subscribeMessageBuffer int publishLimiter *rate.Limiter logf func(f string, v ...interface{}) serveMux http.ServeMux subscribersLock sync.Mutex - subscribers map[*Subscriber][]models.Adapter + subscribers map[*Subscriber][]adapter.Adapter } func New() *BBSServer { - srvr := &BBSServer{ - subscribeMessageBuffer: 16, - logf: log.Printf, - subscribers: make(map[*Subscriber][]model.Adapter), - } - - // frontend is here + srvr := &BBSServer{ + subscribeMessageBuffer: 16, + logf: log.Printf, + subscribers: make(map[*Subscriber][]adapter.Adapter), + } + + // frontend is here srvr.serveMux.Handle("/app/", http.StripPrefix("/app/", renderer.Subtree("./dist"))) - + // websocket srvr.serveMux.HandleFunc("/subscribe", srvr.subscribeHandler) srvr.serveMux.HandleFunc("/publish", srvr.publishHandler) - + return srvr } -func (self *GameTableServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (self *BBSServer) ServeHTTP(w http.ResponseWriter, r *http.Request) { self.serveMux.ServeHTTP(w, r) } -func (self *GameTableServer) subscribeHandler(w http.ResponseWriter, r *http.Request) { - +func (self *BBSServer) subscribeHandler(w http.ResponseWriter, r *http.Request) { + c, err := websocket.Accept(w, r, &websocket.AcceptOptions{ - Subprotocols: [], + Subprotocols: []string{}, }) if err != nil { self.logf("%v", err) return } - + // decode subprotocol data into settings objects data := c.Subprotocol() - + // base64 decode decoded, err := base64.StdEncoding.DecodeString(data) if err != nil { - c.Close(3000, err.Error()) + c.Close(3000, err.Error()) + return } - - settings := []models.Settings + + settings := []models.Settings{} // unmarshal the json into the settings array - err := json.Unmarshal([]byte(decoded), &settings) - if (err != nil) { - c.Close(3001, err.Erorr()) + err = json.Unmarshal([]byte(decoded), &settings) + if err != nil { + c.Close(3001, err.Error()) + return } + msgc := make(chan models.SocketData) + // attempt to initialize adapters - for i, s := range settings { - switch(s.Protocol) { - case "nostr": - break; - case "masto": - break; - default: - break; - } + adapters := make([]adapter.Adapter, 0, 4) + for _, s := range settings { + var a adapter.Adapter + switch s.Protocol { + case "nostr": + a = &adapter.NostrAdapter{} + break + case "masto": + break + default: + break + } + err := a.Init(s, msgc) + if err != nil { + c.Close(3002, err.Error()) + return + } + + adapters = append(adapters, a) } - // keep reference to the adapters? - + + // keep reference to the adapters so we can execute commands on them later + ctx := r.Context() + ctx = c.CloseRead(ctx) + + s := &Subscriber{ + msgs: make(chan []byte, self.subscribeMessageBuffer), + closeSlow: func() { + c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages") + }, + } + + self.addSubscriber(s, adapters) + + defer self.deleteSubscriber(s) + defer c.Close(websocket.StatusInternalError, "") - err = self.subscribe(r, c) + // collect data from the channels injected into the adapters, + // and send it back to the client + // we block until we stop receiving data on all the input channels + + listen([]chan models.SocketData{msgc}, s.msgs) + if errors.Is(err, context.Canceled) { return } @@ -101,42 +140,23 @@ func (self *GameTableServer) subscribeHandler(w http.ResponseWriter, r *http.Req } } -func (self *GameTableServer) subscribe(r *http.Request, c *websocket.Conn) error { - ctx := r.Context() - ctx = c.CloseRead(ctx) - - s := &Subscriber{ - msgs: make(chan []byte, self.subscribeMessageBuffer), - closeSlow: func() { - c.Close(websocket.StatusPolicyViolation, "connection too slow to keep up with messages") - }, - } - - // validate the subprotocol - - self.addSubscriber(s, tableKey) - - defer self.deleteSubscriber(s) - select { - case s.msgs <- self.getCurrentState(tableKey): - default: - go s.closeSlow() - } - - for { - select { - case msg := <-s.msgs: - err := writeTimeout(ctx, time.Second*5, c, msg) - if err != nil { - return err +func listen(channels []chan models.SocketData, out chan []byte) { + var wg sync.WaitGroup + for _, ch := range channels { + wg.Add(1) + go func(ch <-chan models.SocketData) { + defer wg.Done() + for data := range ch { + out <- data.ToDatagram() } - case <-ctx.Done(): - return ctx.Err() - } + }(ch) + } + wg.Wait() + close(out) } -func (self *GameTableServer) publishHandler(w http.ResponseWriter, r *http.Request) { +func (self *BBSServer) publishHandler(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return @@ -153,29 +173,29 @@ func (self *GameTableServer) publishHandler(w http.ResponseWriter, r *http.Reque w.WriteHeader(http.StatusAccepted) } -func (self *GameTableServer) publish(msg []byte) { +func (self *BBSServer) publish(msg []byte) { self.subscribersLock.Lock() defer self.subscribersLock.Unlock() - // send messages to our adapter(s) - + // send messages to our adapter(s) + self.publishLimiter.Wait(context.Background()) - // send any response from the adapter(s) back to the client - - for s, k := range self.subscribers { + // send any response from the adapter(s) back to the client + + /*for s, k := range self.subscribers { // whatever logic to select which subscriber to send back to - } + }*/ } -func (self *GameTableServer) addSubscriber(s *Subscriber, k models.TableKey) { +func (self *BBSServer) addSubscriber(s *Subscriber, k []adapter.Adapter) { self.subscribersLock.Lock() self.subscribers[s] = k self.subscribersLock.Unlock() } -func (self *GameTableServer) deleteSubscriber(s *Subscriber) { +func (self *BBSServer) deleteSubscriber(s *Subscriber) { self.subscribersLock.Lock() delete(self.subscribers, s) self.subscribersLock.Unlock() @@ -186,4 +206,4 @@ func writeTimeout(ctx context.Context, timeout time.Duration, c *websocket.Conn, defer cancel() return c.Write(ctx, websocket.MessageText, msg) -} \ No newline at end of file +} diff --git a/ts/index.ts b/ts/index.ts index 57016ef..62650f0 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -212,9 +212,10 @@ function connect() { } subprotocol += "]"; subprotocol = btoa(subprotocol); - // open the websocket connection with settings as subprotocol - _conn = new WebSocket("/subscribe", subprotocol); + // open the websocket connection with settings as subprotocol + const wsProto = location.protocol == "https:" ? "wss" : "ws"; + _conn = new WebSocket(`${wsProto}://${location.host}/subscribe`, subprotocol); _("websocket", _conn); } } diff --git a/underbbs.go b/underbbs.go index 713b7e6..70ba941 100644 --- a/underbbs.go +++ b/underbbs.go @@ -7,9 +7,10 @@ import ( "net/http" "os" "os/signal" - "path/filepath" "strconv" "time" + + "forge.lightcrystal.systems/lightcrystal/underbbs/server" ) func main() { @@ -20,14 +21,14 @@ func main() { } func run() error { - l, err := net.Listen("tcp", ":"+strconv.FormatInt(int64(cfg.Port), 10)) + l, err := net.Listen("tcp", ":"+strconv.FormatInt(int64(9090), 10)) if err != nil { return err } - serviceHandler := // something + bbsServer := server.New() s := &http.Server{ - Handler: serviceHandler, + Handler: bbsServer, ReadTimeout: time.Second * 10, WriteTimeout: time.Second * 10, }