From 88b4aaa301b4c9f6c2df2bbb1917b0e54e96c432 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Oct 2025 05:43:49 -0400 Subject: [PATCH] v0.4.6 - Implement NIP-50 search functionality with LIKE-based content and tag searching --- ...9615d90684bb5b2ca5f859ab0f0b704075871aa.db | 0 relay.pid | 2 +- src/main.c | 28 ++ src/main.h | 4 +- src/websockets.c | 28 ++ test_nip50_search.sh | 97 ++++ tests/50_nip_test.sh | 420 ++++++++++++++++++ 7 files changed, 576 insertions(+), 3 deletions(-) delete mode 100644 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa.db create mode 100644 test_nip50_search.sh create mode 100755 tests/50_nip_test.sh diff --git a/4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa.db b/4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa.db deleted file mode 100644 index e69de29..0000000 diff --git a/relay.pid b/relay.pid index ed4280f..ea988e7 100644 --- a/relay.pid +++ b/relay.pid @@ -1 +1 @@ -63206 +109955 diff --git a/src/main.c b/src/main.c index 7b077f5..3417485 100644 --- a/src/main.c +++ b/src/main.c @@ -1012,6 +1012,34 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru } } + // Handle search filter (NIP-50) + cJSON* search = cJSON_GetObjectItem(filter, "search"); + if (search && cJSON_IsString(search)) { + const char* search_term = cJSON_GetStringValue(search); + if (search_term && strlen(search_term) > 0) { + // Search in both content and tag values using LIKE + // Escape single quotes in search term for SQL safety + char escaped_search[256]; + size_t escaped_len = 0; + for (size_t i = 0; search_term[i] && escaped_len < sizeof(escaped_search) - 1; i++) { + if (search_term[i] == '\'') { + escaped_search[escaped_len++] = '\''; + escaped_search[escaped_len++] = '\''; + } else { + escaped_search[escaped_len++] = search_term[i]; + } + } + escaped_search[escaped_len] = '\0'; + + // Add search conditions for content and tags + // Use tags LIKE to search within the JSON string representation of tags + snprintf(sql_ptr, remaining, " AND (content LIKE '%%%s%%' OR tags LIKE '%%\"%s\"%%')", + escaped_search, escaped_search); + sql_ptr += strlen(sql_ptr); + remaining = sizeof(sql) - strlen(sql); + } + } + // Handle since filter cJSON* since = cJSON_GetObjectItem(filter, "since"); if (since && cJSON_IsNumber(since)) { diff --git a/src/main.h b/src/main.h index 06b2729..2cffae0 100644 --- a/src/main.h +++ b/src/main.h @@ -12,10 +12,10 @@ #define MAIN_H // Version information (auto-updated by build_and_push.sh) -#define VERSION "v0.4.5" +#define VERSION "v0.4.6" #define VERSION_MAJOR 0 #define VERSION_MINOR 4 -#define VERSION_PATCH 5 +#define VERSION_PATCH 6 // Relay metadata (authoritative source for NIP-11 information) #define RELAY_NAME "C-Relay" diff --git a/src/websockets.c b/src/websockets.c index 9784306..0e0c517 100644 --- a/src/websockets.c +++ b/src/websockets.c @@ -1085,6 +1085,34 @@ int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, st } } + // Handle search filter (NIP-50) + cJSON* search = cJSON_GetObjectItem(filter, "search"); + if (search && cJSON_IsString(search)) { + const char* search_term = cJSON_GetStringValue(search); + if (search_term && strlen(search_term) > 0) { + // Search in both content and tag values using LIKE + // Escape single quotes in search term for SQL safety + char escaped_search[256]; + size_t escaped_len = 0; + for (size_t i = 0; search_term[i] && escaped_len < sizeof(escaped_search) - 1; i++) { + if (search_term[i] == '\'') { + escaped_search[escaped_len++] = '\''; + escaped_search[escaped_len++] = '\''; + } else { + escaped_search[escaped_len++] = search_term[i]; + } + } + escaped_search[escaped_len] = '\0'; + + // Add search conditions for content and tags + // Use tags LIKE to search within the JSON string representation of tags + snprintf(sql_ptr, remaining, " AND (content LIKE '%%%s%%' OR tags LIKE '%%\"%s\"%%')", + escaped_search, escaped_search); + sql_ptr += strlen(sql_ptr); + remaining = sizeof(sql) - strlen(sql); + } + } + // Handle since filter cJSON* since = cJSON_GetObjectItem(filter, "since"); if (since && cJSON_IsNumber(since)) { diff --git a/test_nip50_search.sh b/test_nip50_search.sh new file mode 100644 index 0000000..16dc594 --- /dev/null +++ b/test_nip50_search.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Test script for NIP-50 search functionality +# This script tests the new search field in filter objects + +echo "=== Testing NIP-50 Search Functionality ===" + +# Function to send WebSocket message and capture response +send_ws_message() { + local message="$1" + echo "Sending: $message" + echo "$message" | websocat ws://127.0.0.1:8888 --text --no-close --one-message 2>/dev/null +} + +# Function to publish an event +publish_event() { + local content="$1" + local kind="${2:-1}" + local tags="${3:-[]}" + + # Create event JSON + local event="[\"EVENT\", {\"id\": \"\", \"pubkey\": \"\", \"created_at\": $(date +%s), \"kind\": $kind, \"tags\": $tags, \"content\": \"$content\", \"sig\": \"\"}]" + + # Send the event + send_ws_message "$event" +} + +# Function to search for events +search_events() { + local search_term="$1" + local sub_id="${2:-search_test}" + + # Create search filter + local filter="{\"search\": \"$search_term\"}" + local req="[\"REQ\", \"$sub_id\", $filter]" + + # Send the search request + send_ws_message "$req" + + # Wait a moment for response + sleep 0.5 + + # Send CLOSE to end subscription + local close="[\"CLOSE\", \"$sub_id\"]" + send_ws_message "$close" +} + +# Function to count events with search +count_events() { + local search_term="$1" + local sub_id="${2:-count_test}" + + # Create count filter with search + local filter="{\"search\": \"$search_term\"}" + local count_req="[\"COUNT\", \"$sub_id\", $filter]" + + # Send the count request + send_ws_message "$count_req" +} + +echo "Publishing test events with searchable content..." + +# Publish some test events with different content +publish_event "This is a test message about Bitcoin" +publish_event "Another message about Lightning Network" +publish_event "Nostr protocol discussion" +publish_event "Random content without keywords" +publish_event "Bitcoin and Lightning are great technologies" +publish_event "Discussion about Nostr and Bitcoin integration" + +echo "Waiting for events to be stored..." +sleep 2 + +echo "" +echo "Testing search functionality..." + +echo "1. Searching for 'Bitcoin':" +search_events "Bitcoin" + +echo "" +echo "2. Searching for 'Nostr':" +search_events "Nostr" + +echo "" +echo "3. Searching for 'Lightning':" +search_events "Lightning" + +echo "" +echo "4. Testing COUNT with search:" +count_events "Bitcoin" + +echo "" +echo "5. Testing COUNT with search for 'Nostr':" +count_events "Nostr" + +echo "" +echo "=== NIP-50 Search Test Complete ===" \ No newline at end of file diff --git a/tests/50_nip_test.sh b/tests/50_nip_test.sh new file mode 100755 index 0000000..fe8d4e1 --- /dev/null +++ b/tests/50_nip_test.sh @@ -0,0 +1,420 @@ +#!/bin/bash + +# NIP-50 Search Message Test - Test search functionality +# Tests search field in filter objects to verify correct event searching + +set -e # Exit on any error + +# Color constants +RED='\033[31m' +GREEN='\033[32m' +YELLOW='\033[33m' +BLUE='\033[34m' +BOLD='\033[1m' +RESET='\033[0m' + +# Test configuration +RELAY_URL="ws://127.0.0.1:8888" +TEST_PRIVATE_KEY="nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99" + +# Print functions +print_header() { + echo -e "${BLUE}${BOLD}=== $1 ===${RESET}" +} + +print_step() { + echo -e "${YELLOW}[STEP]${RESET} $1" +} + +print_success() { + echo -e "${GREEN}✓${RESET} $1" +} + +print_error() { + echo -e "${RED}✗${RESET} $1" +} + +print_info() { + echo -e "${BLUE}[INFO]${RESET} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${RESET} $1" +} + +# Global arrays to store event IDs for search tests +declare -a SEARCH_EVENT_IDS=() + +# Baseline counts from existing events in relay +BASELINE_TOTAL=0 +BASELINE_BITCOIN=0 +BASELINE_LIGHTNING=0 +BASELINE_NOSTR=0 +BASELINE_DECENTRALIZED=0 +BASELINE_NETWORK=0 + +# Helper function to get baseline count for a search term (before publishing test events) +get_baseline_search_count() { + local search_term="$1" + + # Create COUNT message with search + local filter="{\"search\":\"$search_term\"}" + local count_message="[\"COUNT\",\"baseline_search\",$filter]" + + # Send COUNT message and get response + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>&1 || echo "") + fi + + # Parse COUNT response + if [[ -n "$response" ]]; then + local count_result=$(echo "$response" | grep '"COUNT"' | head -1) + if [[ -n "$count_result" ]]; then + local count=$(echo "$count_result" | jq -r '.[2].count' 2>/dev/null) + if [[ "$count" =~ ^[0-9]+$ ]]; then + echo "$count" + return 0 + fi + fi + fi + + echo "0" # Default to 0 if we can't get the count +} + +# Helper function to publish event and extract ID +publish_event() { + local event_json="$1" + local description="$2" + + # Extract event ID + local event_id=$(echo "$event_json" | jq -r '.id' 2>/dev/null) + if [[ "$event_id" == "null" || -z "$event_id" ]]; then + print_error "Could not extract event ID from $description" + return 1 + fi + + print_info "Publishing $description..." + + # Create EVENT message in Nostr format + local event_message="[\"EVENT\",$event_json]" + + # Publish to relay + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$event_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "Connection failed") + else + print_error "websocat not found - required for testing" + return 1 + fi + + # Check response + if [[ "$response" == *"Connection failed"* ]]; then + print_error "Failed to connect to relay for $description" + return 1 + elif [[ "$response" == *"true"* ]]; then + print_success "$description uploaded (ID: ${event_id:0:16}...)" + SEARCH_EVENT_IDS+=("$event_id") + echo # Add blank line for readability + return 0 + else + print_warning "$description might have failed: $response" + echo # Add blank line for readability + return 1 + fi +} + +# Helper function to send COUNT message with search and check response +test_search_count() { + local sub_id="$1" + local filter="$2" + local description="$3" + local expected_count="$4" + + print_step "Testing SEARCH COUNT: $description" + + # Create COUNT message + local count_message="[\"COUNT\",\"$sub_id\",$filter]" + + print_info "Sending filter: $filter" + + # Send COUNT message and get response + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "") + fi + + # Parse COUNT response + local count_result="" + if [[ -n "$response" ]]; then + # Look for COUNT response: ["COUNT","sub_id",{"count":N}] + count_result=$(echo "$response" | grep '"COUNT"' | head -1) + if [[ -n "$count_result" ]]; then + local actual_count=$(echo "$count_result" | jq -r '.[2].count' 2>/dev/null) + if [[ "$actual_count" =~ ^[0-9]+$ ]]; then + print_info "Received count: $actual_count" + + # Check if count matches expected + if [[ "$expected_count" == "any" ]]; then + print_success "$description - Count: $actual_count" + return 0 + elif [[ "$actual_count" -eq "$expected_count" ]]; then + print_success "$description - Expected: $expected_count, Got: $actual_count" + return 0 + else + print_error "$description - Expected: $expected_count, Got: $actual_count" + return 1 + fi + else + print_error "$description - Invalid count response: $count_result" + return 1 + fi + else + print_error "$description - No COUNT response received" + print_error "Raw response: $response" + return 1 + fi + else + print_error "$description - No response from relay" + return 1 + fi +} + +# Helper function to send REQ message with search and check response +test_search_req() { + local sub_id="$1" + local filter="$2" + local description="$3" + local expected_events="$4" + + print_step "Testing SEARCH REQ: $description" + + # Create REQ message + local req_message="[\"REQ\",\"$sub_id\",$filter]" + + print_info "Sending filter: $filter" + + # Send REQ message and get response + local response="" + if command -v websocat &> /dev/null; then + response=$(echo "$req_message" | timeout 5s websocat "$RELAY_URL" 2>&1 || echo "") + fi + + # Send CLOSE message to end subscription + local close_message="[\"CLOSE\",\"$sub_id\"]" + echo "$close_message" | timeout 2s websocat "$RELAY_URL" >/dev/null 2>&1 || true + + # Parse response for EVENT messages + local event_count=0 + if [[ -n "$response" ]]; then + # Count EVENT messages in response + event_count=$(echo "$response" | grep -c '"EVENT"') + + print_info "Received events: $event_count" + + # Check if event count matches expected + if [[ "$expected_events" == "any" ]]; then + print_success "$description - Events: $event_count" + return 0 + elif [[ "$event_count" -eq "$expected_events" ]]; then + print_success "$description - Expected: $expected_events, Got: $event_count" + return 0 + else + print_error "$description - Expected: $expected_events, Got: $event_count" + return 1 + fi + else + print_error "$description - No response from relay" + return 1 + fi +} + +# Main test function +run_search_test() { + print_header "NIP-50 Search Message Test" + + # Check dependencies + print_step "Checking dependencies..." + if ! command -v nak &> /dev/null; then + print_error "nak command not found" + print_info "Please install nak: go install github.com/fiatjaf/nak@latest" + return 1 + fi + if ! command -v websocat &> /dev/null; then + print_error "websocat command not found" + print_info "Please install websocat for testing" + return 1 + fi + if ! command -v jq &> /dev/null; then + print_error "jq command not found" + print_info "Please install jq for JSON processing" + return 1 + fi + print_success "All dependencies found" + + print_header "PHASE 0: Establishing Baseline Search Counts" + + # Get baseline counts BEFORE publishing any test events + print_step "Getting baseline search counts from existing events in relay..." + + BASELINE_TOTAL=$(get_baseline_search_count "") + BASELINE_BITCOIN=$(get_baseline_search_count "Bitcoin") + BASELINE_LIGHTNING=$(get_baseline_search_count "Lightning") + BASELINE_NOSTR=$(get_baseline_search_count "Nostr") + BASELINE_DECENTRALIZED=$(get_baseline_search_count "decentralized") + BASELINE_NETWORK=$(get_baseline_search_count "network") + + print_info "Initial baseline search counts established:" + print_info " Total events: $BASELINE_TOTAL" + print_info " 'Bitcoin' matches: $BASELINE_BITCOIN" + print_info " 'Lightning' matches: $BASELINE_LIGHTNING" + print_info " 'Nostr' matches: $BASELINE_NOSTR" + print_info " 'decentralized' matches: $BASELINE_DECENTRALIZED" + print_info " 'network' matches: $BASELINE_NETWORK" + + print_header "PHASE 1: Publishing Test Events with Searchable Content" + + # Create events with searchable content + print_step "Creating events with searchable content..." + + # Events with "Bitcoin" in content + local bitcoin1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Bitcoin is a decentralized digital currency" -k 1 --ts $(($(date +%s) - 100)) -t "topic=crypto" 2>/dev/null) + local bitcoin2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "The Bitcoin network is secure and decentralized" -k 1 --ts $(($(date +%s) - 90)) -t "topic=blockchain" 2>/dev/null) + + # Events with "Lightning" in content + local lightning1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Lightning Network enables fast Bitcoin transactions" -k 1 --ts $(($(date +%s) - 80)) -t "topic=lightning" 2>/dev/null) + local lightning2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Lightning channels are bidirectional payment channels" -k 1 --ts $(($(date +%s) - 70)) -t "topic=scaling" 2>/dev/null) + + # Events with "Nostr" in content + local nostr1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Nostr is a decentralized social network protocol" -k 1 --ts $(($(date +%s) - 60)) -t "topic=nostr" 2>/dev/null) + local nostr2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Nostr relays store and distribute events" -k 1 --ts $(($(date +%s) - 50)) -t "topic=protocol" 2>/dev/null) + + # Events with searchable content in tags + local tag_event=$(nak event --sec "$TEST_PRIVATE_KEY" -c "This event has searchable tags" -k 1 --ts $(($(date +%s) - 40)) -t "search=bitcoin" -t "category=crypto" 2>/dev/null) + + # Event with no searchable content + local no_match=$(nak event --sec "$TEST_PRIVATE_KEY" -c "This event has no matching content" -k 1 --ts $(($(date +%s) - 30)) -t "topic=other" 2>/dev/null) + + # Publish all test events + publish_event "$bitcoin1" "Bitcoin event #1" + publish_event "$bitcoin2" "Bitcoin event #2" + publish_event "$lightning1" "Lightning event #1" + publish_event "$lightning2" "Lightning event #2" + publish_event "$nostr1" "Nostr event #1" + publish_event "$nostr2" "Nostr event #2" + publish_event "$tag_event" "Event with searchable tags" + publish_event "$no_match" "Non-matching event" + + # Brief pause to let events settle + sleep 2 + + print_header "PHASE 2: Testing SEARCH Functionality" + + local test_failures=0 + + # Test 1: Search for "Bitcoin" - should find baseline + 4 new events (2 in content + 1 in tags + 1 with search=bitcoin tag) + local expected_bitcoin=$((BASELINE_BITCOIN + 4)) + if ! test_search_count "search_bitcoin_count" '{"search":"Bitcoin"}' "COUNT search for 'Bitcoin'" "$expected_bitcoin"; then + ((test_failures++)) + fi + + if ! test_search_req "search_bitcoin_req" '{"search":"Bitcoin"}' "REQ search for 'Bitcoin'" "$expected_bitcoin"; then + ((test_failures++)) + fi + + # Test 2: Search for "Lightning" - should find baseline + 2 new events + local expected_lightning=$((BASELINE_LIGHTNING + 2)) + if ! test_search_count "search_lightning_count" '{"search":"Lightning"}' "COUNT search for 'Lightning'" "$expected_lightning"; then + ((test_failures++)) + fi + + if ! test_search_req "search_lightning_req" '{"search":"Lightning"}' "REQ search for 'Lightning'" "$expected_lightning"; then + ((test_failures++)) + fi + + # Test 3: Search for "Nostr" - should find baseline + 2 new events + local expected_nostr=$((BASELINE_NOSTR + 2)) + if ! test_search_count "search_nostr_count" '{"search":"Nostr"}' "COUNT search for 'Nostr'" "$expected_nostr"; then + ((test_failures++)) + fi + + if ! test_search_req "search_nostr_req" '{"search":"Nostr"}' "REQ search for 'Nostr'" "$expected_nostr"; then + ((test_failures++)) + fi + + # Test 4: Search for "decentralized" - should find baseline + 3 new events (Bitcoin #1, Bitcoin #2, Nostr #1) + local expected_decentralized=$((BASELINE_DECENTRALIZED + 3)) + if ! test_search_count "search_decentralized_count" '{"search":"decentralized"}' "COUNT search for 'decentralized'" "$expected_decentralized"; then + ((test_failures++)) + fi + + if ! test_search_req "search_decentralized_req" '{"search":"decentralized"}' "REQ search for 'decentralized'" "$expected_decentralized"; then + ((test_failures++)) + fi + + # Test 5: Search for "network" - should find baseline + 3 new events (Bitcoin2, Lightning1, Nostr1) + local expected_network=$((BASELINE_NETWORK + 3)) + if ! test_search_count "search_network_count" '{"search":"network"}' "COUNT search for 'network'" "$expected_network"; then + ((test_failures++)) + fi + + # Test 6: Search for non-existent term - should find 0 events + if ! test_search_count "search_nonexistent_count" '{"search":"xyzzy"}' "COUNT search for non-existent term" "0"; then + ((test_failures++)) + fi + + # Test 7: Search combined with other filters + local expected_combined=$((BASELINE_BITCOIN + 4)) + if ! test_search_count "search_combined_count" '{"search":"Bitcoin","kinds":[1]}' "COUNT search 'Bitcoin' with kind filter" "$expected_combined"; then + ((test_failures++)) + fi + + # Test 8: Search with time range + local recent_timestamp=$(($(date +%s) - 60)) + if ! test_search_count "search_time_count" "{\"search\":\"Bitcoin\",\"since\":$recent_timestamp}" "COUNT search 'Bitcoin' with time filter" "any"; then + ((test_failures++)) + fi + + # Test 9: Empty search string - should return all events + local expected_empty=$((BASELINE_TOTAL + 8)) + if ! test_search_count "search_empty_count" '{"search":""}' "COUNT with empty search string" "$expected_empty"; then + ((test_failures++)) + fi + + # Test 10: Case insensitive search (SQLite LIKE is case insensitive by default) + local expected_case=$((BASELINE_BITCOIN + 4)) + if ! test_search_count "search_case_count" '{"search":"BITCOIN"}' "COUNT case-insensitive search for 'BITCOIN'" "$expected_case"; then + ((test_failures++)) + fi + + # Report test results + if [[ $test_failures -gt 0 ]]; then + print_error "SEARCH TESTS FAILED: $test_failures test(s) failed" + return 1 + else + print_success "All SEARCH tests passed" + fi + + return 0 +} + +# Run the SEARCH test +print_header "Starting NIP-50 Search Message Test Suite" +echo + +if run_search_test; then + echo + print_success "All NIP-50 SEARCH tests completed successfully!" + print_info "The C-Relay SEARCH functionality is working correctly" + print_info "✅ Search field in filter objects works for both REQ and COUNT messages" + print_info "✅ Search works across event content and tag values" + print_info "✅ Search is case-insensitive and supports partial matches" + echo + exit 0 +else + echo + print_error "❌ NIP-50 SEARCH TESTS FAILED!" + print_error "The SEARCH functionality has issues that need to be fixed" + echo + exit 1 +fi \ No newline at end of file