move relay and web-client out to other repositories.

This commit is contained in:
fiatjaf 2021-01-13 23:45:50 -03:00
parent 31ebd7e83d
commit 098aa10a5b
34 changed files with 0 additions and 2277 deletions

5
.gitignore vendored
View File

@ -1,5 +0,0 @@
*.env
*.sqlite
# Local Netlify folder
.netlify

2
relay/.gitignore vendored
View File

@ -1,2 +0,0 @@
relay-lite
relay-full

View File

@ -1,5 +0,0 @@
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

View File

@ -1,128 +0,0 @@
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)
}

View File

@ -1,26 +0,0 @@
package main
import (
"encoding/json"
"net/http"
)
func queryUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json")
keys := r.URL.Query()["keys"]
found := make(map[string]int, len(keys))
for _, key := range keys {
var exists bool
err := db.Get(&exists, `SELECT true FROM event WHERE pubkey = $1`, key)
if err != nil {
w.WriteHeader(500)
log.Warn().Err(err).Str("key", key).Msg("failed to check existence")
return
}
if exists {
found[key] = 1
}
}
json.NewEncoder(w).Encode(found)
}

View File

@ -1,19 +0,0 @@
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
)

View File

@ -1,85 +0,0 @@
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=

View File

@ -1,392 +0,0 @@
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
}

View File

@ -1,94 +0,0 @@
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)
}
}
}

View File

@ -1,53 +0,0 @@
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()
}

View File

@ -1,6 +0,0 @@
package main
type Notice struct {
Kind string `json:"kind"`
Message string `json:"message"`
}

View File

@ -1,32 +0,0 @@
// +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
}

View File

@ -1,31 +0,0 @@
// +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
}

View File

@ -1,2 +0,0 @@
node_modules
bundle.*

View File

@ -1,2 +0,0 @@
static/bundle.js: $(shell find src -name '*.js' -or -name '*.html')
./node_modules/.bin/rollup -c rollup.config.js

View File

@ -1,26 +0,0 @@
{
"dependencies": {
"dexie": "^3.0.2",
"insort": "^0.4.0",
"nostr-tools": "0.1.3",
"pretty-date": "^0.2.0",
"quick-lru": "^5.1.1",
"vue": "^3.0.2",
"vue-router": "^4.0.0-rc.3",
"vuex": "^4.0.0-rc.1"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-inject": "^4.0.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^10.0.0",
"@vue/compiler-sfc": "^3.0.2",
"rollup": "2.36.1",
"rollup-plugin-css-only": "^2.1.0",
"rollup-plugin-ignore": "^1.0.9",
"rollup-plugin-inject-process-env": "^1.3.1",
"rollup-plugin-node-polyfills": "^0.2.1",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-vue": "^6.0.0-beta.11"
}
}

View File

@ -1,53 +0,0 @@
import vuePlugin from 'rollup-plugin-vue'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import {terser} from 'rollup-plugin-terser'
import css from 'rollup-plugin-css-only'
import inject from '@rollup/plugin-inject'
import injectProcessEnv from 'rollup-plugin-inject-process-env'
import json from '@rollup/plugin-json'
import nodePolyfills from 'rollup-plugin-node-polyfills'
const production = !!process.env.PRODUCTION
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'static/bundle.js'
},
plugins: [
vuePlugin({
include: /\.html$/,
preprocessStyles: true
}),
commonjs(),
nodePolyfills(),
css({output: 'static/bundle.css'}),
json({
// exclude: '**/bip39/src/wordlists/!(english).json',
indent: ''
}),
resolve({
browser: true,
preferBuiltins: false
}),
inject({
Buffer: ['buffer', 'Buffer']
}),
injectProcessEnv({
NODE_ENV: production ? 'production' : 'development'
}),
production && terser()
]
}

View File

