commit e9cbff07a401d5db7444652c6a9ca5984e4cb88c Author: fiatjaf Date: Wed Jan 13 23:46:06 2021 -0300 migrate off of main nostr repository. 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 +}