| 1 | package nostr |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | ) |
| 8 | |
| 9 | // Filter describes the event fields and tags a subscription or query accepts. |
| 10 | // Fields combine with AND; values within a list are alternatives. Nil IDs, |
| 11 | // Authors and Kinds leave that field unrestricted, while an empty non-nil list |
| 12 | // matches nothing. Tags uses names without the wire prefix #. Since and Until |
| 13 | // are inclusive Unix timestamps. Limit controls a historical result batch; |
| 14 | // Matches evaluates each event independently of that limit. |
| 15 | type Filter struct { |
| 16 | IDs []string `json:"ids,omitempty"` |
| 17 | Authors []string `json:"authors,omitempty"` |
| 18 | Kinds []int `json:"kinds,omitempty"` |
| 19 | Tags map[string][]string `json:"-"` |
| 20 | Since *int64 `json:"since,omitempty"` |
| 21 | Until *int64 `json:"until,omitempty"` |
| 22 | Limit *int `json:"limit,omitempty"` |
| 23 | Search string `json:"search,omitempty"` |
| 24 | } |
| 25 | |
| 26 | // ParseFilter decodes supported Nostr filter fields, including #tag keys, and |
| 27 | // checks their JSON types. |
| 28 | func ParseFilter(raw []byte) (Filter, error) { |
| 29 | var values map[string]json.RawMessage |
| 30 | if err := json.Unmarshal(raw, &values); err != nil { |
| 31 | return Filter{}, errorsFilter("filter must be an object" ) |
| 32 | } |
| 33 | f := Filter{Tags: make(map[string][]string)} |
| 34 | for key, value := range values { |
| 35 | switch { |
| 36 | case key == "ids" , key == "authors" : |
| 37 | if !isJSONArray(value) { |
| 38 | return Filter{}, errorsFilter(key + " must be a list of strings" ) |
| 39 | } |
| 40 | var out []string |
| 41 | if err := json.Unmarshal(value, &out); err != nil { |
| 42 | return Filter{}, errorsFilter(key + " must be a list of strings" ) |
| 43 | } |
| 44 | if key == "ids" { |
| 45 | f.IDs = out |
| 46 | } else { |
| 47 | f.Authors = out |
| 48 | } |
| 49 | case key == "kinds" : |
| 50 | if !isJSONArray(value) { |
| 51 | return Filter{}, errorsFilter("kinds must be a list of integers" ) |
| 52 | } |
| 53 | if err := json.Unmarshal(value, &f.Kinds); err != nil { |
| 54 | return Filter{}, errorsFilter("kinds must be a list of integers" ) |
| 55 | } |
| 56 | case key == "since" , key == "until" : |
| 57 | var n int64 |
| 58 | if err := json.Unmarshal(value, &n); err != nil { |
| 59 | return Filter{}, errorsFilter(key + " must be an integer" ) |
| 60 | } |
| 61 | if key == "since" { |
| 62 | f.Since = &n |
| 63 | } else { |
| 64 | f.Until = &n |
| 65 | } |
| 66 | case key == "limit" : |
| 67 | var n int |
| 68 | if err := json.Unmarshal(value, &n); err != nil { |
| 69 | return Filter{}, errorsFilter("limit must be an integer" ) |
| 70 | } |
| 71 | f.Limit = &n |
| 72 | case key == "search" : |
| 73 | if err := json.Unmarshal(value, &f.Search); err != nil { |
| 74 | return Filter{}, errorsFilter("search must be a string" ) |
| 75 | } |
| 76 | case len(key) == 2 && key[0] == '#' : |
| 77 | if !isJSONArray(value) { |
| 78 | return Filter{}, errorsFilter(key + " must be a list of strings" ) |
| 79 | } |
| 80 | var out []string |
| 81 | if err := json.Unmarshal(value, &out); err != nil { |
| 82 | return Filter{}, errorsFilter(key + " must be a list of strings" ) |
| 83 | } |
| 84 | f.Tags[key[1:]] = out |
| 85 | } |
| 86 | } |
| 87 | return f, nil |
| 88 | } |
| 89 | |
| 90 | // MarshalJSON encodes tag constraints with their Nostr #name wire keys. |
| 91 | func (f Filter) MarshalJSON() ([]byte, error) { |
| 92 | values := make(map[string]json.RawMessage, 7+len(f.Tags)) |
| 93 | if f.IDs != nil { |
| 94 | values["ids" ] = mustRaw(f.IDs) |
| 95 | } |
| 96 | if f.Authors != nil { |
| 97 | values["authors" ] = mustRaw(f.Authors) |
| 98 | } |
| 99 | if f.Kinds != nil { |
| 100 | values["kinds" ] = mustRaw(f.Kinds) |
| 101 | } |
| 102 | if f.Since != nil { |
| 103 | values["since" ] = mustRaw(f.Since) |
| 104 | } |
| 105 | if f.Until != nil { |
| 106 | values["until" ] = mustRaw(f.Until) |
| 107 | } |
| 108 | if f.Limit != nil { |
| 109 | values["limit" ] = mustRaw(f.Limit) |
| 110 | } |
| 111 | if f.Search != "" { |
| 112 | values["search" ] = mustRaw(f.Search) |
| 113 | } |
| 114 | for key, tags := range f.Tags { |
| 115 | values["#" +key] = mustRaw(tags) |
| 116 | } |
| 117 | return json.Marshal(values) |
| 118 | } |
| 119 | |
| 120 | // Matches reports whether e satisfies every populated constraint in f. |
| 121 | // Search terms are matched against content without classifying event kinds. |
| 122 | // Callers must enforce visibility and authorization before returning matches. |
| 123 | func Matches(f Filter, e Event) bool { |
| 124 | if f.IDs != nil && !contains(f.IDs, e.ID) || f.Authors != nil && !contains(f.Authors, e.PubKey) || f.Kinds != nil && !containsInt(f.Kinds, e.Kind) { |
| 125 | return false |
| 126 | } |
| 127 | if f.Since != nil && e.CreatedAt < *f.Since || f.Until != nil && e.CreatedAt > *f.Until { |
| 128 | return false |
| 129 | } |
| 130 | for name, values := range f.Tags { |
| 131 | if !tagMatch(e, name, values) { |
| 132 | return false |
| 133 | } |
| 134 | } |
| 135 | terms := SearchTerms(f.Search) |
| 136 | if len(terms) == 0 { |
| 137 | return true |
| 138 | } |
| 139 | |
| 140 | content := strings.ToLower(e.Content) |
| 141 | for _, term := range terms { |
| 142 | if !strings.Contains(content, strings.ToLower(term)) { |
| 143 | return false |
| 144 | } |
| 145 | } |
| 146 | return true |
| 147 | } |
| 148 | |
| 149 | // SearchTerms splits a search query into terms and drops protocol-style |
| 150 | // name:value tokens. |
| 151 | func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) } |
| 152 | |
| 153 | func slicesFilter(words []string) []string { |
| 154 | out := make([]string, 0, len(words)) |
| 155 | for _, word := range words { |
| 156 | if !strings.Contains(word, ":" ) { |
| 157 | out = append(out, word) |
| 158 | } |
| 159 | } |
| 160 | return out |
| 161 | } |
| 162 | func contains(values []string, want string) bool { |
| 163 | for _, value := range values { |
| 164 | if value == want { |
| 165 | return true |
| 166 | } |
| 167 | } |
| 168 | return false |
| 169 | } |
| 170 | func containsInt(values []int, want int) bool { |
| 171 | for _, value := range values { |
| 172 | if value == want { |
| 173 | return true |
| 174 | } |
| 175 | } |
| 176 | return false |
| 177 | } |
| 178 | func tagMatch(e Event, name string, values []string) bool { |
| 179 | for _, tag := range e.Tags { |
| 180 | if len(tag) > 1 && tag[0] == name && contains(values, tag[1]) { |
| 181 | return true |
| 182 | } |
| 183 | } |
| 184 | return false |
| 185 | } |
| 186 | func mustRaw(value any) json.RawMessage { data, _ := json.Marshal(value); return data } |
| 187 | func errorsFilter(message string) error { return fmt.Errorf("%s" , message) } |
| 188 | |
| 189 | func isJSONArray(value json.RawMessage) bool { |
| 190 | trimmed := strings.TrimSpace(string(value)) |
| 191 | return len(trimmed) > 0 && trimmed[0] == '[' |
| 192 | } |