package nostr import ( "errors" "strconv" "strings" ) // Filter is a NIP-01 subscription filter. // // Zero values mean "unset" for Since/Until/Limit; empty slices mean "no // constraint". Tags is keyed by the single-letter tag name (the filter key // "#e" is stored as "e"). type Filter struct { IDs []string Authors []string Kinds []int Tags map[string][]string Since int64 Until int64 Limit int Search string // Graph is the optional _graph filter extension. When set the filter is a // graph traversal request rather than a NIP-01 event query. Graph *GraphQuery } var errBadFilter = errors.New("nostr: malformed filter") // Matches applies the filter to an event. The Search term is not evaluated // here: the store resolves it through the word index and only then calls // Matches on the candidates. func (f *Filter) Matches(e *Event) bool { if len(f.IDs) > 0 && !containsString(f.IDs, Hex(e.ID[:])) { return false } if len(f.Authors) > 0 && !containsString(f.Authors, Hex(e.PubKey[:])) { return false } if len(f.Kinds) > 0 && !containsInt(f.Kinds, e.Kind) { return false } if f.Since != 0 && e.CreatedAt < f.Since { return false } if f.Until != 0 && e.CreatedAt > f.Until { return false } for name, values := range f.Tags { if len(name) != 1 { // NIP-01 only defines single-letter tag filters. A multi-letter // filter can never be satisfied by the index, so it cannot match. return false } matched := false for _, t := range e.Tags { if t.Name() != name || len(t) < 2 { continue } if containsString(values, t.Value()) { matched = true break } } if !matched { return false } } return true } // Selectivity returns a rough ordering hint used to choose a driving index: // higher means the filter constrains more. func (f *Filter) HasTagFilters() bool { return len(f.Tags) > 0 } // SingleLetterTags returns the tag filters as a flat list of (name, values). func (f *Filter) SingleLetterTags() map[string][]string { return f.Tags } func containsString(haystack []string, needle string) bool { for _, s := range haystack { if s == needle { return true } } return false } func containsInt(haystack []int, needle int) bool { for _, n := range haystack { if n == needle { return true } } return false } // AppendJSON appends the filter's JSON form (used by tests and diagnostics). func (f *Filter) AppendJSON(dst []byte) []byte { dst = append(dst, '{') first := true add := func() { if !first { dst = append(dst, ',') } first = false } if len(f.IDs) > 0 { add() dst = append(dst, `"ids":`...) dst = appendStringArray(dst, f.IDs) } if len(f.Authors) > 0 { add() dst = append(dst, `"authors":`...) dst = appendStringArray(dst, f.Authors) } if len(f.Kinds) > 0 { add() dst = append(dst, `"kinds":[`...) for i, k := range f.Kinds { if i > 0 { dst = append(dst, ',') } dst = strconv.AppendInt(dst, int64(k), 10) } dst = append(dst, ']') } for name, values := range f.Tags { add() dst = append(dst, `"#`...) dst = append(dst, name...) dst = append(dst, `":`...) dst = appendStringArray(dst, values) } if f.Since != 0 { add() dst = append(dst, `"since":`...) dst = strconv.AppendInt(dst, f.Since, 10) } if f.Until != 0 { add() dst = append(dst, `"until":`...) dst = strconv.AppendInt(dst, f.Until, 10) } if f.Limit != 0 { add() dst = append(dst, `"limit":`...) dst = strconv.AppendInt(dst, int64(f.Limit), 10) } if f.Search != "" { add() dst = append(dst, `"search":`...) dst = AppendJSONString(dst, f.Search) } if f.Graph != nil { add() dst = append(dst, `"_graph":[`...) dst = AppendJSONString(dst, f.Graph.Seed) dst = append(dst, ',') dst = strconv.AppendInt(dst, int64(f.Graph.Depth), 10) dst = append(dst, ',') dst = AppendJSONString(dst, f.Graph.Edge) dst = append(dst, ',') dst = AppendJSONString(dst, f.Graph.Direction) dst = append(dst, ']') } return append(dst, '}') } func appendStringArray(dst []byte, values []string) []byte { dst = append(dst, '[') for i, v := range values { if i > 0 { dst = append(dst, ',') } dst = AppendJSONString(dst, v) } return append(dst, ']') } // String renders the filter for logs. func (f *Filter) String() string { return string(f.AppendJSON(nil)) } // TagsKey normalizes a filter tag key: callers may pass "#e" or "e". func TagsKey(key string) string { return strings.TrimPrefix(key, "#") } // Clone returns a deep copy so that a filter can be handed to another domain // without either side sharing slice or map storage. func (f *Filter) Clone() Filter { out := *f out.IDs = append([]string(nil), f.IDs...) out.Authors = append([]string(nil), f.Authors...) out.Kinds = append([]int(nil), f.Kinds...) if f.Tags != nil { out.Tags = make(map[string][]string, len(f.Tags)) for k, values := range f.Tags { out.Tags[k] = append([]string(nil), values...) } } if f.Graph != nil { q := *f.Graph out.Graph = &q } return out }