start: introduce Server type and Shutdown (breaking change)

the main motivation for this change is to be able to run tests.
before this commit, Start, Router and Log operated on global variables,
making automated testing unreasonably hard.

this commit puts all that a server needs in a new Server type,
which also made it possible for a Server.Shutdown - see ShutdownAware
doc comments.

BREAKING CHANGES:
- Relay.OnInitialized takes one argument now, *relayer.Server.
- relayer.Router is now replaced by relayer.Server.Router().
  package users can still hook into the router from OnInitialized
  for custom HTTP routing.
- relayer.Log is gone. apart from another global var, imho this was
  a too opinionated choice for a framework to build a custom relay upon.
  this commit introduces a Logger interface which package users can implement
  for zerolog to make it log like before. see Server.Log for details.

other notable changes: finally added a couple basic tests, for start up
and shutdown. doc comments now explain most of the essentials,
hopefully making it more approachable for newcomers and easier to understand
the relayer package.

the changes in handlers.go are minimal, although git diff goes crazy.
this is because most of the lines are simply shifted indentation back by one
due to go fmt.

before this commit:

    func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request)
    func handleNIP11(relay Relay) func(http.ResponseWriter, *http.Request)

after:

    func (s *Server) handleWebsocket(w http.ResponseWriter, r *http.Request)
    func (s *Server) handleNIP11(w http.ResponseWriter, r *http.Request)
This commit is contained in:
alex
2022-12-24 22:21:26 +01:00
committed by fiatjaf
parent 932a9b62a7
commit 627724f702
14 changed files with 654 additions and 305 deletions

View File

@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"time" "time"
"github.com/fiatjaf/relayer" "github.com/fiatjaf/relayer"
@@ -25,7 +26,7 @@ func (r *Relay) Storage() relayer.Storage {
return r.storage return r.storage
} }
func (r *Relay) OnInitialized() {} func (r *Relay) OnInitialized(*relayer.Server) {}
func (r *Relay) Init() error { func (r *Relay) Init() error {
err := envconfig.Process("", r) err := envconfig.Process("", r)
@@ -71,11 +72,11 @@ func (r *Relay) AfterSave(evt *nostr.Event) {
func main() { func main() {
r := Relay{} r := Relay{}
if err := envconfig.Process("", &r); err != nil { if err := envconfig.Process("", &r); err != nil {
relayer.Log.Fatal().Err(err).Msg("failed to read from env") log.Fatalf("failed to read from env: %v", err)
return return
} }
r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase} r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase}
if err := relayer.Start(&r); err != nil { if err := relayer.Start(&r); err != nil {
relayer.Log.Fatal().Err(err).Msg("server terminated") log.Fatalf("server terminated: %v", err)
} }
} }

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log"
"time" "time"
"github.com/fiatjaf/relayer" "github.com/fiatjaf/relayer"
@@ -45,10 +46,10 @@ func (r *Relay) Init() error {
return nil return nil
} }
func (r *Relay) OnInitialized() { func (r *Relay) OnInitialized(s *relayer.Server) {
// special handlers // special handlers
relayer.Router.Path("/").HandlerFunc(handleWebpage) s.Router().Path("/").HandlerFunc(handleWebpage)
relayer.Router.Path("/invoice").HandlerFunc(handleInvoice) s.Router().Path("/invoice").HandlerFunc(handleInvoice)
} }
func (r *Relay) AcceptEvent(evt *nostr.Event) bool { func (r *Relay) AcceptEvent(evt *nostr.Event) bool {
@@ -81,11 +82,11 @@ func (r *Relay) AfterSave(evt *nostr.Event) {
func main() { func main() {
r := Relay{} r := Relay{}
if err := envconfig.Process("", &r); err != nil { if err := envconfig.Process("", &r); err != nil {
relayer.Log.Fatal().Err(err).Msg("failed to read from env") log.Fatalf("failed to read from env: %v", err)
return return
} }
r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase} r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase}
if err := relayer.Start(&r); err != nil { if err := relayer.Start(&r); err != nil {
relayer.Log.Fatal().Err(err).Msg("server terminated") log.Fatalf("server terminated: %v", err)
} }
} }

1
go.mod
View File

@@ -16,7 +16,6 @@ require (
github.com/nbd-wtf/go-nostr v0.9.0 github.com/nbd-wtf/go-nostr v0.9.0
github.com/rif/cache2go v1.0.0 github.com/rif/cache2go v1.0.0
github.com/rs/cors v1.7.0 github.com/rs/cors v1.7.0
github.com/rs/zerolog v1.20.0
github.com/stevelacy/daz v0.1.4 github.com/stevelacy/daz v0.1.4
github.com/tidwall/gjson v1.14.1 github.com/tidwall/gjson v1.14.1
) )

