filter_utils.go raw

   1  // Package database provides filter utilities for normalizing tag values.
   2  //
   3  // The nostr library optimizes e/p tag values by storing them in binary format
   4  // (32 bytes + null terminator) rather than hex strings (64 chars). However,
   5  // filter tags from client queries come as hex strings and don't go through
   6  // the same binary encoding during unmarshalling.
   7  //
   8  // This file provides utilities to normalize filter tags to match the binary
   9  // encoding used in stored events, ensuring consistent index lookups and
  10  // tag comparisons.
  11  package database
  12  
  13  import (
  14  	"next.orly.dev/pkg/nostr/encoders/filter"
  15  	"next.orly.dev/pkg/nostr/encoders/hex"
  16  	"next.orly.dev/pkg/nostr/encoders/tag"
  17  )
  18  
  19  // Tag binary encoding constants (matching the nostr library)
  20  const (
  21  	// BinaryEncodedLen is the length of a binary-encoded 32-byte hash with null terminator
  22  	BinaryEncodedLen = 33
  23  	// HexEncodedLen is the length of a hex-encoded 32-byte hash
  24  	HexEncodedLen = 64
  25  	// HashLen is the raw length of a hash (pubkey/event ID)
  26  	HashLen = 32
  27  )
  28  
  29  // binaryOptimizedTags defines which tag keys use binary encoding optimization
  30  var binaryOptimizedTags = map[byte]bool{
  31  	'e': true, // event references
  32  	'p': true, // pubkey references
  33  }
  34  
  35  // IsBinaryOptimizedTag returns true if the given tag key uses binary encoding
  36  func IsBinaryOptimizedTag(key byte) bool {
  37  	return binaryOptimizedTags[key]
  38  }
  39  
  40  // IsBinaryEncoded checks if a value field is stored in optimized binary format
  41  func IsBinaryEncoded(val []byte) bool {
  42  	return len(val) == BinaryEncodedLen && val[HashLen] == 0
  43  }
  44  
  45  // IsValidHexValue checks if a byte slice is a valid 64-character hex string
  46  func IsValidHexValue(b []byte) bool {
  47  	if len(b) != HexEncodedLen {
  48  		return false
  49  	}
  50  	return IsHexString(b)
  51  }
  52  
  53  // HexToBinary converts a 64-character hex string to 33-byte binary format
  54  // Returns nil if the input is not a valid hex string
  55  func HexToBinary(hexVal []byte) []byte {
  56  	if !IsValidHexValue(hexVal) {
  57  		return nil
  58  	}
  59  	binVal := make([]byte, BinaryEncodedLen)
  60  	if _, err := hex.DecBytes(binVal[:HashLen], hexVal); err != nil {
  61  		return nil
  62  	}
  63  	binVal[HashLen] = 0 // null terminator
  64  	return binVal
  65  }
  66  
  67  // BinaryToHex converts a 33-byte binary value to 64-character hex string
  68  // Returns nil if the input is not in binary format
  69  func BinaryToHex(binVal []byte) []byte {
  70  	if !IsBinaryEncoded(binVal) {
  71  		return nil
  72  	}
  73  	return hex.EncAppend(nil, binVal[:HashLen])
  74  }
  75  
  76  // NormalizeTagValue normalizes a tag value for the given key.
  77  // For e/p tags, hex values are converted to binary format.
  78  // Other tags are returned unchanged.
  79  func NormalizeTagValue(key byte, val []byte) []byte {
  80  	if !IsBinaryOptimizedTag(key) {
  81  		return val
  82  	}
  83  	// If already binary, return as-is
  84  	if IsBinaryEncoded(val) {
  85  		return val
  86  	}
  87  	// If valid hex, convert to binary
  88  	if binVal := HexToBinary(val); binVal != nil {
  89  		return binVal
  90  	}
  91  	// Otherwise return as-is
  92  	return val
  93  }
  94  
  95  // NormalizeTagToHex returns the hex representation of a tag value.
  96  // For binary-encoded values, converts to hex. For hex values, returns as-is.
  97  func NormalizeTagToHex(val []byte) []byte {
  98  	if IsBinaryEncoded(val) {
  99  		return BinaryToHex(val)
 100  	}
 101  	return val
 102  }
 103  
 104  // NormalizeFilterTag creates a new tag with binary-encoded values for e/p tags.
 105  // The original tag is not modified.
 106  func NormalizeFilterTag(t *tag.T) *tag.T {
 107  	if t == nil || t.Len() < 2 {
 108  		return t
 109  	}
 110  
 111  	keyBytes := t.Key()
 112  	var key byte
 113  
 114  	// Handle both "e" and "#e" style keys
 115  	if len(keyBytes) == 1 {
 116  		key = keyBytes[0]
 117  	} else if len(keyBytes) == 2 && keyBytes[0] == '#' {
 118  		key = keyBytes[1]
 119  	} else {
 120  		return t // Not a single-letter tag
 121  	}
 122  
 123  	if !IsBinaryOptimizedTag(key) {
 124  		return t // Not an optimized tag type
 125  	}
 126  
 127  	// Create new tag with normalized values
 128  	normalized := tag.NewWithCap(t.Len())
 129  	normalized.T = append(normalized.T, t.T[0]) // Keep key as-is
 130  
 131  	// Normalize each value
 132  	for _, val := range t.T[1:] {
 133  		normalizedVal := NormalizeTagValue(key, val)
 134  		normalized.T = append(normalized.T, normalizedVal)
 135  	}
 136  
 137  	return normalized
 138  }
 139  
 140  // NormalizeFilterTags normalizes all tags in a tag.S, converting e/p hex values to binary.
 141  // Returns a new tag.S with normalized tags.
 142  func NormalizeFilterTags(tags *tag.S) *tag.S {
 143  	if tags == nil || tags.Len() == 0 {
 144  		return tags
 145  	}
 146  
 147  	normalized := tag.NewSWithCap(tags.Len())
 148  	for _, t := range *tags {
 149  		normalizedTag := NormalizeFilterTag(t)
 150  		normalized.Append(normalizedTag)
 151  	}
 152  	return normalized
 153  }
 154  
 155  // NormalizeFilter normalizes a filter's tags for consistent database queries.
 156  // This should be called before using a filter for database lookups.
 157  // The original filter is not modified; a copy with normalized tags is returned.
 158  func NormalizeFilter(f *filter.F) *filter.F {
 159  	if f == nil {
 160  		return nil
 161  	}
 162  
 163  	// Create a shallow copy of the filter
 164  	normalized := &filter.F{
 165  		Ids:     f.Ids,
 166  		Kinds:   f.Kinds,
 167  		Authors: f.Authors,
 168  		Since:   f.Since,
 169  		Until:   f.Until,
 170  		Search:  f.Search,
 171  		Limit:   f.Limit,
 172  	}
 173  
 174  	// Normalize the tags
 175  	normalized.Tags = NormalizeFilterTags(f.Tags)
 176  
 177  	return normalized
 178  }
 179  
 180  // TagValuesMatch compares two tag values, handling both binary and hex encodings.
 181  // This is useful for post-query tag matching where event values may be binary
 182  // and filter values may be hex (or vice versa).
 183  func TagValuesMatch(key byte, eventVal, filterVal []byte) bool {
 184  	// If both are the same, they match
 185  	if len(eventVal) == len(filterVal) {
 186  		for i := range eventVal {
 187  			if eventVal[i] != filterVal[i] {
 188  				goto different
 189  			}
 190  		}
 191  		return true
 192  	}
 193  different:
 194  
 195  	// For non-optimized tags, require exact match
 196  	if !IsBinaryOptimizedTag(key) {
 197  		return false
 198  	}
 199  
 200  	// Normalize both to hex and compare
 201  	eventHex := NormalizeTagToHex(eventVal)
 202  	filterHex := NormalizeTagToHex(filterVal)
 203  
 204  	if len(eventHex) != len(filterHex) {
 205  		return false
 206  	}
 207  	for i := range eventHex {
 208  		if eventHex[i] != filterHex[i] {
 209  			return false
 210  		}
 211  	}
 212  	return true
 213  }
 214  
 215  // TagValuesMatchUsingTagMethods compares an event tag's value with a filter value
 216  // using the tag.T methods. This leverages the nostr library's ValueHex() method
 217  // for proper binary/hex conversion.
 218  func TagValuesMatchUsingTagMethods(eventTag *tag.T, filterVal []byte) bool {
 219  	if eventTag == nil {
 220  		return false
 221  	}
 222  
 223  	keyBytes := eventTag.Key()
 224  	if len(keyBytes) != 1 {
 225  		// Not a single-letter tag, use direct comparison
 226  		return bytesEqual(eventTag.Value(), filterVal)
 227  	}
 228  
 229  	key := keyBytes[0]
 230  	if !IsBinaryOptimizedTag(key) {
 231  		// Not an optimized tag, use direct comparison
 232  		return bytesEqual(eventTag.Value(), filterVal)
 233  	}
 234  
 235  	// For e/p tags, use ValueHex() for proper conversion
 236  	eventHex := eventTag.ValueHex()
 237  	filterHex := NormalizeTagToHex(filterVal)
 238  
 239  	return bytesEqual(eventHex, filterHex)
 240  }
 241  
 242  // bytesEqual is a fast equality check that avoids allocation
 243  func bytesEqual(a, b []byte) bool {
 244  	if len(a) != len(b) {
 245  		return false
 246  	}
 247  	for i := range a {
 248  		if a[i] != b[i] {
 249  			return false
 250  		}
 251  	}
 252  	return true
 253  }
 254