From e9cbff07a401d5db7444652c6a9ca5984e4cb88c Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Wed, 13 Jan 2021 23:46:06 -0300 Subject: [PATCH] migrate off of main nostr repository. --- .gitignore | 3 + Makefile | 5 + event.go | 128 +++++++++++++++++ go.mod | 19 +++ go.sum | 85 +++++++++++ handlers.go | 392 ++++++++++++++++++++++++++++++++++++++++++++++++++ listener.go | 94 ++++++++++++ main.go | 53 +++++++ notice.go | 6 + postgresql.go | 32 +++++ sqlite.go | 31 ++++ 11 files changed, 848 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 event.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 handlers.go create mode 100644 listener.go create mode 100644 main.go create mode 100644 notice.go create mode 100644 postgresql.go create mode 100644 sqlite.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3969cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +relay-lite +relay-full +*.sqlite diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6c38e40 --- /dev/null +++ b/Makefile @@ -0,0 +1,5 @@ +relay-lite: $(shell find . -name "*.go") + go build -ldflags="-s -w" -o ./relay-lite + +relay-full: $(shell find . -name "*.go") + go build -ldflags="-s -w" -tags full -o ./relay-full diff --git a/event.go b/event.go new file mode 100644 index 0000000..b663484 --- /dev/null +++ b/event.go @@ -0,0 +1,128 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + + "github.com/fiatjaf/schnorr" +) + +const ( + KindSetMetadata uint8 = 0 + KindTextNote uint8 = 1 + KindRecommendServer uint8 = 2 + KindContactList uint8 = 3 +) + +type Event struct { + ID string `db:"id" json:"id"` // it's the hash of the serialized event + + PubKey string `db:"pubkey" json:"pubkey"` + CreatedAt uint32 `db:"created_at" json:"created_at"` + + Kind uint8 `db:"kind" json:"kind"` + + Tags Tags `db:"tags" json:"tags"` + Content string `db:"content" json:"content"` + Sig string `db:"sig" json:"sig"` +} + +type Tags []Tag + +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 +} + +type Tag []interface{} + +// 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 + arr := make([]interface{}, 6) + + // version: 0 + arr[0] = 0 + + // pubkey + arr[1] = evt.PubKey + + // created_at + arr[2] = int64(evt.CreatedAt) + + // kind + arr[3] = int64(evt.Kind) + + // tags + if evt.Tags != nil { + arr[4] = evt.Tags + } else { + arr[4] = make([]bool, 0) + } + + // content + arr[5] = evt.Content + + serialized, _ := json.Marshal(arr) + return serialized +} + +// 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 + pubkeyb, err := hex.DecodeString(evt.PubKey) + if err != nil { + return false, err + } + if len(pubkeyb) != 32 { + return false, fmt.Errorf("pubkey must be 32 bytes, not %d", len(pubkeyb)) + } + + // check tags + for _, tag := range evt.Tags { + for _, item := range tag { + switch item.(type) { + case string, int64, float64, int, bool: + // fine + default: + // not fine + return false, fmt.Errorf("tag contains an invalid value %v", item) + } + } + } + + sig, err := hex.DecodeString(evt.Sig) + if err != nil { + return false, fmt.Errorf("signature is invalid hex: %w", err) + } + if len(sig) != 64 { + return false, fmt.Errorf("signature must be 64 bytes, not %d", len(sig)) + } + + var p [32]byte + copy(p[:], pubkeyb) + + var s [64]byte + copy(s[:], sig) + + h := sha256.Sum256(evt.Serialize()) + + return schnorr.Verify(p, h, s) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2a620b2 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/fiatjaf/nostr-relay + +go 1.15 + +require ( + github.com/fiatjaf/schnorr v0.2.1-hack + github.com/go-sql-driver/mysql v1.5.0 // indirect + github.com/gorilla/mux v1.8.0 + github.com/gorilla/websocket v1.4.2 + github.com/jmoiron/sqlx v1.2.0 + github.com/kelseyhightower/envconfig v1.4.0 + github.com/kr/pretty v0.2.1 + github.com/lib/pq v1.8.0 + github.com/mattn/go-sqlite3 v1.14.4 + github.com/rs/cors v1.7.0 + github.com/rs/zerolog v1.20.0 + golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 + gopkg.in/antage/eventsource.v1 v1.0.0-20150318155416-803f4c5af225 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..9ec0f5c --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95 h1:bmv+LE3sbjb/M06u2DBi92imeKj7KnCUBOvyZYqI8d8= +github.com/btcsuite/btcd v0.0.0-20190109040709-5bda5314ca95/go.mod h1:d3C0AkH6BRcvO8T0UEPu53cnw4IbV63x1bEjildYhO0= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20180706230648-ab6388e0c60a/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v0.0.0-20190112041146-bf1e1be93589/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/fiatjaf/schnorr v0.2.1-hack h1:6NwQNN5O4+ZUm8KliT+l198vDVH8ovv8AJ8CiL4hvs0= +github.com/fiatjaf/schnorr v0.2.1-hack/go.mod h1:6aMsVxPxyO6awpdmNkfkJ8vXqsmUOeGCHp2CdG5LPR0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= +github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= +github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kkdai/bstream v0.0.0-20181106074824-b3251f7901ec/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.8.0 h1:9xohqzkUwzR4Ga4ivdTcawVS89YSDVxXMa3xJX3cGzg= +github.com/lib/pq v1.8.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= +github.com/mattn/go-sqlite3 v1.14.4 h1:4rQjbDxdu9fSgI/r3KN72G3c2goxknAqHHgPWWs8UlI= +github.com/mattn/go-sqlite3 v1.14.4/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/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= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190103213133-ff983b9c42bc/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE= +golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20190828213141-aed303cbaa74/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/antage/eventsource.v1 v1.0.0-20150318155416-803f4c5af225 h1:xy+AV3uSExoRQc2qWXeZdbhFGwBFK/AmGlrBZEjbvuQ= +gopkg.in/antage/eventsource.v1 v1.0.0-20150318155416-803f4c5af225/go.mod h1:SiXNRpUllqhl+GIw2V/BtKI7BUlz+uxov9vBFtXHqh8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..f0d9ee4 --- /dev/null +++ b/handlers.go @@ -0,0 +1,392 @@ +package main + +import ( + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/gorilla/websocket" + "golang.org/x/time/rate" +) + +const ( + // Time allowed to write a message to the peer. + writeWait = 10 * time.Second + + // Time allowed to read the next pong message from the peer. + pongWait = 60 * time.Second + + // Send pings to peer with this period. Must be less than pongWait. + pingPeriod = pongWait / 2 + + // Maximum message size allowed from peer. + maxMessageSize = 512000 +) + +var ratelimiter = rate.NewLimiter(rate.Every(time.Second*40), 2) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, +} + +func handleWebsocket(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Warn().Err(err).Msg("failed to upgrade websocket") + return + } + + // reader + go func() { + defer func() { + conn.Close() + }() + + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + typ, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError( + err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Warn().Err(err).Msg("unexpected close error") + } + break + } + + if typ == websocket.PingMessage { + conn.WriteMessage(websocket.PongMessage, nil) + continue + } + + text := string(message) + + switch { + case text == "PING": + conn.WriteMessage(websocket.TextMessage, []byte("PONG")) + + case strings.HasPrefix(text, "{"): + // it's a new event + err = saveEvent(message) + + case strings.HasPrefix(text, "sub-key:"): + watchPubKey(strings.TrimSpace(text[8:]), conn) + + case strings.HasPrefix(text, "unsub-key:"): + unwatchPubKey(strings.TrimSpace(text[10:]), conn) + + case strings.HasPrefix(text, "req-feed:"): + err = requestFeed(message[len([]byte("req-feed:")):], conn) + + case strings.HasPrefix(text, "req-event:"): + err = requestEvent(message[len([]byte("req-event")):], conn) + + case strings.HasPrefix(text, "req-key:"): + err = requestKey(message[len([]byte("req-key")):], conn) + } + + if err != nil { + errj, _ := json.Marshal([]interface{}{ + "notice", + err.Error(), + }) + conn.WriteMessage(websocket.TextMessage, errj) + continue + } + } + }() + + // writer + go func() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + conn.Close() + }() + + for { + select { + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + err := conn.WriteMessage(websocket.TextMessage, []byte("PING")) + if err != nil { + log.Warn().Err(err).Msg("error writing ping, closing websocket") + return + } + conn.WriteMessage(websocket.PingMessage, nil) + } + } + }() +} + +func saveEvent(body []byte) error { + if !ratelimiter.Allow() { + return errors.New("rate-limit") + } + + var evt Event + err := json.Unmarshal(body, &evt) + if err != nil { + log.Warn().Err(err).Msg("couldn't decode body") + return errors.New("failed to decode event") + } + + // disallow large contents + if len(evt.Content) > 1000 { + log.Warn().Err(err).Msg("event content too large") + return errors.New("event content too large") + } + + // check serialization + serialized := evt.Serialize() + + // assign ID + hash := sha256.Sum256(serialized) + evt.ID = hex.EncodeToString(hash[:]) + + // check signature (requires the ID to be set) + if ok, err := evt.CheckSignature(); err != nil { + log.Warn().Err(err).Msg("signature verification error") + return errors.New("signature verification error") + } else if !ok { + log.Warn().Err(err).Msg("signature invalid") + return errors.New("signature invalid") + } + + // react to different kinds of events + switch evt.Kind { + case KindSetMetadata: + // delete past set_metadata events from this user + db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 1`, evt.PubKey) + case KindTextNote: + // do nothing + case KindRecommendServer: + // delete past recommend_server events equal to this one + db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 2 AND content = $2`, + evt.PubKey, evt.Content) + case KindContactList: + // delete past contact lists from this same pubkey + db.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 3`, evt.PubKey) + } + + // insert + tagsj, _ := json.Marshal(evt.Tags) + _, err = db.Exec(` + INSERT INTO event (id, pubkey, created_at, kind, tags, content, sig) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, evt.ID, evt.PubKey, evt.CreatedAt, evt.Kind, tagsj, evt.Content, evt.Sig) + if err != nil { + if strings.Index(err.Error(), "UNIQUE") != -1 { + // already exists + return nil + } + + log.Warn().Err(err).Str("pubkey", evt.PubKey).Msg("failed to save") + return errors.New("failed to save event") + } + + notifyPubKeyEvent(evt.PubKey, &evt) + return nil +} + +func requestFeed(body []byte, conn *websocket.Conn) error { + var data struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + } + json.Unmarshal(body, &data) + + if data.Limit <= 0 || data.Limit > 100 { + data.Limit = 50 + } + if data.Offset < 0 { + data.Offset = 0 + } else if data.Offset > 500 { + return errors.New("offset over 500") + } + + keys, ok := backwatchers[conn] + if !ok { + return errors.New("not subscribed to anything") + } + + inkeys := make([]string, 0, len(keys)) + for _, key := range keys { + // to prevent sql attack here we will check if these keys are valid 32byte hex + parsed, err := hex.DecodeString(key) + if err != nil || len(parsed) != 32 { + continue + } + inkeys = append(inkeys, fmt.Sprintf("'%x'", parsed)) + } + var lastUpdates []Event + err := db.Select(&lastUpdates, ` + SELECT * + FROM event + WHERE pubkey IN (`+strings.Join(inkeys, ",")+`) + ORDER BY created_at DESC + LIMIT $1 + OFFSET $2 + `, data.Limit, data.Offset) + if err != nil && err != sql.ErrNoRows { + log.Warn().Err(err).Interface("keys", keys).Msg("failed to fetch events") + return errors.New("failed to fetch events") + } + + for _, evt := range lastUpdates { + jevent, _ := json.Marshal([]interface{}{ + evt, + "p", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } + + return nil +} + +func requestKey(body []byte, conn *websocket.Conn) error { + var data struct { + Key string `json:"key"` + Limit int `json:"limit"` + Offset int `json:"offset"` + } + json.Unmarshal(body, &data) + if data.Key == "" { + return errors.New("invalid pubkey") + } + if data.Limit <= 0 || data.Limit > 100 { + data.Limit = 30 + } + if data.Offset < 0 { + data.Offset = 0 + } else if data.Offset > 300 { + return errors.New("offset over 300") + } + + go func() { + var metadata Event + if err := db.Get(&metadata, ` + SELECT * FROM event + WHERE pubkey = $1 AND kind = 0 + `, data.Key); err == nil { + jevent, _ := json.Marshal([]interface{}{ + metadata, + "r", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } else if err != sql.ErrNoRows { + log.Warn().Err(err). + Str("key", data.Key). + Msg("error fetching metadata from requested user") + } + }() + + go func() { + var lastUpdates []Event + if err := db.Select(&lastUpdates, ` + SELECT * FROM event + WHERE pubkey = $1 AND kind != 0 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + `, data.Key, data.Limit, data.Offset); err == nil { + for _, evt := range lastUpdates { + jevent, _ := json.Marshal([]interface{}{ + evt, + "r", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } + } else if err != sql.ErrNoRows { + log.Warn().Err(err). + Str("key", data.Key). + Msg("error fetching updates from requested user") + } + }() + + return nil +} + +func requestEvent(body []byte, conn *websocket.Conn) error { + var data struct { + Id string `json:"id"` + Limit int `json:"limit"` + } + json.Unmarshal(body, &data) + if data.Id == "" { + return errors.New("no id provided") + } + if data.Limit > 100 || data.Limit <= 0 { + data.Limit = 50 + } + + go func() { + // get requested event + var evt Event + if err := db.Get(&evt, ` + SELECT * FROM event WHERE id = $1 + `, data.Id); err == nil { + jevent, _ := json.Marshal([]interface{}{ + evt, + "r", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } else if err != sql.ErrNoRows { + log.Warn().Err(err). + Str("key", data.Id). + Msg("error fetching a specific event") + } + + for _, tag := range evt.Tags { + log.Print(tag) + // get referenced event TODO + // var ref Event + // if err := db.Get(&ref, ` + // SELECT * FROM event WHERE id = $1 + // `, evt.Ref); err == nil { + // jevent, _ := json.Marshal(ref) + // (*es).SendEventMessage(string(jevent), "r", "") + // } else if err != sql.ErrNoRows { + // log.Warn().Err(err). + // Str("key", data.Id).Str("ref", evt.Ref). + // Msg("error fetching a referenced event") + // } + } + }() + + go func() { + // get events that reference this + var related []Event + if err := db.Select(&related, ` + SELECT * FROM event + WHERE ref = $1 + LIMIT $2 + `, data.Id, data.Limit); err == nil { + for _, evt := range related { + jevent, _ := json.Marshal([]interface{}{ + evt, + "r", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } + } else if err != sql.ErrNoRows { + log.Warn().Err(err). + Str("key", data.Id). + Msg("error fetching events that reference requested event") + } + }() + + return nil +} diff --git a/listener.go b/listener.go new file mode 100644 index 0000000..66fcd99 --- /dev/null +++ b/listener.go @@ -0,0 +1,94 @@ +package main + +import ( + "encoding/json" + "sync" + + "github.com/gorilla/websocket" +) + +var watchers = make(map[string][]*websocket.Conn) +var backwatchers = make(map[*websocket.Conn][]string) +var wlock = sync.Mutex{} + +func watchPubKey(key string, ws *websocket.Conn) { + wlock.Lock() + defer wlock.Unlock() + + currentKeys, _ := backwatchers[ws] + backwatchers[ws] = append(currentKeys, key) + + if wss, ok := watchers[key]; ok { + watchers[key] = append(wss, ws) + } else { + watchers[key] = []*websocket.Conn{ws} + } +} + +func unwatchPubKey(excludedKey string, ws *websocket.Conn) { + wlock.Lock() + defer wlock.Unlock() + + if wss, ok := watchers[excludedKey]; ok { + newWss := make([]*websocket.Conn, len(wss)-1) + + var i = 0 + for _, existingWs := range wss { + if existingWs == ws { + continue + } + newWss[i] = existingWs + i++ + } + + watchers[excludedKey] = newWss + } + + currentKeys, _ := backwatchers[ws] + newKeys := make([]string, 0, len(currentKeys)) + for _, currentKey := range currentKeys { + if excludedKey == currentKey { + continue + } + newKeys = append(newKeys, currentKey) + } + + backwatchers[ws] = newKeys +} + +func removeFromWatchers(es *websocket.Conn) { + wlock.Lock() + defer wlock.Unlock() + + for _, key := range backwatchers[es] { + if arr, ok := watchers[key]; ok { + newarr := make([]*websocket.Conn, len(arr)-1) + i := 0 + for _, oldes := range arr { + if oldes == es { + continue + } + newarr[i] = oldes + i++ + } + watchers[key] = newarr + } + } + delete(backwatchers, es) +} + +func notifyPubKeyEvent(key string, evt *Event) { + wlock.Lock() + arr, ok := watchers[key] + wlock.Unlock() + + if ok { + for _, conn := range arr { + jevent, _ := json.Marshal([]interface{}{ + evt, + "n", + }) + conn.WriteMessage(websocket.TextMessage, jevent) + } + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..71e1e18 --- /dev/null +++ b/main.go @@ -0,0 +1,53 @@ +package main + +import ( + "net/http" + "os" + "time" + + "github.com/gorilla/mux" + "github.com/jmoiron/sqlx" + "github.com/kelseyhightower/envconfig" + "github.com/rs/cors" + "github.com/rs/zerolog" +) + +type Settings struct { + Host string `envconfig:"HOST" default:"0.0.0.0"` + Port string `envconfig:"PORT" default:"7447"` + + PostgresDatabase string `envconfig:"POSTGRESQL_DATABASE"` + SQLiteDatabase string `envconfig:"SQLITE_DATABASE"` +} + +var s Settings +var err error +var db *sqlx.DB +var log = zerolog.New(os.Stderr).Output(zerolog.ConsoleWriter{Out: os.Stderr}) +var router = mux.NewRouter() + +func main() { + err = envconfig.Process("", &s) + if err != nil { + log.Fatal().Err(err).Msg("couldn't process envconfig") + } + + db, err = initDB() + if err != nil { + log.Fatal().Err(err).Msg("failed to open database") + } + + // NIP01 + router.Path("/ws").Methods("GET").HandlerFunc(handleWebsocket) + + srv := &http.Server{ + Handler: cors.Default().Handler(router), + Addr: s.Host + ":" + s.Port, + WriteTimeout: 2 * time.Second, + ReadTimeout: 2 * time.Second, + IdleTimeout: 30 * time.Second, + ReadHeaderTimeout: 2 * time.Second, + } + log.Debug().Str("addr", srv.Addr).Msg("listening") + srv.ListenAndServe() +} diff --git a/notice.go b/notice.go new file mode 100644 index 0000000..9d96d4a --- /dev/null +++ b/notice.go @@ -0,0 +1,6 @@ +package main + +type Notice struct { + Kind string `json:"kind"` + Message string `json:"message"` +} diff --git a/postgresql.go b/postgresql.go new file mode 100644 index 0000000..6e7e713 --- /dev/null +++ b/postgresql.go @@ -0,0 +1,32 @@ +// +build full + +package main + +import ( + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +func initDB() (*sqlx.DB, error) { + db, err := sqlx.Connect("postgres", s.PostgresDatabase) + if err != nil { + return nil, err + } + + _, err = db.Exec(` +CREATE TABLE event ( + id text NOT NULL, + pubkey text NOT NULL, + created_at integer NOT NULL, + kind integer NOT NULL, + tags jsonb NOT NULL, + content text NOT NULL, + sig text NOT NULL +); + +CREATE UNIQUE INDEX ididx ON event (id); +CREATE INDEX pubkeytimeidx ON event (pubkey, created_at); + `) + log.Print(err) + return db, nil +} diff --git a/sqlite.go b/sqlite.go new file mode 100644 index 0000000..bc894bb --- /dev/null +++ b/sqlite.go @@ -0,0 +1,31 @@ +// +build !full + +package main + +import ( + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +func initDB() (*sqlx.DB, error) { + db, err := sqlx.Connect("sqlite3", s.SQLiteDatabase) + if err != nil { + return nil, err + } + + _, err = db.Exec(` +CREATE TABLE event ( + id text NOT NULL, + pubkey text NOT NULL, + created_at integer NOT NULL, + kind integer NOT NULL, + tags text NOT NULL, + content text NOT NULL, + sig text NOT NULL +); + +CREATE UNIQUE INDEX ididx ON event (id); +CREATE INDEX pubkeytimeidx ON event (pubkey, created_at); + `) + return db, nil +}