5
go.sum
View File

@@ -100,7 +100,6 @@ github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM= github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f h1:JOrtw2xFKzlg+cbHpyrpLDmnN1HqhBfnX7WDiW7eG2c=
github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
@@ -353,9 +352,6 @@ github.com/rif/cache2go v1.0.0 h1:DhvZcxXvsuD9ExQ6ZO6f/sOE66OaAQIwB8Mfumap4w4=
github.com/rif/cache2go v1.0.0/go.mod h1:reDqW0mGufW34CGJ1tvjMobI1BY3dCTxA0ZWdbvm06s= github.com/rif/cache2go v1.0.0/go.mod h1:reDqW0mGufW34CGJ1tvjMobI1BY3dCTxA0ZWdbvm06s=
github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.20.0 h1:38k9hgtUBdxFwE34yS8rTHmHBa4eN16E4DJlv177LNs=
github.com/rs/zerolog v1.20.0/go.mod h1:IzD0RJ65iWH0w97OQQebJEvTZYvsCUm9WVLWBQrJRjo=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
@@ -553,7 +549,6 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=

View File

@@ -13,6 +13,7 @@ import (
"github.com/nbd-wtf/go-nostr/nip11" "github.com/nbd-wtf/go-nostr/nip11"
) )
// TODO: consdier moving these to Server as config params
const ( const (
// Time allowed to write a message to the peer. // Time allowed to write a message to the peer.
writeWait = 10 * time.Second writeWait = 10 * time.Second
@@ -27,23 +28,26 @@ const (
maxMessageSize = 512000 maxMessageSize = 512000
) )
// TODO: consdier moving these to Server as config params
var upgrader = websocket.Upgrader{ var upgrader = websocket.Upgrader{
ReadBufferSize: 1024, ReadBufferSize: 1024,
WriteBufferSize: 1024, WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
} }
func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) { func (s *Server) handleWebsocket(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { store := s.relay.Storage()
store := relay.Storage()
advancedDeleter, _ := store.(AdvancedDeleter) advancedDeleter, _ := store.(AdvancedDeleter)
advancedQuerier, _ := store.(AdvancedQuerier) advancedQuerier, _ := store.(AdvancedQuerier)
conn, err := upgrader.Upgrade(w, r, nil) conn, err := upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("failed to upgrade websocket") s.Log.Errorf("failed to upgrade websocket: %v", err)
return return
} }
s.clientsMu.Lock()
defer s.clientsMu.Unlock()
s.clients[conn] = struct{}{}
ticker := time.NewTicker(pingPeriod) ticker := time.NewTicker(pingPeriod)
ws := &WebSocket{conn: conn} ws := &WebSocket{conn: conn}
@@ -52,7 +56,12 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
go func() { go func() {
defer func() { defer func() {
ticker.Stop() ticker.Stop()
s.clientsMu.Lock()
if _, ok := s.clients[conn]; ok {
conn.Close() conn.Close()
delete(s.clients, conn)
}
s.clientsMu.Unlock()
}() }()
conn.SetReadLimit(maxMessageSize) conn.SetReadLimit(maxMessageSize)
@@ -71,7 +80,7 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
websocket.CloseNoStatusReceived, // 1005 websocket.CloseNoStatusReceived, // 1005
websocket.CloseAbnormalClosure, // 1006 websocket.CloseAbnormalClosure, // 1006
) { ) {
log.Warn().Err(err).Str("ip", r.Header.Get("x-forwarded-for")).Msg("unexpected close error") s.Log.Warningf("unexpected close error from %s: %v", r.Header.Get("X-Forwarded-For"), err)
} }
break break
} }
@@ -149,7 +158,7 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
return return
} }
ok, message := AddEvent(relay, evt) ok, message := AddEvent(s.relay, evt)
ws.WriteJSON([]interface{}{"OK", evt.ID, ok, message}) ws.WriteJSON([]interface{}{"OK", evt.ID, ok, message})
break break
@@ -178,22 +187,22 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
} }
events, err := store.QueryEvents(filter) events, err := store.QueryEvents(filter)
if err == nil { if err != nil {
s.Log.Errorf("store: %v", err)
continue
}
if advancedQuerier != nil { if advancedQuerier != nil {
advancedQuerier.AfterQuery(events, filter) advancedQuerier.AfterQuery(events, filter)
} }
if filter.Limit > 0 && len(events) > filter.Limit { if filter.Limit > 0 && len(events) > filter.Limit {
events = events[0:filter.Limit] events = events[0:filter.Limit]
} }
for _, event := range events { for _, event := range events {
ws.WriteJSON([]interface{}{"EVENT", id, event}) ws.WriteJSON([]interface{}{"EVENT", id, event})
} }
ws.WriteJSON([]interface{}{"EOSE", id}) ws.WriteJSON([]interface{}{"EOSE", id})
} }
}
setListener(id, ws, filters) setListener(id, ws, filters)
break break
@@ -208,7 +217,7 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
removeListener(ws, id) removeListener(ws, id)
break break
default: default:
if cwh, ok := relay.(CustomWebSocketHandler); ok { if cwh, ok := s.relay.(CustomWebSocketHandler); ok {
cwh.HandleUnknownType(ws, typ, request) cwh.HandleUnknownType(ws, typ, request)
} else { } else {
notice = "unknown message type " + typ notice = "unknown message type " + typ
@@ -231,21 +240,19 @@ func handleWebsocket(relay Relay) func(http.ResponseWriter, *http.Request) {
case <-ticker.C: case <-ticker.C:
err := ws.WriteMessage(websocket.PingMessage, nil) err := ws.WriteMessage(websocket.PingMessage, nil)
if err != nil { if err != nil {
log.Warn().Err(err).Msg("error writing ping, closing websocket") s.Log.Errorf("error writing ping: %v; closing websocket", err)
return return
} }
} }
} }
}() }()
}
} }
func handleNIP11(relay Relay) func(http.ResponseWriter, *http.Request) { func (s *Server) handleNIP11(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
info := nip11.RelayInformationDocument{ info := nip11.RelayInformationDocument{
Name: relay.Name(), Name: s.relay.Name(),
Description: "relay powered by the relayer framework", Description: "relay powered by the relayer framework",
PubKey: "~", PubKey: "~",
Contact: "~", Contact: "~",
@@ -254,10 +261,9 @@ func handleNIP11(relay Relay) func(http.ResponseWriter, *http.Request) {
Version: "~", Version: "~",
} }
if ifmer, ok := relay.(Informationer); ok { if ifmer, ok := s.relay.(Informationer); ok {
info = ifmer.GetNIP11InformationDocument() info = ifmer.GetNIP11InformationDocument()
} }
json.NewEncoder(w).Encode(info) json.NewEncoder(w).Encode(info)
}
} }

