clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / protocol / nostr / filter.go
filter.go 6.6K
1package nostr23import (4 "encoding/json"5 "fmt"6 "strings"7)89// 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 list12// matches nothing. Tags uses names without the wire prefix #. Since and Until13// are inclusive Unix timestamps. Limit controls a historical result batch;14// Matches evaluates each event independently of that limit.15type 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}2526// ParseFilter decodes supported Nostr filter fields, including #tag keys, and27// checks their JSON types.28func ParseFilter(raw []byte) (Filter, error) {29 trimmed := strings.TrimSpace(string(raw))30 if len(trimmed) == 0 || trimmed[0] != '{' {31 return Filter{}, errorsFilter("filter must be an object")32 }33 var values map[string]json.RawMessage34 if err := json.Unmarshal(raw, &values); err != nil {35 return Filter{}, errorsFilter("filter must be an object")36 }37 f := Filter{Tags: make(map[string][]string)}38 for key, value := range values {39 switch {40 case key == "ids", key == "authors":41 if !isJSONArray(value) {42 return Filter{}, errorsFilter(key + " must be a list of strings")43 }44 if hasJSONNullElement(value) {45 return Filter{}, errorsFilter(key + " must be a list of strings")46 }47 var out []string48 if err := json.Unmarshal(value, &out); err != nil {49 return Filter{}, errorsFilter(key + " must be a list of strings")50 }51 if key == "ids" {52 f.IDs = out53 } else {54 f.Authors = out55 }56 case key == "kinds":57 if !isJSONArray(value) {58 return Filter{}, errorsFilter("kinds must be a list of integers")59 }60 if hasJSONNullElement(value) {61 return Filter{}, errorsFilter("kinds must be a list of integers")62 }63 if err := json.Unmarshal(value, &f.Kinds); err != nil {64 return Filter{}, errorsFilter("kinds must be a list of integers")65 }66 case key == "since", key == "until":67 if isJSONNull(value) {68 return Filter{}, errorsFilter(key + " must be an integer")69 }70 var n int6471 if err := json.Unmarshal(value, &n); err != nil {72 return Filter{}, errorsFilter(key + " must be an integer")73 }74 if key == "since" {75 f.Since = &n76 } else {77 f.Until = &n78 }79 case key == "limit":80 if isJSONNull(value) {81 return Filter{}, errorsFilter("limit must be an integer")82 }83 var n int84 if err := json.Unmarshal(value, &n); err != nil {85 return Filter{}, errorsFilter("limit must be an integer")86 }87 f.Limit = &n88 case key == "search":89 if isJSONNull(value) {90 return Filter{}, errorsFilter("search must be a string")91 }92 if err := json.Unmarshal(value, &f.Search); err != nil {93 return Filter{}, errorsFilter("search must be a string")94 }95 case len(key) == 2 && key[0] == '#':96 if !isJSONArray(value) {97 return Filter{}, errorsFilter(key + " must be a list of strings")98 }99 if hasJSONNullElement(value) {100 return Filter{}, errorsFilter(key + " must be a list of strings")101 }102 var out []string103 if err := json.Unmarshal(value, &out); err != nil {104 return Filter{}, errorsFilter(key + " must be a list of strings")105 }106 f.Tags[key[1:]] = out107 }108 }109 return f, nil110}111112func isJSONNull(value json.RawMessage) bool {113 return strings.TrimSpace(string(value)) == "null"114}115116func hasJSONNullElement(value json.RawMessage) bool {117 var values []json.RawMessage118 if err := json.Unmarshal(value, &values); err != nil {119 return false120 }121 for _, item := range values {122 if isJSONNull(item) {123 return true124 }125 }126 return false127}128129// MarshalJSON encodes tag constraints with their Nostr #name wire keys.130func (f Filter) MarshalJSON() ([]byte, error) {131 values := make(map[string]json.RawMessage, 7+len(f.Tags))132 if f.IDs != nil {133 values["ids"] = mustRaw(f.IDs)134 }135 if f.Authors != nil {136 values["authors"] = mustRaw(f.Authors)137 }138 if f.Kinds != nil {139 values["kinds"] = mustRaw(f.Kinds)140 }141 if f.Since != nil {142 values["since"] = mustRaw(f.Since)143 }144 if f.Until != nil {145 values["until"] = mustRaw(f.Until)146 }147 if f.Limit != nil {148 values["limit"] = mustRaw(f.Limit)149 }150 if f.Search != "" {151 values["search"] = mustRaw(f.Search)152 }153 for key, tags := range f.Tags {154 values["#"+key] = mustRaw(tags)155 }156 return json.Marshal(values)157}158159// Matches reports whether e satisfies every populated constraint in f.160// Search terms are matched against content without classifying event kinds.161// Callers must enforce visibility and authorization before returning matches.162func Matches(f Filter, e Event) bool {163 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) {164 return false165 }166 if f.Since != nil && e.CreatedAt < *f.Since || f.Until != nil && e.CreatedAt > *f.Until {167 return false168 }169 for name, values := range f.Tags {170 if !tagMatch(e, name, values) {171 return false172 }173 }174 terms := SearchTerms(f.Search)175 if len(terms) == 0 {176 return true177 }178179 content := strings.ToLower(e.Content)180 for _, term := range terms {181 if !strings.Contains(content, strings.ToLower(term)) {182 return false183 }184 }185 return true186}187188// SearchTerms splits a search query into terms and drops protocol-style189// name:value tokens.190func SearchTerms(query string) []string { return slicesFilter(strings.Fields(query)) }191192func slicesFilter(words []string) []string {193 out := make([]string, 0, len(words))194 for _, word := range words {195 if !strings.Contains(word, ":") {196 out = append(out, word)197 }198 }199 return out200}201func contains(values []string, want string) bool {202 for _, value := range values {203 if value == want {204 return true205 }206 }207 return false208}209func containsInt(values []int, want int) bool {210 for _, value := range values {211 if value == want {212 return true213 }214 }215 return false216}217func tagMatch(e Event, name string, values []string) bool {218 for _, tag := range e.Tags {219 if len(tag) > 1 && tag[0] == name && contains(values, tag[1]) {220 return true221 }222 }223 return false224}225func mustRaw(value any) json.RawMessage { data, _ := json.Marshal(value); return data }226func errorsFilter(message string) error { return fmt.Errorf("%s", message) }227228func isJSONArray(value json.RawMessage) bool {229 trimmed := strings.TrimSpace(string(value))230 return len(trimmed) > 0 && trimmed[0] == '['231}