@ -1,55 +0,0 @@
<template>
<div class="nav">
<router-link to="/">Home</router-link>
<a
class="pubkey"
:href="'#/' + $store.getters.pubKeyHex"
:title="$store.getters.pubKeyHex"
>{{ $store.getters.pubKeyHex }}</a
>
<form @submit="submitSearch">
<input v-model="search" />
<button style="display: none">Search</button>
</form>
<router-link to="/setup">Setup</router-link>
</div>
<div>
<router-view />
</div>
</template>
<script>
export default {
data() {
return {search: ''}
},
methods: {
submitSearch(e) {
e.preventDefault()
this.$router.push('/' + this.search)
}
}
}
</script>
<style module>
.nav {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.nav > * {
margin: 0 10px;
}
.pubkey {
display: inline-block;
width: 160px;
text-overflow: ellipsis;
overflow: hidden;
}
a {
cursor: pointer;
color: blue;
text-decoration: underline;
}
</style>

View File

@ -1,33 +0,0 @@
<template>
<h1>Home</h1>
<div v-if="$store.state.following.length">
<h2>Following</h2>
<ul>
<li v-for="key in $store.state.following">
<a :href="'#/' + key">
<Name :pubkey="key" />
</a>
</li>
</ul>
</div>
<Publish />
<p>Relays: {{ relays }}</p>
<div v-if="$store.state.home.size === 0">
<p>Didn't find any notes to show.</p>
</div>
<List v-else :notes="$store.state.home.values()" />
</template>
<script>
import {pool} from './relay'
export default {
computed: {
relays() {
return Object.keys(pool.relays)
}
}
}
</script>
<style scoped></style>

View File

@ -1,19 +0,0 @@
<template>
<section class="notes">
<div v-for="note in notes">
<Note :note="note" :key="note.id" />
</div>
</section>
</template>
<script>
export default {
props: ['notes']
}
</script>
<style scoped>
.notes {
background: 'whitesmoke';
}
</style>

View File

@ -1,79 +0,0 @@
<template>
<div
v-if="pubkey"
:class="{Name: true, full}"
@click="full = expandable ? !full : false"
>
<span
class="petname"
:title="petnames[0].length > 1 ? 'hierarchical petname' : 'our name for this user'"
v-if="petnames.length"
>{{ petnames[0].join('.') }}</span
>
<span
class="petname"
title="hierarchical petname"
v-if="full && petnames.length > 1"
v-for="petname in petnames.slice(1)"
>{{ petname.join('.') }}</span
>
<span
class="self-given"
title="their own self-given name"
v-if="(!petnames.length || full) && selfGiven"
>{{ selfGiven }}</span
>
<span
class="abbr"
title="pubkey abbreviation"
v-if="petnames.length === 0 && !selfGiven"
>{{ abbr }}</span
>
<span v-if="$store.getters.pubKeyHex === pubkey"> (yourself)</span>
</div>
</template>
<script>
export default {
props: ['pubkey', 'expandable'],
data() {
return {
full: false
}
},
computed: {
petnames() {
return (this.$store.state.petnames[this.pubkey] || [])
.filter(name => !name[name.length - 1].match(/^[0-9a-f]{64}$/))
.sort((a, b) => b.length - a.length)
},
selfGiven() {
let theirOwnMetadata = this.$store.state.metadata.get(this.pubkey) || {}
return theirOwnMetadata.name
},
abbr() {
return this.pubkey.slice(0, 4) + '…' + this.pubkey.slice(-4)
}
}
}
</script>
<style>
.Name {
display: inline-block;
margin: 7px;
padding: 3px;
}
.Name.full {
display: block;
}
.Name .petname {
background-color: lightgreen;
}
.Name .self-given {
background-color: orange;
}
.Name .abbr {
background-color: lightgrey;
}
</style>

View File

@ -1,144 +0,0 @@
<template>
<article class="Note" :class="{ours: ours}">
<div v-if="reference" class="reference">
<Note :note="reference" />
</div>
<header class="pubkey">
<a :href="'#/' + pubkey">
<Name :pubkey="pubkey" />
</a>
</header>
<p>{{ content }}</p>
<footer>
<div>
<em>
<a :href="'#/n/' + id"
><time :datetime="isoDate(created_at)" :title="isoDate(created_at)"
>{{ humanDate(created_at) }}</time
></a
>
</em>
</div>
<div>
<a @click="referencing = !referencing">Quote/Reply</a>
</div>
<div v-if="ours">
<div
class="publish-status"
v-for="({time, status}, host) in ($store.state.publishStatus[id] || {})"
>
{{ host }}: {{ status }} @ {{ humanDate(time) }}
<button v-if="status == 'failed'" @click="republish">retry</button>
</div>
</div>
</footer>
<Publish
v-if="referencing"
:reference="id"
@publish="referencing = false"
/>
<List v-if="isFullPage" :notes="related" />
</article>
</template>
<script>
import prettydate from 'pretty-date'
import {pool} from './relay'
export default {
props: ['note'],
data() {
return {
referencing: false
}
},
computed: {
isFullPage() {
return !this.note
},
id() {
return this.note ? this.note.id : this.$route.params.id
},
event() {
return (
this.note || this.$store.state.browsing.get(this.id.slice(0, 5)) || {}
)
},
created_at() {
return this.event.created_at && new Date(this.event.created_at * 1000)
},
content() {
return this.event.content
},
pubkey() {
return this.event.pubkey
},
reference() {
if (this.isFullPage && this.event.ref) {
return this.$store.state.browsing.get(this.event.ref.slice(0, 5))
}
},
related() {
var rel = []
if (this.isFullPage) {
for (let k of this.$store.state.browsing.keys()) {
if (k.slice(0, 10) == 'rel:' + this.id.slice(0, 5) + ':') {
rel.push(this.$store.state.browsing.get(k))
}
}
}
return rel
},
ours() {
return this.pubkey === this.$store.getters.pubKeyHex
}
},
methods: {
isoDate(d) {
if (typeof d === 'number') d = new Date(d * 1000)
return d && d.toISOString()
},
humanDate(d) {
if (typeof d === 'number') d = new Date(d * 1000)
return d && prettydate.format(d)
},
republish(e) {
e.preventDefault()
pool.publish(this.note)
}
},
mounted() {
if (!this.note) {
pool.reqEvent({id: this.$route.params.id})
}
}
}
</script>
<style scoped>
.Note {
padding: 5px 10px;
margin: 5px 0;
border: 2px dotted;
}
.Note.ours {
background-color: orange;
}
.Note p {
margin: 0;
padding-left: 20px;
}
.Note .reference {
background-color: lightblue;
}
.Note .publish-status {
font-size: 0.5em;
}
.Note footer {
display: flex;
}
.Note footer > * {
margin: 0 10px;
}
</style>

View File

@ -1,139 +0,0 @@
<template>
<div class="Profile">
<code class="canonical-pubkey">{{ $route.params.key }}</code>
<h1>
<form v-if="editingName !== null" @submit="setContactName">
<label>
Petname:
<input v-model="editingName" />
(Will be made public)
</label>
<button>Save</button>
<button @click="editingName = null">Cancel</button>
</form>
<div v-else>
<Name :pubkey="this.$route.params.key" expandable />
<button
v-if="!myself"
@click="editingName = this.$store.getters.ourPetNameFor(this.$route.params.key) || ''"
>
Rename
</button>
</div>
</h1>
<div v-if="!myself">
<button v-if="following" @click="unfollow">Unfollow</button>
<button v-else @click="follow">Follow</button>
</div>
<article class="metadata">
<form v-if="editingMetadata" @submit="saveEditedMetadata">
<label>
Picture URL:
<input v-model="editingMetadata.picture" />
</label>
<label>
Name:
<input v-model="editingMetadata.name" />
</label>
About: <textarea v-model="editingMetadata.about"></textarea>
<div>
<button>Save</button>
<button @click="editingMetadata = null">Cancel</button>
</div>
</form>
<div v-else>
<img v-if="(metadata.picture || '').length" :src="metadata.picture" />
<h2>{{ metadata.name }}</h2>
<div>{{ metadata.about }}</div>
<button v-if="myself" @click="editingMetadata = {...metadata}">
Edit
</button>
</div>
</article>
<List :notes="notes" />
</div>
</template>
<script>
import {pool} from './relay'
export default {
data() {
return {
editingMetadata: null,
editingName: null
}
},
computed: {
myself() {
return this.$route.params.key === this.$store.getters.pubKeyHex
},
metadata() {
return this.$store.state.metadata.get(this.$route.params.key) || {}
},
following() {
return (
this.$store.state.following.indexOf(this.$route.params.key) !== -1
)
},
notes() {
var notes = []
for (let k of this.$store.state.browsing.keys()) {
if (
k.slice(0, 11) ===
'from:' + this.$route.params.key.slice(0, 5) + ':'
) {
let note = this.$store.state.browsing.get(k)
notes.push(note)
}
}
notes.sort((a, b) => b.created_at - a.created_at)
return notes
}
},
methods: {
follow(e) {
e.preventDefault()
this.$store.commit('follow', this.$route.params.key)
},
unfollow(e) {
e.preventDefault()
this.$store.commit('unfollow', this.$route.params.key)
},
saveEditedMetadata(e) {
e.preventDefault()
this.$store.dispatch('publishMetadata', this.editingMetadata)
this.editingMetadata = null
},
setContactName(e) {
e.preventDefault()
this.$store.dispatch('setContactName', {
pubkey: this.$route.params.key,
name: this.editingName
})
this.editingName = null
}
},
mounted() {
pool.reqKey({key: this.$route.params.key})
}
}
</script>
<style>
.Profile {
}
.Profile .metadata {
margin: 5px;
padding: 5px;
border: dotted 2px orange;
}
.Profile .canonical-pubkey {
display: block;
white-space: pre-wrap;
word-break: break-all;
margin: 26px;
text-decoration: underline;
width: 300px;
}
</style>

View File

@ -1,50 +0,0 @@
<template>
<form @submit="publishNote">
<legend>Publishing to {{ writeServersList }}</legend>
<label
>Write anything:
<input :disabled="publishing" v-model="text" />
</label>
<button :disabled="publishing">Publish</button>
</form>
</template>
<script>
import {pool} from './relay'
export default {
props: ['reference'],
data() {
return {
text: '',
publishing: false
}
},
computed: {
writeServersList() {
return Object.keys(pool.relays)
.filter(url => pool.relays[url].policy.write)
.join(' ')
.replace(/"/g, '')
}
},
methods: {
async publishNote(ev) {
ev.preventDefault()
this.publishing = true
try {
await this.$store.dispatch('publishNote', {
text: this.text,
reference: this.reference
})
this.text = ''
this.$emit('publish')
} catch (err) {
console.log('error publishing', err)
}
this.publishing = false
}
}
}
</script>

View File

@ -1,175 +0,0 @@
<template>
<section>
<b>Secret Key:</b>
<div v-if="typeof importing !== 'string'">
{{ $store.state.key }}
<button @click="importing = ''">
Import a secret key from elsewhere
</button>
</div>
<form v-else @submit="saveImported">
<input v-model="importing" />
<button :disabled="!isValidKey(importing)">Save</button>
<button @click="importing = null">Cancel</button>
</form>
</section>
<section>
<b>{{ showingIgnored ? 'Ignored relays' : 'Relays' }}:</b>
&nbsp;
<a v-if="Object.keys($store.state.ignoredRelays).length" @click="showingIgnored = !showingIgnored"
>show {{ showingIgnored ? 'active' : 'ignored' }}
</a>
<div v-if="showingIgnored" v-for="host in $store.state.ignoredRelays">
{{ host }}
<button @click="e => unignoreRelay(e, host)">Unignore</button>
</div>
<div v-else v-for="(relay, i) in (relays || []).concat(null)">
<form v-if="editing === i" @submit="saveRelay">
<label>
URL:
<input v-model="host" />
</label>
<label>
Read:
<input v-model="policy_r" type="checkbox" />
</label>
<label>
Write:
<input v-model="policy_w" type="checkbox" />
</label>
<button @click="cancelRelay">Cancel</button>
<button>Save</button>
</form>
<div v-else>
<span v-if="relay">
{{ relay.host }}: {{ relay.policy }}
<button @click="e => editRelay(e, i)">Edit</button>
<button @click="e => recommendRelay(e, relay.host)">Recommend</button>
<button @click="e => ignoreRelay(e, relay.host)">Ignore</button>
<span v-if="relay.recommender"
>recommended by:
<a :href="'#/' + relay.recommender">
<Name :pubkey="relay.recommender" /> </a
>&nbsp;</span
>
</span>
<span v-else>
<button @click="e => editRelay(e, i)">Add</button>
</span>
</div>
</div>
</section>
</template>
<script>
import {pool} from './relay'
export default {
data() {
return {
importing: null,
showingIgnored: false,
editing: null,
host: '',
policy_r: false,
policy_w: false
}
},
computed: {
relays() {
return Object.keys(pool.relays)
.map(relayURL => {
let relay = pool.relays[relayURL]
return {
host: relayURL,
policy: `${relay.policy.read ? 'r' : ''}${relay.policy.write ? 'w' : ''}`
}
})
.filter(({policy}) => policy !== 'i')
.sort((a, b) => {
let pa = a.policy
let pb = b.policy
if (pa === pb) return 0
if (pa === 'rw') return 1
if (pb === 'rw') return -1
if (pa === 'w') return 1
if (pb === 'w') return -1
return 0
})
},
},
methods: {
isValidKey(key) {
if (typeof key !== 'string') return false
if (key.length !== 64) return false
if (!/^[0-9a-f]+$/.exec(key.toLowerCase())) return false
return true
},
saveImported(e) {
e.preventDefault()
this.$store.dispatch('importSecretKey', this.importing)
this.importing = null
},
cancelRelay(e) {
e.preventDefault()
e.stopPropagation()
this.editing = null
},
editRelay(e, i) {
e.preventDefault()
if (i === this.relays.length) {
this.host = ''
this.policy_r = true
this.policy_w = true
} else {
this.host = this.relays[i].host
this.policy_r = this.relays[i].policy.indexOf('r') !== -1
this.policy_w = this.relays[i].policy.indexOf('w') !== -1
}
this.editing = i
},
async saveRelay(e) {
e.preventDefault()
let relay = {
host: this.host,
policy: (this.policy_r ? 'r' : '') + (this.policy_w ? 'w' : '')
}
if (this.editing === this.relays.length) {
await this.$store.dispatch('addRelay', relay)
} else if (this.editing >= 0) {
await this.$store.dispatch('updateRelay', {
...relay,
key: this.relays[this.editing].host
})
}
this.editing = null
this.$forceUpdate()
},
async recommendRelay(e, host) {
e.preventDefault()
this.$store.dispatch('recommendRelay', host)
this.$forceUpdate()
},
async ignoreRelay(e, host) {
e.preventDefault()
await this.$store.dispatch('updateRelay', {key: host, host, policy: 'i'})
this.$forceUpdate()
},
async unignoreRelay(e, host) {
e.preventDefault()
await this.$store.dispatch('updateRelay', {key: host, host, policy: ''})
this.$forceUpdate()
}
}
}
</script>
<style scoped>
section {
margin: 10px;
}
</style>

View File

@ -1,202 +0,0 @@
// vuex store actions
import {verifySignature} from 'nostr-tools'
import {parsePolicy, overwriteEvent} from './helpers'
import {
CONTEXT_NOW,
CONTEXT_REQUESTED,
CONTEXT_PAST,
KIND_METADATA,
KIND_TEXTNOTE,
KIND_RECOMMENDSERVER,
KIND_CONTACTLIST
} from './constants'
import {db} from './db'
import {pool} from './relay'
export default {
async importSecretKey(store, newKey) {
// save previous key in case the user wants it back later
var discardedSecretKeys = JSON.parse(
window.localStorage.getItem('discardedSecretKeys') || '[]'
)
discardedSecretKeys.push(store.state.key)
window.localStorage.setItem(
'discardedSecretKeys',
JSON.stringify(discardedSecretKeys)
)
pool.setPrivateKey(newKey)
// save new secret key
localStorage.setItem('key', newKey)
store.commit('setSecretKey', newKey)
},
async receivedEvent(store, {event, context}) {
if (!verifySignature(event)) {
console.log('received event with invalid signature', event)
return
}
switch (event.kind) {
case KIND_METADATA:
if (context === CONTEXT_REQUESTED) {
// just someone we're viewing
store.commit('receivedSetMetadata', {event, context})
} else if (context === CONTEXT_NOW) {
// an update from someone we follow that happened just now
store.commit('receivedSetMetadata', {event, context})
await db.events
.where({kind: KIND_METADATA, pubkey: event.pubkey})
.delete()
await db.events.put(event)
} else if (context === CONTEXT_PAST) {
// someone we follow, but an old update -- check first
// check first if we don't have a newer one
let foundNewer = await overwriteEvent(
{kind: KIND_METADATA, pubkey: event.pubkey},
event
)
if (!foundNewer) store.commit('receivedSetMetadata', {event, context})
}
break
case KIND_TEXTNOTE:
if (event.pubkey === store.getters.pubKeyHex) {
db.mynotes.put(event)
}
store.commit('receivedTextNote', {event, context})
break
case KIND_RECOMMENDSERVER:
let host = event.content
try {
new URL(host)
} catch (err) {
// ignore invalid URLs
return
}
// ignore if we already know this
// this prevents infinite loops and overwriting of our settings
if (await db.relays.get(host)) {
return
}
if (context === 'requested') {
// someone we're just browsing
await db.relays.put({
host,
policy: '',
recommender: event.pubkey
})
} else {
// someone we're following
await db.relays.put({
host,
policy: 'r',
recommender: event.pubkey
})
}
store.commit('loadedRelays', await db.relays.toArray())
break
case KIND_CONTACTLIST:
// if (!(event.pubkey in store.state.petnames)) {
// // we don't know this person, so we won't use their contact list
// return
// }
// check if we have a newest version already
let foundNewer = await overwriteEvent(
{pubkey: event.pubkey, kind: KIND_CONTACTLIST},
event
)
// process
if (!foundNewer) store.dispatch('processContactList', event)
break
}
},
async setContactName(store, {pubkey, name}) {
db.contactlist.put({pubkey, name})
store.commit('savePetName', {pubkey, name: [name]})
// publish our new contact list
pool.publish({
pubkey: store.getters.pubKeyHex,
created_at: Math.round(new Date().getTime() / 1000),
kind: KIND_CONTACTLIST,
content: JSON.stringify(
(await db.contactlist.toArray()).map(({pubkey, name}) => [pubkey, name])
)
})
},
processContactList(store, event) {
// parse event content
var entries = []
try {
entries = JSON.parse(event.content)
} catch (err) {
return
}
for (let i = 0; i < entries.length; i++) {
let [pubkey, name] = entries[i]
if (pubkey in store.state.petnames) {
// we have our own petname for this key already
continue
}
store.commit('savePetName', {pubkey, name: [name, event.pubkey]})
}
},
async addRelay(store, relay) {
await db.relays.put(relay)
pool.addRelay(relay.host, relay.policy)
},
async updateRelay(store, {key, host, policy}) {
let relay = {host, policy}
await db.relays.update(key, relay)
pool.removeRelay(host)
if (policy.length && policy.indexOf('i') !== 'i') {
pool.addRelay(host, parsePolicy(policy))
}
store.commit('unignoreRelay', host)
if (policy.indexOf('i') !== -1) {
store.commit('ignoreRelay', host)
}
},
async publishMetadata(store, meta) {
let event = await pool.publish({
pubkey: store.getters.pubKeyHex,
created_at: Math.round(new Date().getTime() / 1000),
kind: KIND_METADATA,
content: JSON.stringify(meta)
})
store.commit('receivedSetMetadata', {event, context: CONTEXT_NOW})
},
async publishNote(store, {text, reference}) {
let event = await pool.publish({
pubkey: store.getters.pubKeyHex,
created_at: Math.round(new Date().getTime() / 1000),
tags: reference ? [['e', reference, '']] : [],
kind: KIND_TEXTNOTE,
content: text.trim()
})
db.mynotes.put(event)
store.commit('receivedTextNote', {event, context: CONTEXT_NOW})
},
async recommendRelay(store, host) {
pool.publish({
pubkey: store.getters.pubKeyHex,
created_at: Math.round(new Date().getTime() / 1000),
kind: KIND_RECOMMENDSERVER,
content: host
})
}
}

View File

@ -1,8 +0,0 @@
export const KIND_METADATA = 0
export const KIND_TEXTNOTE = 1
export const KIND_RECOMMENDSERVER = 2
export const KIND_CONTACTLIST = 3
export const CONTEXT_REQUESTED = 'r'
export const CONTEXT_PAST = 'p'
export const CONTEXT_NOW = 'n'

View File

@ -1,26 +0,0 @@
import Dexie from 'dexie'
export const db = new Dexie('db')
db.version(1).stores({
// local personal settings and store
relays: 'host',
following: 'pubkey',
mynotes: 'id, kind, created_at',
publishlog: '[id+relay]',
// any special events from other people we may want to use later
// like metadata or contactlists from others, for example
// not simple notes or transient things
events: 'id, pubkey, kind',
// the set of local pet names and maybe other things we assign to others
contactlist: 'pubkey'
})
if (localStorage.getItem('deleted') < '5') {
db.delete().then(() => {
localStorage.setItem('deleted', '5')
location.reload()
})
}

View File

@ -1,29 +0,0 @@
import {db} from './db'
export async function overwriteEvent(conditions, event) {
let events = await db.events.where(conditions).toArray()
for (let i = 0; i < events.length; i++) {
// only save if it's newer than what we have
let evt = events[i]
if (evt.created_at > event.created_at) {
// we found a newer one
return true
}
// this is older, delete it
db.events.delete(evt.id)
}
// we didn't find a newer one
await db.events.put(event)
return false
}
export function parsePolicy(rw) {
var policy = {}
if (rw.indexOf('r') !== -1) policy.read = true
if (rw.indexOf('w') !== -1) policy.write = true
return policy
}

View File

@ -1,37 +0,0 @@
import {createApp} from 'vue/dist/vue.esm-bundler.js'
import {createRouter, createWebHashHistory} from 'vue-router'
import App from './App.html'
import Home from './Home.html'
import Setup from './Setup.html'
import Profile from './Profile.html'
import Note from './Note.html'
import List from './List.html'
import Publish from './Publish.html'
import Name from './Name.html'
import store from './store'
const router = createRouter({
history: createWebHashHistory(),
routes: [
{path: '/', component: Home},
{path: '/setup', component: Setup},
{path: '/:key', component: Profile},
{path: '/n/:id', component: Note}
]
})
const app = createApp({})
app.use(router)
app.use(store)
app.component('App', App)
app.component('Home', Home)
app.component('Setup', Setup)
app.component('Profile', Profile)
app.component('Note', Note)
app.component('List', List)
app.component('Publish', Publish)
app.component('Name', Name)
app.mount('#app')

View File

@ -1,115 +0,0 @@
// vuex store mutations
import {getPublicKey} from 'nostr-tools'
import {db} from './db'
import {CONTEXT_REQUESTED} from './constants'
export default {
setInit(state, {following, home, metadata, petnames}) {
state.following = following.concat(
// always be following thyself
getPublicKey(state.key)
)
state.home = home
for (let key in metadata) {
state.metadata.set(key, metadata[key])
}
state.petnames = petnames
},
gotEventSource(state) {
state.haveEventSource.resolve()
},
setSecretKey(state, newKey) {
state.key = newKey
},
follow(state, key) {
state.following.push(key)
db.following.put({pubkey: key})
},
unfollow(state, key) {
state.following.splice(state.following.indexOf(key), 1)
db.following.delete(key)
},
savePetName(state, payload) {
state.petnames[payload.pubkey] = state.petnames[payload.pubkey] || []
// maybe stop here if we already have enough names for <payload.pubkey>?
if (state.petnames[payload.pubkey].length > 4) {
return
}
if (payload.name.length === 2) {
// this is a hierarchy of type [<name>, <author-pubkey>]
if (payload.name[1] in state.petnames) {
// if we have any names for <author-pubkey>, replace them here
state.petnames[payload.name[1]].forEach(supername => {
state.petnames[payload.pubkey].push([payload.name[0], ...supername])
})
// and keep all references to [<name>, <replaced-hierarchy>]?
// maybe keeping just some is better?
state.petnames[payload.pubkey].sort((a, b) => a.length - b.length)
state.petnames = state.petnames.slice(0, 5)
} else {
// otherwise save this in raw form
state.petnames[payload.pubkey].push(payload.name)
}
} else if (payload.name.length === 1) {
// also save this in raw form if it's a single thing
state.petnames[payload.pubkey].push(payload.name)
}
// now search all our other names for any that uses <payload.pubkey>
for (let pubkey in state.petnames) {
if (pubkey === payload.pubkey) continue
let names = state.petnames[pubkey]
for (let i = 0; i < names.length; i++) {
let name = names[i]
if (name[name.length - 1] === payload.pubkey) {
// found one, replace it with the name we just got
names[i] = name.slice(0, -1).concat(payload.name)
}
}
}
// print this mess
console.log(state.petnames)
},
receivedSetMetadata(state, {event, context}) {
try {
let meta = JSON.parse(event.content)
state.metadata.set(event.pubkey, meta)
} catch (err) {}
},
receivedTextNote(state, {event: evt, context}) {
// all notes go to browsing
state.browsing.set(evt.id.slice(0, 5), evt)
state.browsing.set(
'from:' + evt.pubkey.slice(0, 5) + ':' + evt.id.slice(0, 5),
evt
)
if (evt.ref && evt.ref.length) {
state.browsing.set(
'rel:' + evt.ref.slice(0, 5) + ':' + evt.id.slice(0, 5),
evt
)
}
// only past and happening notes go to the main feed
if (context !== CONTEXT_REQUESTED) {
state.home.set(evt.id + ':' + evt.created_at, evt)
}
},
ignoreRelay(state, host) {
state.ignoredRelays[host] = true
},
unignoreRelay(state, host) {
delete state.ignoredRelays[host]
},
updatePublishStatus(state, {id, time, relay, status}) {
state.publishStatus = {
...state.publishStatus,
[id]: {...(state.publishStatus[id] || {}), [relay]: {time, status}}
}
}
}

View File

@ -1,72 +0,0 @@
import {relayPool} from 'nostr-tools'
import {db} from './db'
import {parsePolicy} from './helpers'
export const pool = relayPool()
const hardcodedRelays = [
{
host: 'https://nostr-relay.bigsun.xyz',
policy: 'rw'
}
]
export function relayStorePlugin(store) {
db.relays
.bulkPut(hardcodedRelays)
.then(() => db.relays.toArray())
.then(relays => {
relays.forEach(({host, policy}) => {
if (policy.indexOf('i') !== -1) {
store.commit('ignoreRelay', host)
}
let relay = pool.addRelay(host, parsePolicy(policy))
setTimeout(() => {
relay.reqFeed()
}, 1)
})
})
store.subscribe(mutation => {
switch (mutation.type) {
case 'setInit':
store.state.following.forEach(key => {
pool.subKey(key)
})
break
case 'follow':
pool.subKey(mutation.payload)
break
case 'unfollow':
pool.unsubKey(mutation.payload)
break
}
})
// setup event listener
pool.onEvent(async (event, context, {url: relayURL}) => {
store.dispatch('receivedEvent', {event, context})
// is this our note? mark it as published on this relay
if (await db.mynotes.get(event.id)) {
db.publishlog.put({
id: event.id,
time: Math.round(Date.now() / 1000),
relay: relayURL,
status: 'published'
})
}
})
// setup attempt status listener
pool.onAttempt((eventId, status, {url: relayURL}) => {
db.publishlog.put({
id: eventId,
time: Math.round(Date.now() / 1000),
relay: relayURL,
status
})
})
}

View File

@ -1,118 +0,0 @@
import {createStore, createLogger} from 'vuex'
import {SortedMap} from 'insort'
import LRU from 'quick-lru'
import {getPublicKey, makeRandom32} from 'nostr-tools'
import {db} from './db'
import actions from './actions'
import mutations from './mutations'
import {pool, relayStorePlugin} from './relay'
import {KIND_METADATA, KIND_CONTACTLIST} from './constants'
export default createStore({
plugins: (process.env.NODE_ENV !== 'production'
? [createLogger()]
: []
).concat([relayStorePlugin, init, publishStatusLoader]),
state() {
let secretKey = localStorage.getItem('key')
if (!secretKey) {
secretKey = Buffer.from(makeRandom32()).toString('hex')
localStorage.setItem('key', secretKey)
}
pool.setPrivateKey(secretKey)
return {
key: secretKey,
following: [],
home: new SortedMap(),
metadata: new LRU({maxSize: 100}),
browsing: new LRU({maxSize: 500}),
publishStatus: {},
petnames: {},
ignoredRelays: {},
}
},
getters: {
pubKeyHex: state => getPublicKey(state.key),
ourPetNameFor: state => pubkey => {
if (state.petnames[pubkey]) {
let single = state.petnames[pubkey].find(name => name.length === 1)
if (single) return single[0]
}
return null
}
},
mutations,
actions
})
async function init(store) {
let [following, home, metadata, petnames] = await Promise.all([
db.following.toArray().then(r => r.map(({pubkey}) => pubkey)),
db.mynotes
.orderBy('created_at')
.reverse()
.limit(30)
.toArray()
.then(notes => {
return new SortedMap(
notes.map(n => [n.id + ':' + n.created_at, n]),
(a, b) => b.split(':')[1] - a.split(':')[1]
)
}),
db.events
.where({kind: KIND_METADATA})
.toArray()
.then(events => {
var metadata = {}
events.forEach(({content, pubkey}) => {
let meta = JSON.parse(content)
metadata[pubkey] = meta
})
return metadata
}),
db.contactlist.toArray().then(contacts => {
var petnames = {}
contacts.forEach(({pubkey, name}) => {
petnames[pubkey] = [[name]]
})
return petnames
})
])
store.commit('setInit', {
following,
home,
metadata,
petnames
})
// process contact lists (nip-02)
let events = await db.events.where({kind: KIND_CONTACTLIST}).toArray()
for (let i = 0; i < events.length; i++) {
store.dispatch('processContactList', events[i])
}
}
function publishStatusLoader(store) {
db.publishlog.toArray().then(logs => {
logs.forEach(({id, time, relay, status}) => {
if (time < new Date().getTime() / 1000 - 60 * 60 * 24 * 30) {
// older than 30 days, delete and ignore
db.publishlog.delete([id, relay])
return
}
store.commit('updatePublishStatus', {id, time, relay, status})
})
})
db.publishlog.hook('creating', (_, {id, time, relay, status}) => {
store.commit('updatePublishStatus', {id, time, relay, status})
})
db.publishlog.hook('updating', (mod, _, prev) => {
let {id, time, relay, status} = {...prev, ...mod}
store.commit('updatePublishStatus', {id, time, relay, status})
})
}

View File

@ -1,15 +0,0 @@
<!DOCTYPE html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>nostr-web 📡</title>
<link rel="stylesheet" href="/bundle.css" />
<body>
<div id="app">
<App />
</div>
</body>
<script src="/bundle.js"></script>