View File

@@ -1,20 +1,31 @@
package relayer package relayer
import ( import (
"context"
"encoding/json" "encoding/json"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/nbd-wtf/go-nostr/nip11" "github.com/nbd-wtf/go-nostr/nip11"
) )
var Log = log // Relay is the main interface for implementing a nostr relay.
// relay
type Relay interface { type Relay interface {
// Name is used as the "name" field in NIP-11 and as a prefix in default Server logging.
// For other NIP-11 fields, see [Informationer].
Name() string Name() string
// Init is called at the very beginning by [Server.Start], allowing a relay
// to initialize its internal resources.
// Also see [Storage.Init].
Init() error Init() error
OnInitialized() // OnInitialized is called by [Server.Start] right before starting to serve HTTP requests.
// It is passed the server to allow callers make final adjustments, such as custom routing.
OnInitialized(*Server)
// AcceptEvent is called for every nostr event received by the server.
// If the returned value is true, the event is passed on to [Storage.SaveEvent].
// Otherwise, the server responds with a negative and "blocked" message as described
// in NIP-20.
AcceptEvent(*nostr.Event) bool AcceptEvent(*nostr.Event) bool
// Storage returns the relay storage implementation.
Storage() Storage Storage() Storage
} }
@@ -22,33 +33,60 @@ type Injector interface {
InjectEvents() chan nostr.Event InjectEvents() chan nostr.Event
} }
// Informationer is called to compose NIP-11 response to an HTTP request
// with application/nostr+json mime type.
// See also [Relay.Name].
type Informationer interface { type Informationer interface {
GetNIP11InformationDocument() nip11.RelayInformationDocument GetNIP11InformationDocument() nip11.RelayInformationDocument
} }
// CustomWebSocketHandler, if implemented, is passed nostr message types unrecognized
// by the server.
// The server handles "EVENT", "REQ" and "CLOSE" messages, as described in NIP-01.
type CustomWebSocketHandler interface { type CustomWebSocketHandler interface {
HandleUnknownType(ws *WebSocket, typ string, request []json.RawMessage) HandleUnknownType(ws *WebSocket, typ string, request []json.RawMessage)
} }
// storage // ShutdownAware is called during the server shutdown.
// See [Server.Shutdown] for details.
type ShutdownAware interface {
OnShutdown(context.Context)
}
// Logger is what [Server] uses to log messages.
type Logger interface {
Infof(format string, v ...any)
Warningf(format string, v ...any)
Errorf(format string, v ...any)
}
// Storage is a persistence layer for nostr events handled by a relay.
type Storage interface { type Storage interface {
// Init is called at the very beginning by [Server.Start], after [Relay.Init],
// allowing a storage to initialize its internal resources.
Init() error Init() error
// QueryEvents is invoked upon a client's REQ as described in NIP-01.
QueryEvents(filter *nostr.Filter) (events []nostr.Event, err error) QueryEvents(filter *nostr.Filter) (events []nostr.Event, err error)
// DeleteEvent is used to handle deletion events, as per NIP-09.
DeleteEvent(id string, pubkey string) error DeleteEvent(id string, pubkey string) error
// SaveEvent is called once Relay.AcceptEvent reports true.
SaveEvent(event *nostr.Event) error SaveEvent(event *nostr.Event) error
} }
// AdvancedQuerier methods are called before and after [Storage.QueryEvents].
type AdvancedQuerier interface { type AdvancedQuerier interface {
BeforeQuery(*nostr.Filter) BeforeQuery(*nostr.Filter)
AfterQuery([]nostr.Event, *nostr.Filter) AfterQuery([]nostr.Event, *nostr.Filter)
} }
// AdvancedDeleter methods are called before and after [Storage.DeleteEvent].
type AdvancedDeleter interface { type AdvancedDeleter interface {
BeforeDelete(id string, pubkey string) BeforeDelete(id string, pubkey string)
AfterDelete(id string, pubkey string) AfterDelete(id string, pubkey string)
} }
// AdvancedSaver methods are called before and after [Storage.SaveEvent].
type AdvancedSaver interface { type AdvancedSaver interface {
BeforeSave(*nostr.Event) BeforeSave(*nostr.Event)
AfterSave(*nostr.Event) AfterSave(*nostr.Event)

View File

@@ -3,10 +3,10 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/fiatjaf/relayer"
. "github.com/stevelacy/daz" . "github.com/stevelacy/daz"
) )
@@ -123,7 +123,7 @@ func handleCreateFeed(w http.ResponseWriter, r *http.Request) {
return return
} }
relayer.Log.Info().Str("url", feedurl).Str("pubkey", pubkey).Msg("saved feed") log.Printf("saved feed at url %q as pubkey %s", feedurl, pubkey)
fmt.Fprintf(w, "url : %s\npubkey: %s", feedurl, pubkey) fmt.Fprintf(w, "url : %s\npubkey: %s", feedurl, pubkey)
return return

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"sync" "sync"
"time" "time"
@@ -29,7 +30,10 @@ func (relay *Relay) Name() string {
return "relayer-rss-bridge" return "relayer-rss-bridge"
} }
func (r *Relay) OnInitialized() {} func (r *Relay) OnInitialized(s *relayer.Server) {
s.Router().Path("/").HandlerFunc(handleWebpage)
s.Router().Path("/create").HandlerFunc(handleCreateFeed)
}
func (relay *Relay) Init() error { func (relay *Relay) Init() error {
err := envconfig.Process("", relay) err := envconfig.Process("", relay)
@@ -38,20 +42,16 @@ func (relay *Relay) Init() error {
} }
if db, err := pebble.Open("db", nil); err != nil { if db, err := pebble.Open("db", nil); err != nil {
relayer.Log.Fatal().Err(err).Str("path", "db").Msg("failed to open db") log.Fatalf("failed to open db: %v", err)
} else { } else {
relay.db = db relay.db = db
} }
relayer.Router.Path("/").HandlerFunc(handleWebpage)
relayer.Router.Path("/create").HandlerFunc(handleCreateFeed)
go func() { go func() {
time.Sleep(20 * time.Minute) time.Sleep(20 * time.Minute)
filters := relayer.GetListeningFilters() filters := relayer.GetListeningFilters()
relayer.Log.Info().Int("filters active", len(filters)). log.Printf("checking for updates; %d filters active", len(filters))
Msg("checking for updates")
for _, filter := range filters { for _, filter := range filters {
if filter.Kinds == nil || filter.Kinds.Contains(nostr.KindTextNote) { if filter.Kinds == nil || filter.Kinds.Contains(nostr.KindTextNote) {
@@ -61,16 +61,13 @@ func (relay *Relay) Init() error {
var entity Entity var entity Entity
if err := json.Unmarshal(val, &entity); err != nil { if err := json.Unmarshal(val, &entity); err != nil {
relayer.Log.Error().Err(err).Str("key", pubkey). log.Printf("got invalid json from db at key %s: %v", pubkey, err)
Str("val", string(val)).
Msg("got invalid json from db")
continue continue
} }
feed, err := parseFeed(entity.URL) feed, err := parseFeed(entity.URL)
if err != nil { if err != nil {
relayer.Log.Warn().Err(err).Str("url", entity.URL). log.Printf("failed to parse feed at url %q: %v", entity.URL, err)
Msg("failed to parse feed")
continue continue
} }
@@ -121,15 +118,13 @@ func (b store) QueryEvents(filter *nostr.Filter) ([]nostr.Event, error) {
var entity Entity var entity Entity
if err := json.Unmarshal(val, &entity); err != nil { if err := json.Unmarshal(val, &entity); err != nil {
relayer.Log.Error().Err(err).Str("key", pubkey).Str("val", string(val)). log.Printf("got invalid json from db at key %s: %v", pubkey, err)
Msg("got invalid json from db")
continue continue
} }
feed, err := parseFeed(entity.URL) feed, err := parseFeed(entity.URL)
if err != nil { if err != nil {
relayer.Log.Warn().Err(err).Str("url", entity.URL). log.Printf("failed to parse feed at url %q: %v", entity.URL, err)
Msg("failed to parse feed")
continue continue
} }
@@ -182,6 +177,6 @@ func (relay *Relay) InjectEvents() chan nostr.Event {
func main() { func main() {
if err := relayer.Start(relay); err != nil { if err := relayer.Start(relay); err != nil {
relayer.Log.Fatal().Err(err).Msg("server terminated") log.Fatalf("server terminated: %v", err)
} }
} }

196
start.go
View File

@@ -1,29 +1,27 @@
package relayer package relayer
import ( import (
"net" "context"
"fmt" "fmt"
"log"
"net"
"net/http" "net/http"
"os" "os"
"sync"
"time" "time"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"github.com/rs/cors" "github.com/rs/cors"
"github.com/rs/zerolog"
) )
// Settings specify initial startup parameters for a relay server. // Settings specify initial startup parameters for Start and StartConf.
// See StartConf for details.
type Settings struct { type Settings struct {
Host string `envconfig:"HOST" default:"0.0.0.0"` Host string `envconfig:"HOST" default:"0.0.0.0"`
Port string `envconfig:"PORT" default:"7447"` Port string `envconfig:"PORT" default:"7447"`
} }
var log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stderr})
var Router = mux.NewRouter()
// Start calls StartConf with Settings parsed from the process environment. // Start calls StartConf with Settings parsed from the process environment.
func Start(relay Relay) error { func Start(relay Relay) error {
var s Settings var s Settings
@@ -33,34 +31,111 @@ func Start(relay Relay) error {
return StartConf(s, relay) return StartConf(s, relay)
} }
// StartConf initalizes the relay and its storage using their respective Init methods, // StartConf creates a new Server, passing it host:port for the address,
// returning any non-nil errors, and starts listening for HTTP requests on host:port otherwise, // and starts serving propagating any error returned from [Server.Start].
// as specified in the settings.
//
// StartConf never returns until termination of the underlying http.Server, forwarding
// any but http.ErrServerClosed error from the server's ListenAndServe.
func StartConf(s Settings, relay Relay) error { func StartConf(s Settings, relay Relay) error {
// allow implementations to do initialization stuff addr := net.JoinHostPort(s.Host, s.Port)
if err := relay.Init(); err != nil { srv := NewServer(addr, relay)
return srv.Start()
}
// Server is a base for package users to implement nostr relays.
// It can serve HTTP requests and websockets, passing control over to a relay implementation.
//
// To implement a relay, it is enough to satisfy [Relay] interface. Other interfaces are
// [Informationer], [CustomWebSocketHandler], [ShutdownAware] and AdvancedXxx types.
// See their respective doc comments.
//
// The basic usage is to call Start or StartConf, which starts serving immediately.
// For a more fine-grained control, use NewServer.
// See [basic/main.go], [whitelisted/main.go], [expensive/main.go] and [rss-bridge/main.go]
// for example implementations.
//
// The following resource is a good starting point for details on what nostr protocol is
// and how it works: https://github.com/nostr-protocol/nostr
type Server struct {
// Default logger, as set by NewServer, is a stdlib logger prefixed with [Relay.Name],
// outputting to stderr.
Log Logger
addr string
relay Relay
router *mux.Router
httpServer *http.Server // set at Server.Start
// keep a connection reference to all connected clients for Server.Shutdown
clientsMu sync.Mutex
clients map[*websocket.Conn]struct{}
}
// NewServer creates a relay server with sensible defaults.
// The provided address is used to listen and respond to HTTP requests.
func NewServer(addr string, relay Relay) *Server {
srv := &Server{
Log: defaultLogger(relay.Name() + ": "),
addr: addr,
relay: relay,
router: mux.NewRouter(),
clients: make(map[*websocket.Conn]struct{}),
}
srv.router.Path("/").Headers("Upgrade", "websocket").HandlerFunc(srv.handleWebsocket)
srv.router.Path("/").Headers("Accept", "application/nostr+json").HandlerFunc(srv.handleNIP11)
return srv
}
// Router returns an http.Handler used to handle server's in-flight HTTP requests.
// By default, the router is setup to handle websocket upgrade and NIP-11 requests.
//
// In a larger system, where the relay server is not the only HTTP handler,
// prefer using s as http.Handler instead of the returned router.
func (s *Server) Router() *mux.Router {
return s.router
}
// Addr returns Server's HTTP listener address in host:port form.
// If the initial port value provided in NewServer is 0, the actual port
// value is picked at random and available by the time [Relay.OnInitialized]
// is called.
func (s *Server) Addr() string {
return s.addr
}
// ServeHTTP implements http.Handler interface.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.router.ServeHTTP(w, r)
}
// Start initializes the relay and its storage using their respective Init methods,
// returning any non-nil errors, and starts listening for HTTP requests on the address
// provided to NewServer.
//
// Just before starting to serve HTTP requests, Start calls Relay.OnInitialized
// allowing package users to make last adjustments, such as setting up custom HTTP
// handlers using s.Router.
//
// Start never returns until termination of the underlying http.Server, forwarding
// any but http.ErrServerClosed error from the server's ListenAndServe.
// To terminate the server, call Shutdown.
func (s *Server) Start() error {
ln, err := net.Listen("tcp", s.addr)
if err != nil {
return err
}
s.addr = ln.Addr().String()
return s.startListener(ln)
}
func (s *Server) startListener(ln net.Listener) error {
// init the relay
if err := s.relay.Init(); err != nil {
return fmt.Errorf("relay init: %w", err) return fmt.Errorf("relay init: %w", err)
} }
if err := s.relay.Storage().Init(); err != nil {
// initialize storage
if err := relay.Storage().Init(); err != nil {
return fmt.Errorf("storage init: %w", err) return fmt.Errorf("storage init: %w", err)
} }
// expose this Log instance so implementations can use it // push events from implementations, if any
Log = log.With().Str("name", relay.Name()).Logger() if inj, ok := s.relay.(Injector); ok {
// catch the websocket call before anything else
Router.Path("/").Headers("Upgrade", "websocket").HandlerFunc(handleWebsocket(relay))
// nip-11, relay information
Router.Path("/").Headers("Accept", "application/nostr+json").HandlerFunc(handleNIP11(relay))
// wait for events to come from implementations, if this is implemented
if inj, ok := relay.(Injector); ok {
go func() { go func() {
for event := range inj.InjectEvents() { for event := range inj.InjectEvents() {
notifyListeners(&event) notifyListeners(&event)
@@ -68,21 +143,58 @@ func StartConf(s Settings, relay Relay) error {
}() }()
} }
relay.OnInitialized() s.httpServer = &http.Server{
Handler: cors.Default().Handler(s),
// start http server Addr: s.addr,
srv := &http.Server{
Handler: cors.Default().Handler(Router),
Addr: net.JoinHostPort(s.Host, s.Port),
WriteTimeout: 2 * time.Second, WriteTimeout: 2 * time.Second,
ReadTimeout: 2 * time.Second, ReadTimeout: 2 * time.Second,
IdleTimeout: 30 * time.Second, IdleTimeout: 30 * time.Second,
ReadHeaderTimeout: 2 * time.Second,
} }
log.Debug().Str("addr", srv.Addr).Msg("listening") s.httpServer.RegisterOnShutdown(s.disconnectAllClients)
srvErr := srv.ListenAndServe() // final callback, just before serving http
if srvErr == http.ErrServerClosed { s.relay.OnInitialized(s)
srvErr = nil
// start accepting incoming requests
s.Log.Infof("listening on %s", s.addr)
err := s.httpServer.Serve(ln)
if err == http.ErrServerClosed {
err = nil
} }
return srvErr return err
} }
// Shutdown stops serving HTTP requests and send a websocket close control message
// to all connected clients.
//
// If the relay is ShutdownAware, Shutdown calls its OnShutdown, passing the context as is.
// Note that the HTTP server make some time to shutdown and so the context deadline,
// if any, may have been shortened by the time OnShutdown is called.
func (s *Server) Shutdown(ctx context.Context) error {
err := s.httpServer.Shutdown(ctx)
if f, ok := s.relay.(ShutdownAware); ok {
f.OnShutdown(ctx)
}
return err
}
func (s *Server) disconnectAllClients() {
s.clientsMu.Lock()
defer s.clientsMu.Unlock()
for conn := range s.clients {
conn.WriteControl(websocket.CloseMessage, nil, time.Now().Add(time.Second))
conn.Close()
delete(s.clients, conn)
}
}
func defaultLogger(prefix string) Logger {
l := log.New(os.Stderr, "", log.LstdFlags|log.Lmsgprefix)
l.SetPrefix(prefix)
return stdLogger{l}
}
type stdLogger struct{ log *log.Logger }
func (l stdLogger) Infof(format string, v ...any) { l.log.Printf(format, v...) }
func (l stdLogger) Warningf(format string, v ...any) { l.log.Printf(format, v...) }
func (l stdLogger) Errorf(format string, v ...any) { l.log.Printf(format, v...) }

106
start_test.go Normal file
View File

@@ -0,0 +1,106 @@
package relayer
import (
"context"
"net/http"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/nbd-wtf/go-nostr"
)
func TestServerStartShutdown(t *testing.T) {
var (
serverHost string
inited bool
storeInited bool
shutdown bool
)
ready := make(chan struct{})
rl := &testRelay{
name: "test server start",
init: func() error {
inited = true
return nil
},
onInitialized: func(s *Server) {
serverHost = s.Addr()
close(ready)
},
onShutdown: func(context.Context) { shutdown = true },
storage: &testStorage{
init: func() error { storeInited = true; return nil },
},
}
srv := NewServer("127.0.0.1:0", rl)
done := make(chan error)
go func() { done <- srv.Start(); close(done) }()
// verify everything's initialized
select {
case <-ready:
// continue
case <-time.After(time.Second):
t.Fatal("srv.Start too long to initialize")
}
if !inited {
t.Error("didn't call testRelay.init")
}
if !storeInited {
t.Error("didn't call testStorage.init")
}
// check that http requests are served
if _, err := http.Get("http://" + serverHost); err != nil {
t.Errorf("GET %s: %v", serverHost, err)
}
// verify server shuts down
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
t.Errorf("srv.Shutdown: %v", err)
}
if !shutdown {
t.Error("didn't call testRelay.onShutdown")
}
select {
case err := <-done:
if err != nil {
t.Errorf("srv.Start: %v", err)
}
case <-time.After(time.Second):
t.Error("srv.Start too long to return")
}
}
func TestServerShutdownWebsocket(t *testing.T) {
// set up a new relay server
srv := startTestRelay(t, &testRelay{storage: &testStorage{}})
// connect a client to it
ctx1, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
client, err := nostr.RelayConnectContext(ctx1, "ws://"+srv.Addr())
if err != nil {
t.Fatalf("nostr.RelayConnectContext: %v", err)
}
// now, shut down the server
ctx2, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := srv.Shutdown(ctx2); err != nil {
t.Errorf("srv.Shutdown: %v", err)
}
// wait for the client to receive a "connection close"
select {
case err := <-client.ConnectionError:
if _, ok := err.(*websocket.CloseError); !ok {
t.Errorf("client.ConnextionError: %v (%T); want websocket.CloseError", err, err)
}
case <-time.After(2 * time.Second):
t.Error("client took too long to disconnect")
}
}

View File

@@ -1,7 +1,6 @@
package postgresql package postgresql
import ( import (
"github.com/fiatjaf/relayer"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx" "github.com/jmoiron/sqlx/reflectx"
_ "github.com/lib/pq" _ "github.com/lib/pq"
@@ -41,8 +40,5 @@ CREATE INDEX IF NOT EXISTS timeidx ON event (created_at);
CREATE INDEX IF NOT EXISTS kindidx ON event (kind); CREATE INDEX IF NOT EXISTS kindidx ON event (kind);
CREATE INDEX IF NOT EXISTS arbitrarytagvalues ON event USING gin (tagvalues); CREATE INDEX IF NOT EXISTS arbitrarytagvalues ON event USING gin (tagvalues);
`) `)
if err != nil { return err
relayer.Log.Print(err)
}
return nil
} }

View File

@@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr"
"github.com/fiatjaf/relayer"
) )
func (b PostgresBackend) QueryEvents(filter *nostr.Filter) (events []nostr.Event, err error) { func (b PostgresBackend) QueryEvents(filter *nostr.Filter) (events []nostr.Event, err error) {
@@ -137,11 +136,7 @@ func (b PostgresBackend) QueryEvents(filter *nostr.Filter) (events []nostr.Event
rows, err := b.DB.Query(query, params...) rows, err := b.DB.Query(query, params...)
if err != nil && err != sql.ErrNoRows { if err != nil && err != sql.ErrNoRows {
relayer.Log.Warn().Err(err). return nil, fmt.Errorf("failed to fetch events using query %q: %w", query, err)
Interface("filter", filter).
Str("query", query).
Msg("failed to fetch events")
return nil, fmt.Errorf("failed to fetch events: %w", err)
} }
for rows.Next() { for rows.Next() {

104
util_test.go Normal file
View File

@@ -0,0 +1,104 @@
package relayer
import (
"context"
"testing"
"time"
"github.com/nbd-wtf/go-nostr"
)
func startTestRelay(t *testing.T, tr *testRelay) *Server {
t.Helper()
ready := make(chan struct{})
onInitializedFn := tr.onInitialized
tr.onInitialized = func(s *Server) {
close(ready)
if onInitializedFn != nil {
onInitializedFn(s)
}
}
srv := NewServer("127.0.0.1:0", tr)
go srv.Start()
select {
case <-ready:
case <-time.After(time.Second):
t.Fatal("server took too long to start up")
}
return srv
}
type testRelay struct {
name string
storage Storage
init func() error
onInitialized func(*Server)
onShutdown func(context.Context)
acceptEvent func(*nostr.Event) bool
}
func (tr *testRelay) Name() string { return tr.name }
func (tr *testRelay) Storage() Storage { return tr.storage }
func (tr *testRelay) Init() error {
if fn := tr.init; fn != nil {
return fn()
}
return nil
}
func (tr *testRelay) OnInitialized(s *Server) {
if fn := tr.onInitialized; fn != nil {
fn(s)
}
}
func (tr *testRelay) OnShutdown(ctx context.Context) {
if fn := tr.onShutdown; fn != nil {
fn(ctx)
}
}
func (tr *testRelay) AcceptEvent(e *nostr.Event) bool {
if fn := tr.acceptEvent; fn != nil {
return fn(e)
}
return true
}
type testStorage struct {
init func() error
queryEvents func(*nostr.Filter) ([]nostr.Event, error)
deleteEvent func(id string, pubkey string) error
saveEvent func(*nostr.Event) error
}
func (st *testStorage) Init() error {
if fn := st.init; fn != nil {
return fn()
}
return nil
}
func (st *testStorage) QueryEvents(f *nostr.Filter) ([]nostr.Event, error) {
if fn := st.queryEvents; fn != nil {
return fn(f)
}
return nil, nil
}
func (st *testStorage) DeleteEvent(id string, pubkey string) error {
if fn := st.deleteEvent; fn != nil {
return fn(id, pubkey)
}
return nil
}
func (st *testStorage) SaveEvent(e *nostr.Event) error {
if fn := st.saveEvent; fn != nil {
return fn(e)
}
return nil
}

View File

@@ -2,6 +2,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"log"
"github.com/fiatjaf/relayer" "github.com/fiatjaf/relayer"
"github.com/fiatjaf/relayer/storage/postgresql" "github.com/fiatjaf/relayer/storage/postgresql"
@@ -20,7 +21,7 @@ func (r *Relay) Name() string {
return "WhitelistedRelay" return "WhitelistedRelay"
} }
func (r *Relay) OnInitialized() {} func (r *Relay) OnInitialized(*relayer.Server) {}
func (r *Relay) Storage() relayer.Storage { func (r *Relay) Storage() relayer.Storage {
return r.storage return r.storage
@@ -55,11 +56,11 @@ func (r *Relay) AcceptEvent(evt *nostr.Event) bool {
func main() { func main() {
r := Relay{} r := Relay{}
if err := envconfig.Process("", &r); err != nil { if err := envconfig.Process("", &r); err != nil {
relayer.Log.Fatal().Err(err).Msg("failed to read from env") log.Fatalf("failed to read from env: %v", err)
return return
} }
r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase} r.storage = &postgresql.PostgresBackend{DatabaseURL: r.PostgresDatabase}
if err := relayer.Start(&r); err != nil { if err := relayer.Start(&r); err != nil {
relayer.Log.Fatal().Err(err).Msg("server terminated") log.Fatalf("server terminated: %v", err)
} }
} }