From c075684e65e1942d28bfe8a06e19bf7116c203bd Mon Sep 17 00:00:00 2001 From: fiatjaf Date: Mon, 2 May 2022 16:55:39 -0300 Subject: [PATCH] add closedrelay: like basic, but only allows authorized pubkeys to post. --- closedrelay/.gitignore | 1 + closedrelay/Makefile | 2 + closedrelay/README | 24 ++++++ closedrelay/main.go | 43 +++++++++++ closedrelay/postgresql.go | 42 ++++++++++ closedrelay/query.go | 158 ++++++++++++++++++++++++++++++++++++++ closedrelay/save.go | 58 ++++++++++++++ 7 files changed, 328 insertions(+) create mode 100644 closedrelay/.gitignore create mode 100644 closedrelay/Makefile create mode 100644 closedrelay/README create mode 100644 closedrelay/main.go create mode 100644 closedrelay/postgresql.go create mode 100644 closedrelay/query.go create mode 100644 closedrelay/save.go diff --git a/closedrelay/.gitignore b/closedrelay/.gitignore new file mode 100644 index 0000000..4f201ab --- /dev/null +++ b/closedrelay/.gitignore @@ -0,0 +1 @@ +closedrelay diff --git a/closedrelay/Makefile b/closedrelay/Makefile new file mode 100644 index 0000000..28cd63e --- /dev/null +++ b/closedrelay/Makefile @@ -0,0 +1,2 @@ +closedrelay: $(shell find .. -name "*.go") + go build -ldflags="-s -w" -o ./closedrelay diff --git a/closedrelay/README b/closedrelay/README new file mode 100644 index 0000000..57988e5 --- /dev/null +++ b/closedrelay/README @@ -0,0 +1,24 @@ +closed relay +============ + + - a basic relay implementation based on relayer. + - uses postgres, which I think must be over version 12 since it uses generated columns. + - only accepts events from specific pubkeys defined via the environment variable `AUTHORIZED_PUBKEYS` (comma-separated). + +running +------- + +grab a binary from the releases page and run it with the environment variable POSTGRESQL_DATABASE set to some postgres url: + + POSTGRESQL_DATABASE=postgres://name:pass@localhost:5432/dbname ./closedrelay + +it also accepts a HOST and a PORT environment variables. + +compiling +--------- + +if you know Go you already know this: + + go install github.com/fiatjaf/relayer/closedrelay + +or something like that. diff --git a/closedrelay/main.go b/closedrelay/main.go new file mode 100644 index 0000000..5419740 --- /dev/null +++ b/closedrelay/main.go @@ -0,0 +1,43 @@ +package main + +import ( + "fmt" + + "github.com/fiatjaf/relayer" + "github.com/jmoiron/sqlx" + "github.com/jmoiron/sqlx/reflectx" + "github.com/kelseyhightower/envconfig" +) + +type ClosedRelay struct { + PostgresDatabase string `envconfig:"POSTGRESQL_DATABASE"` + AuthorizedPubkeys []string `envconfig:"AUTHORIZED_PUBKEYS"` + + DB *sqlx.DB +} + +func (b *ClosedRelay) Name() string { + return "ClosedRelay" +} + +func (b *ClosedRelay) Init() error { + err := envconfig.Process("", b) + if err != nil { + return fmt.Errorf("couldn't process envconfig: %w", err) + } + + if db, err := initDB(b.PostgresDatabase); err != nil { + return fmt.Errorf("failed to open database: %w", err) + } else { + db.Mapper = reflectx.NewMapperFunc("json", sqlx.NameMapper) + b.DB = db + } + + return nil +} + +func main() { + var b ClosedRelay + + relayer.Start(&b) +} diff --git a/closedrelay/postgresql.go b/closedrelay/postgresql.go new file mode 100644 index 0000000..c804347 --- /dev/null +++ b/closedrelay/postgresql.go @@ -0,0 +1,42 @@ +package main + +import ( + "github.com/fiatjaf/relayer" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" +) + +func initDB(dburl string) (*sqlx.DB, error) { + db, err := sqlx.Connect("postgres", dburl) + if err != nil { + return nil, err + } + + _, err = db.Exec(` +CREATE FUNCTION tags_to_tagvalues(jsonb) RETURNS text[] + AS 'SELECT array_agg(t->>1) FROM (SELECT jsonb_array_elements($1) AS t)s;' + LANGUAGE SQL + IMMUTABLE + RETURNS NULL ON NULL INPUT; + +CREATE TABLE IF NOT EXISTS 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, + + tagvalues text[] GENERATED ALWAYS AS (tags_to_tagvalues(tags)) STORED +); + +CREATE UNIQUE INDEX IF NOT EXISTS ididx ON event USING btree (id text_pattern_ops); +CREATE INDEX IF NOT EXISTS pubkeyprefix ON event USING btree (pubkey text_pattern_ops); +CREATE INDEX IF NOT EXISTS timeidx ON event (created_at); +CREATE INDEX IF NOT EXISTS kindidx ON event (kind); +CREATE INDEX IF NOT EXISTS arbitrarytagvalues ON event USING gin (tagvalues); + `) + relayer.Log.Print(err) + return db, nil +} diff --git a/closedrelay/query.go b/closedrelay/query.go new file mode 100644 index 0000000..3deb776 --- /dev/null +++ b/closedrelay/query.go @@ -0,0 +1,158 @@ +package main + +import ( + "database/sql" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/fiatjaf/go-nostr" + "github.com/rs/zerolog/log" +) + +func (b *ClosedRelay) QueryEvents(filter *nostr.Filter) (events []nostr.Event, err error) { + var conditions []string + var params []interface{} + + if filter == nil { + err = errors.New("filter cannot be null") + return + } + + if filter.IDs != nil { + if len(filter.IDs) > 500 { + // too many ids, fail everything + return + } + + likeids := make([]string, 0, len(filter.IDs)) + for _, id := range filter.IDs { + // to prevent sql attack here we will check if + // these ids are valid 32byte hex + parsed, err := hex.DecodeString(id) + if err != nil || len(parsed) <= 32 { + continue + } + likeids = append(likeids, fmt.Sprintf("id LIKE '%x%%'", parsed)) + } + if len(likeids) == 0 { + // ids being [] mean you won't get anything + return + } + conditions = append(conditions, "("+strings.Join(likeids, " OR ")+")") + } + + if filter.Authors != nil { + if len(filter.Authors) > 500 { + // too many authors, fail everything + return + } + + likekeys := make([]string, 0, len(filter.Authors)) + for _, key := range filter.Authors { + // 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 + } + likekeys = append(likekeys, fmt.Sprintf("pubkey LIKE '%x%%'", parsed)) + } + if len(likekeys) == 0 { + // authors being [] mean you won't get anything + return + } + conditions = append(conditions, "("+strings.Join(likekeys, " OR ")+")") + } + + if filter.Kinds != nil { + if len(filter.Kinds) > 10 { + // too many kinds, fail everything + return + } + + if len(filter.Kinds) == 0 { + // kinds being [] mean you won't get anything + return + } + // no sql injection issues since these are ints + inkinds := make([]string, len(filter.Kinds)) + for i, kind := range filter.Kinds { + inkinds[i] = strconv.Itoa(kind) + } + conditions = append(conditions, `kind IN (`+strings.Join(inkinds, ",")+`)`) + } + + tagQuery := make([]string, 0, 1) + for _, values := range filter.Tags { + if len(values) == 0 { + // any tag set to [] is wrong + return + } + + // add these tags to the query + tagQuery = append(tagQuery, values...) + + if len(tagQuery) > 10 { + // too many tags, fail everything + return + } + } + + if len(tagQuery) > 0 { + arrayBuild := make([]string, len(tagQuery)) + for i, tagValue := range tagQuery { + arrayBuild[i] = "?" + params = append(params, tagValue) + } + + // we use a very bad implementation in which we only check the tag values and + // ignore the tag names + conditions = append(conditions, + "tagvalues && ARRAY["+strings.Join(arrayBuild, ",")+"]") + } + + if filter.Since != nil { + conditions = append(conditions, "created_at > ?") + params = append(params, filter.Since.Unix()) + } + if filter.Until != nil { + conditions = append(conditions, "created_at < ?") + params = append(params, filter.Until.Unix()) + } + + if len(conditions) == 0 { + // fallback + conditions = append(conditions, "true") + } + + query := b.DB.Rebind(`SELECT + id, pubkey, created_at, kind, tags, content, sig + FROM event WHERE ` + + strings.Join(conditions, " AND ") + + " ORDER BY created_at LIMIT 100") + + rows, err := b.DB.Query(query, params...) + if err != nil && err != sql.ErrNoRows { + log.Warn().Err(err).Interface("filter", filter).Str("query", query). + Msg("failed to fetch events") + return nil, fmt.Errorf("failed to fetch events: %w", err) + } + + for rows.Next() { + var evt nostr.Event + var timestamp int64 + err := rows.Scan(&evt.ID, &evt.PubKey, ×tamp, + &evt.Kind, &evt.Tags, &evt.Content, &evt.Sig) + if err != nil { + return nil, fmt.Errorf("failed to scan row: %w", err) + } + evt.CreatedAt = time.Unix(timestamp, 0) + events = append(events, evt) + } + + return events, nil +} diff --git a/closedrelay/save.go b/closedrelay/save.go new file mode 100644 index 0000000..bd61d47 --- /dev/null +++ b/closedrelay/save.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/fiatjaf/go-nostr" +) + +func (b *ClosedRelay) SaveEvent(evt *nostr.Event) error { + // disallow anything from non-authorized pubkeys + for _, pubkey := range b.AuthorizedPubkeys { + if pubkey == evt.PubKey { + goto save + } + } + return fmt.Errorf("event from '%s' not allowed here", evt.PubKey) + +save: + // react to different kinds of events + switch evt.Kind { + case nostr.KindSetMetadata: + // delete past set_metadata events from this user + b.DB.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 0`, evt.PubKey) + case nostr.KindRecommendServer: + // delete past recommend_server events equal to this one + b.DB.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 2 AND content = $2`, + evt.PubKey, evt.Content) + case nostr.KindContactList: + // delete past contact lists from this same pubkey + b.DB.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = 3`, evt.PubKey) + default: + // delete all but the 10 most recent ones + b.DB.Exec(`DELETE FROM event WHERE pubkey = $1 AND kind = $2 AND created_at < ( + SELECT created_at FROM event WHERE pubkey = $1 + ORDER BY created_at DESC OFFSET 10 LIMIT 1 + )`, + evt.PubKey, evt.Kind) + } + + // insert + tagsj, _ := json.Marshal(evt.Tags) + _, err := b.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.Unix(), evt.Kind, tagsj, evt.Content, evt.Sig) + if err != nil { + if strings.Index(err.Error(), "UNIQUE") != -1 { + // already exists + return nil + } + + return fmt.Errorf("failed to save event %s: %w", evt.ID, err) + } + + return nil +}