diff --git a/go.mod b/go.mod index d0a66be..a7e926d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/fiatjaf/eventstore v0.16.2 github.com/fiatjaf/khatru v0.19.1 + github.com/jmoiron/sqlx v1.4.0 github.com/lib/pq v1.10.9 github.com/nbd-wtf/go-nostr v0.52.3 ) @@ -23,7 +24,6 @@ require ( github.com/decred/dcrd/crypto/blake256 v1.1.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/fasthttp/websocket v1.5.12 // indirect - github.com/jmoiron/sqlx v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.0 // indirect diff --git a/main.go b/main.go index e451bb5..e169a75 100644 --- a/main.go +++ b/main.go @@ -64,7 +64,7 @@ func main() { relay.Info.Software = "https://github.com/layer-systems/relay" relay.Info.Version = "0.1.1" - relay.Info.SupportedNIPs = []any{1, 11, 17, 40, 42, 46, 70, 86} + relay.Info.SupportedNIPs = []any{1, 11, 17, 40, 42, 46, 50, 70, 86} // Open shared database connection with aggressive pool limits managementDB, err := sql.Open("postgres", getEnv("DATABASE_URL", "postgresql://postgres:postgres@db:5432/khatru-relay?sslmode=disable")) @@ -92,8 +92,8 @@ func main() { } relay.StoreEvent = append(relay.StoreEvent, db.SaveEvent) - relay.QueryEvents = append(relay.QueryEvents, db.QueryEvents) - relay.CountEvents = append(relay.CountEvents, db.CountEvents) + relay.QueryEvents = append(relay.QueryEvents, queryEventsWithSearch(&db)) + relay.CountEvents = append(relay.CountEvents, countEventsWithSearch(&db)) relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent) relay.ReplaceEvent = append(relay.ReplaceEvent, db.ReplaceEvent) diff --git a/search.go b/search.go new file mode 100644 index 0000000..f8a358e --- /dev/null +++ b/search.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/fiatjaf/eventstore/postgresql" + "github.com/jmoiron/sqlx" + "github.com/nbd-wtf/go-nostr" +) + +func queryEventsWithSearch(db *postgresql.PostgresBackend) func(context.Context, nostr.Filter) (chan *nostr.Event, error) { + return func(ctx context.Context, filter nostr.Filter) (chan *nostr.Event, error) { + if strings.TrimSpace(filter.Search) == "" { + return db.QueryEvents(ctx, filter) + } + + query, params, err := searchEventsSQL(db, filter, false) + if err != nil { + return nil, err + } + + rows, err := db.DB.QueryContext(ctx, query, params...) + if err != nil && err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to fetch search results using query %q: %w", query, err) + } + + ch := make(chan *nostr.Event) + go func() { + defer rows.Close() + defer close(ch) + + for rows.Next() { + var evt nostr.Event + var timestamp int64 + if err := rows.Scan(&evt.ID, &evt.PubKey, ×tamp, &evt.Kind, &evt.Tags, &evt.Content, &evt.Sig); err != nil { + return + } + evt.CreatedAt = nostr.Timestamp(timestamp) + + select { + case ch <- &evt: + case <-ctx.Done(): + return + } + } + }() + + return ch, nil + } +} + +func countEventsWithSearch(db *postgresql.PostgresBackend) func(context.Context, nostr.Filter) (int64, error) { + return func(ctx context.Context, filter nostr.Filter) (int64, error) { + if strings.TrimSpace(filter.Search) == "" { + return db.CountEvents(ctx, filter) + } + + query, params, err := searchEventsSQL(db, filter, true) + if err != nil { + return 0, err + } + + var count int64 + if err = db.DB.QueryRowContext(ctx, query, params...).Scan(&count); err != nil && err != sql.ErrNoRows { + return 0, fmt.Errorf("failed to count search results using query %q: %w", query, err) + } + return count, nil + } +} + +func searchEventsSQL(db *postgresql.PostgresBackend, filter nostr.Filter, doCount bool) (string, []any, error) { + conditions := make([]string, 0, 8) + params := make([]any, 0, 32) + + if len(filter.IDs) > 0 { + if len(filter.IDs) > db.QueryIDsLimit { + return "", nil, postgresql.TooManyIDs + } + for _, id := range filter.IDs { + params = append(params, id) + } + conditions = append(conditions, `id IN (`+placeholders(len(filter.IDs))+`)`) + } + + if len(filter.Authors) > 0 { + if len(filter.Authors) > db.QueryAuthorsLimit { + return "", nil, postgresql.TooManyAuthors + } + for _, author := range filter.Authors { + params = append(params, author) + } + conditions = append(conditions, `pubkey IN (`+placeholders(len(filter.Authors))+`)`) + } + + if len(filter.Kinds) > 0 { + if len(filter.Kinds) > db.QueryKindsLimit { + return "", nil, postgresql.TooManyKinds + } + for _, kind := range filter.Kinds { + params = append(params, kind) + } + conditions = append(conditions, `kind IN (`+placeholders(len(filter.Kinds))+`)`) + } + + totalTags := 0 + for _, values := range filter.Tags { + if len(values) == 0 { + return "", nil, postgresql.EmptyTagSet + } + for _, tagValue := range values { + params = append(params, tagValue) + } + conditions = append(conditions, `tagvalues && ARRAY[`+placeholders(len(values))+`]`) + + totalTags += len(values) + if totalTags > db.QueryTagsLimit { + return "", nil, postgresql.TooManyTagValues + } + } + + if filter.Since != nil { + conditions = append(conditions, `created_at >= ?`) + params = append(params, int64(*filter.Since)) + } + if filter.Until != nil { + conditions = append(conditions, `created_at <= ?`) + params = append(params, int64(*filter.Until)) + } + + phrase, terms := normalizeSearchQuery(filter.Search) + searchConditions := make([]string, 0, len(terms)+1) + scoreParts := make([]string, 0, len(terms)+1) + if phrase != "" { + searchConditions = append(searchConditions, `content ILIKE ? ESCAPE '\'`) + scoreParts = append(scoreParts, `CASE WHEN content ILIKE ? ESCAPE '\' THEN 100 ELSE 0 END`) + params = append(params, likePattern(phrase)) + } + for _, term := range terms { + searchConditions = append(searchConditions, `content ILIKE ? ESCAPE '\'`) + scoreParts = append(scoreParts, `CASE WHEN content ILIKE ? ESCAPE '\' THEN 1 ELSE 0 END`) + params = append(params, likePattern(term)) + } + if len(searchConditions) > 0 { + conditions = append(conditions, `(`+strings.Join(searchConditions, " OR ")+`)`) + } + + if len(conditions) == 0 { + conditions = append(conditions, `true`) + } + + limit := db.QueryLimit + if filter.Limit > 0 && filter.Limit <= db.QueryLimit { + limit = filter.Limit + } + + var query string + if doCount { + query = `SELECT COUNT(*) FROM event WHERE ` + strings.Join(conditions, " AND ") + } else { + score := "0" + if len(scoreParts) > 0 { + score = strings.Join(scoreParts, " + ") + if phrase != "" { + params = append(params, likePattern(phrase)) + } + for _, term := range terms { + params = append(params, likePattern(term)) + } + } + params = append(params, limit) + query = `SELECT id, pubkey, created_at, kind, tags, content, sig FROM event WHERE ` + + strings.Join(conditions, " AND ") + + ` ORDER BY (` + score + `) DESC, created_at DESC, id LIMIT ?` + } + + return sqlx.Rebind(sqlx.BindType("postgres"), query), params, nil +} + +func normalizeSearchQuery(search string) (string, []string) { + fields := strings.Fields(search) + terms := make([]string, 0, len(fields)) + for _, field := range fields { + if strings.Contains(field, ":") { + continue + } + terms = append(terms, field) + } + + return strings.Join(terms, " "), terms +} + +func likePattern(value string) string { + replacer := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`) + return "%" + replacer.Replace(value) + "%" +} + +func placeholders(n int) string { + return strings.TrimRight(strings.Repeat("?,", n), ",") +} diff --git a/search_test.go b/search_test.go new file mode 100644 index 0000000..9ce1388 --- /dev/null +++ b/search_test.go @@ -0,0 +1,61 @@ +package main + +import ( + "reflect" + "strings" + "testing" + + "github.com/fiatjaf/eventstore/postgresql" + "github.com/nbd-wtf/go-nostr" +) + +func TestNormalizeSearchQueryIgnoresExtensions(t *testing.T) { + phrase, terms := normalizeSearchQuery("include:spam best nostr apps domain:example.com") + + if phrase != "best nostr apps" { + t.Fatalf("phrase = %q, want %q", phrase, "best nostr apps") + } + if !reflect.DeepEqual(terms, []string{"best", "nostr", "apps"}) { + t.Fatalf("terms = %#v", terms) + } +} + +func TestSearchEventsSQLOrdersByScoreBeforeLimit(t *testing.T) { + db := &postgresql.PostgresBackend{ + QueryLimit: 100, + QueryIDsLimit: 500, + QueryAuthorsLimit: 500, + QueryKindsLimit: 10, + QueryTagsLimit: 10, + } + + query, params, err := searchEventsSQL(db, nostr.Filter{ + Kinds: []int{nostr.KindTextNote}, + Search: "include:spam Purple nostr", + Limit: 5, + }, false) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(query, "ORDER BY (CASE WHEN content ILIKE") { + t.Fatalf("query does not order by score: %s", query) + } + if !strings.Contains(query, "DESC, created_at DESC, id LIMIT") { + t.Fatalf("query does not apply limit after score ordering: %s", query) + } + + expectedParams := []any{ + nostr.KindTextNote, + "%Purple nostr%", + "%Purple%", + "%nostr%", + "%Purple nostr%", + "%Purple%", + "%nostr%", + 5, + } + if !reflect.DeepEqual(params, expectedParams) { + t.Fatalf("params = %#v, want %#v", params, expectedParams) + } +}