Compare commits

...

2 Commits

Author SHA1 Message Date
Your Name
d5eb7d4a55 v0.4.4 - Just waking up 2025-10-03 04:52:40 -04:00
Your Name
80b15e16e2 v0.4.3 - feat: Implement dynamic configuration updates without restart
- Add cache refresh mechanism for config updates
- Implement selective re-initialization for NIP-11 relay info changes
- Categorize configs as dynamic vs restart-required using requires_restart field
- Enhance admin API responses with restart requirement information
- Add comprehensive test for dynamic config updates
- Update documentation for dynamic configuration capabilities

Most relay settings can now be updated via admin API without requiring restart, improving operational flexibility while maintaining stability for critical changes.
2025-10-02 15:53:26 -04:00
9 changed files with 994 additions and 31 deletions

View File

@@ -18,7 +18,7 @@ Do NOT modify the formatting, add emojis, or change the text. Keep the simple fo
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [x] NIP-42: Authentication of clients to relays
- [ ] NIP-45: Counting results
- [x] NIP-45: Counting results
- [ ] NIP-50: Keywords filter
- [ ] NIP-70: Protected Events
@@ -116,6 +116,24 @@ All commands are sent as NIP-44 encrypted JSON arrays in the event content. The
- `pow_min_difficulty`: Minimum proof-of-work difficulty
- `nip40_expiration_enabled`: Enable event expiration (`true`/`false`)
### Dynamic Configuration Updates
C-Relay supports **dynamic configuration updates** without requiring a restart for most settings. Configuration parameters are categorized as either **dynamic** (can be updated immediately) or **restart-required** (require relay restart to take effect).
**Dynamic Configuration Parameters (No Restart Required):**
- All relay information (NIP-11) settings: `relay_name`, `relay_description`, `relay_contact`, `relay_software`, `relay_version`, `supported_nips`, `language_tags`, `relay_countries`, `posting_policy`, `payments_url`
- Authentication settings: `auth_enabled`, `nip42_auth_required`, `nip42_auth_required_kinds`, `nip42_challenge_timeout`
- Subscription limits: `max_subscriptions_per_client`, `max_total_subscriptions`
- Event validation limits: `max_event_tags`, `max_content_length`, `max_message_length`
- Proof of Work settings: `pow_min_difficulty`, `pow_mode`
- Event expiration settings: `nip40_expiration_enabled`, `nip40_expiration_strict`, `nip40_expiration_filter`, `nip40_expiration_grace_period`
**Restart-Required Configuration Parameters:**
- Connection settings: `max_connections`, `relay_port`
- Database and core system settings
When updating configuration, the admin API response will indicate whether a restart is required for each parameter. Dynamic updates take effect immediately and are reflected in NIP-11 relay information documents without restart.
### Response Format
All admin commands return **signed EVENT responses** via WebSocket following standard Nostr protocol. Responses use JSON content with structured data.

8
c-relay.code-workspace Normal file
View File

@@ -0,0 +1,8 @@
{
"folders": [
{
"path": "."
}
],
"settings": {}
}

View File

@@ -1 +1 @@
2263673
63206

View File

@@ -127,40 +127,119 @@ void force_config_cache_refresh(void) {
log_info("Configuration cache forcibly invalidated");
}
// Update specific cache value without full refresh
int update_cache_value(const char* key, const char* value) {
if (!key || !value) {
return -1;
}
pthread_mutex_lock(&g_unified_cache.cache_lock);
// Update specific cache fields
if (strcmp(key, "admin_pubkey") == 0) {
strncpy(g_unified_cache.admin_pubkey, value, sizeof(g_unified_cache.admin_pubkey) - 1);
g_unified_cache.admin_pubkey[sizeof(g_unified_cache.admin_pubkey) - 1] = '\0';
} else if (strcmp(key, "relay_pubkey") == 0) {
strncpy(g_unified_cache.relay_pubkey, value, sizeof(g_unified_cache.relay_pubkey) - 1);
g_unified_cache.relay_pubkey[sizeof(g_unified_cache.relay_pubkey) - 1] = '\0';
} else if (strcmp(key, "auth_required") == 0) {
g_unified_cache.auth_required = (strcmp(value, "true") == 0) ? 1 : 0;
} else if (strcmp(key, "admin_enabled") == 0) {
g_unified_cache.admin_enabled = (strcmp(value, "true") == 0) ? 1 : 0;
} else if (strcmp(key, "max_file_size") == 0) {
g_unified_cache.max_file_size = atol(value);
} else if (strcmp(key, "nip42_mode") == 0) {
if (strcmp(value, "disabled") == 0) {
g_unified_cache.nip42_mode = 0;
} else if (strcmp(value, "required") == 0) {
g_unified_cache.nip42_mode = 2;
} else {
g_unified_cache.nip42_mode = 1; // Optional/enabled
}
} else if (strcmp(key, "nip42_challenge_timeout") == 0) {
g_unified_cache.nip42_challenge_timeout = atoi(value);
} else if (strcmp(key, "nip42_time_tolerance") == 0) {
g_unified_cache.nip42_time_tolerance = atoi(value);
} else {
// For NIP-11 relay info fields, update the cache buffers
if (strcmp(key, "relay_name") == 0) {
strncpy(g_unified_cache.relay_info.name, value, sizeof(g_unified_cache.relay_info.name) - 1);
g_unified_cache.relay_info.name[sizeof(g_unified_cache.relay_info.name) - 1] = '\0';
} else if (strcmp(key, "relay_description") == 0) {
strncpy(g_unified_cache.relay_info.description, value, sizeof(g_unified_cache.relay_info.description) - 1);
g_unified_cache.relay_info.description[sizeof(g_unified_cache.relay_info.description) - 1] = '\0';
} else if (strcmp(key, "relay_contact") == 0) {
strncpy(g_unified_cache.relay_info.contact, value, sizeof(g_unified_cache.relay_info.contact) - 1);
g_unified_cache.relay_info.contact[sizeof(g_unified_cache.relay_info.contact) - 1] = '\0';
} else if (strcmp(key, "relay_software") == 0) {
strncpy(g_unified_cache.relay_info.software, value, sizeof(g_unified_cache.relay_info.software) - 1);
g_unified_cache.relay_info.software[sizeof(g_unified_cache.relay_info.software) - 1] = '\0';
} else if (strcmp(key, "relay_version") == 0) {
strncpy(g_unified_cache.relay_info.version, value, sizeof(g_unified_cache.relay_info.version) - 1);
g_unified_cache.relay_info.version[sizeof(g_unified_cache.relay_info.version) - 1] = '\0';
} else if (strcmp(key, "supported_nips") == 0) {
strncpy(g_unified_cache.relay_info.supported_nips_str, value, sizeof(g_unified_cache.relay_info.supported_nips_str) - 1);
g_unified_cache.relay_info.supported_nips_str[sizeof(g_unified_cache.relay_info.supported_nips_str) - 1] = '\0';
} else if (strcmp(key, "language_tags") == 0) {
strncpy(g_unified_cache.relay_info.language_tags_str, value, sizeof(g_unified_cache.relay_info.language_tags_str) - 1);
g_unified_cache.relay_info.language_tags_str[sizeof(g_unified_cache.relay_info.language_tags_str) - 1] = '\0';
} else if (strcmp(key, "relay_countries") == 0) {
strncpy(g_unified_cache.relay_info.relay_countries_str, value, sizeof(g_unified_cache.relay_info.relay_countries_str) - 1);
g_unified_cache.relay_info.relay_countries_str[sizeof(g_unified_cache.relay_info.relay_countries_str) - 1] = '\0';
} else if (strcmp(key, "posting_policy") == 0) {
strncpy(g_unified_cache.relay_info.posting_policy, value, sizeof(g_unified_cache.relay_info.posting_policy) - 1);
g_unified_cache.relay_info.posting_policy[sizeof(g_unified_cache.relay_info.posting_policy) - 1] = '\0';
} else if (strcmp(key, "payments_url") == 0) {
strncpy(g_unified_cache.relay_info.payments_url, value, sizeof(g_unified_cache.relay_info.payments_url) - 1);
g_unified_cache.relay_info.payments_url[sizeof(g_unified_cache.relay_info.payments_url) - 1] = '\0';
}
}
// Reset cache expiration to extend validity
int cache_timeout = get_cache_timeout();
g_unified_cache.cache_expires = time(NULL) + cache_timeout;
pthread_mutex_unlock(&g_unified_cache.cache_lock);
log_info("Updated specific cache value");
printf(" Key: %s\n", key);
return 0;
}
// Refresh unified cache from database
static int refresh_unified_cache_from_table(void) {
if (!g_db) {
log_error("Database not available for cache refresh");
return -1;
}
// Clear cache
memset(&g_unified_cache, 0, sizeof(g_unified_cache));
g_unified_cache.cache_lock = (pthread_mutex_t)PTHREAD_MUTEX_INITIALIZER;
// Load critical config values from table
const char* admin_pubkey = get_config_value_from_table("admin_pubkey");
if (admin_pubkey) {
strncpy(g_unified_cache.admin_pubkey, admin_pubkey, sizeof(g_unified_cache.admin_pubkey) - 1);
g_unified_cache.admin_pubkey[sizeof(g_unified_cache.admin_pubkey) - 1] = '\0';
}
const char* relay_pubkey = get_config_value_from_table("relay_pubkey");
if (relay_pubkey) {
strncpy(g_unified_cache.relay_pubkey, relay_pubkey, sizeof(g_unified_cache.relay_pubkey) - 1);
g_unified_cache.relay_pubkey[sizeof(g_unified_cache.relay_pubkey) - 1] = '\0';
}
// Load auth-related config
const char* auth_required = get_config_value_from_table("auth_required");
g_unified_cache.auth_required = (auth_required && strcmp(auth_required, "true") == 0) ? 1 : 0;
const char* admin_enabled = get_config_value_from_table("admin_enabled");
g_unified_cache.admin_enabled = (admin_enabled && strcmp(admin_enabled, "true") == 0) ? 1 : 0;
const char* max_file_size = get_config_value_from_table("max_file_size");
g_unified_cache.max_file_size = max_file_size ? atol(max_file_size) : 104857600; // 100MB default
const char* nip42_mode = get_config_value_from_table("nip42_mode");
if (nip42_mode) {
if (strcmp(nip42_mode, "disabled") == 0) {
@@ -173,18 +252,18 @@ static int refresh_unified_cache_from_table(void) {
} else {
g_unified_cache.nip42_mode = 1; // Default to optional/enabled
}
const char* challenge_timeout = get_config_value_from_table("nip42_challenge_timeout");
g_unified_cache.nip42_challenge_timeout = challenge_timeout ? atoi(challenge_timeout) : 600;
const char* time_tolerance = get_config_value_from_table("nip42_time_tolerance");
g_unified_cache.nip42_time_tolerance = time_tolerance ? atoi(time_tolerance) : 300;
// Set cache expiration
int cache_timeout = get_cache_timeout();
g_unified_cache.cache_expires = time(NULL) + cache_timeout;
g_unified_cache.cache_valid = 1;
log_info("Unified configuration cache refreshed from database");
return 0;
}
@@ -1980,12 +2059,12 @@ int update_config_in_table(const char* key, const char* value) {
// Populate default config values
int populate_default_config_values(void) {
log_info("Populating default configuration values in table...");
// Add all default configuration values to the table
for (size_t i = 0; i < DEFAULT_CONFIG_COUNT; i++) {
const char* key = DEFAULT_CONFIG_VALUES[i].key;
const char* value = DEFAULT_CONFIG_VALUES[i].value;
// Determine data type
const char* data_type = "string";
if (strcmp(key, "relay_port") == 0 ||
@@ -2009,7 +2088,7 @@ int populate_default_config_values(void) {
strcmp(key, "nip42_auth_required") == 0) {
data_type = "boolean";
}
// Set category
const char* category = "general";
if (strstr(key, "relay_")) {
@@ -2023,21 +2102,29 @@ int populate_default_config_values(void) {
} else if (strstr(key, "max_")) {
category = "limits";
}
// Determine if requires restart
// Determine if requires restart (0 = dynamic, 1 = restart required)
int requires_restart = 0;
if (strcmp(key, "relay_port") == 0) {
// Restart required configs
if (strcmp(key, "relay_port") == 0 ||
strcmp(key, "max_connections") == 0 ||
strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip42_auth_required_kinds") == 0 ||
strcmp(key, "nip42_challenge_timeout") == 0 ||
strcmp(key, "database_path") == 0) {
requires_restart = 1;
}
if (set_config_value_in_table(key, value, data_type, NULL, category, requires_restart) != 0) {
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "Failed to set default config: %s = %s", key, value);
log_error(error_msg);
}
}
log_success("Default configuration values populated");
log_success("Default configuration values populated with restart requirements");
return 0;
}
@@ -3797,10 +3884,59 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
continue;
}
// Check if this config requires restart
const char* requires_restart_sql = "SELECT requires_restart FROM config WHERE key = ?";
sqlite3_stmt* restart_stmt;
int requires_restart = 0;
if (sqlite3_prepare_v2(g_db, requires_restart_sql, -1, &restart_stmt, NULL) == SQLITE_OK) {
sqlite3_bind_text(restart_stmt, 1, key, -1, SQLITE_STATIC);
if (sqlite3_step(restart_stmt) == SQLITE_ROW) {
requires_restart = sqlite3_column_int(restart_stmt, 0);
}
sqlite3_finalize(restart_stmt);
}
// Update the configuration value in the table
if (update_config_in_table(key, value) == 0) {
updates_applied++;
// For dynamic configs (requires_restart = 0), refresh cache immediately
if (requires_restart == 0) {
log_info("Dynamic config updated - refreshing cache");
refresh_unified_cache_from_table();
// Apply selective re-initialization for specific dynamic configs
log_info("Applying selective re-initialization for dynamic config changes");
if (strcmp(key, "max_subscriptions_per_client") == 0 ||
strcmp(key, "max_total_subscriptions") == 0) {
log_info("Subscription limits changed - updating subscription manager");
update_subscription_manager_config();
// Also refresh NIP-11 relay info since max_subscriptions_per_client affects limitation field
log_info("Subscription limits changed - reinitializing relay info for NIP-11");
init_relay_info();
} else if (strcmp(key, "pow_min_difficulty") == 0 ||
strcmp(key, "pow_mode") == 0) {
log_info("PoW configuration changed - reinitializing PoW system");
init_pow_config();
} else if (strcmp(key, "nip40_expiration_enabled") == 0 ||
strcmp(key, "nip40_expiration_strict") == 0 ||
strcmp(key, "nip40_expiration_filter") == 0 ||
strcmp(key, "nip40_expiration_grace_period") == 0) {
log_info("Expiration configuration changed - reinitializing expiration system");
init_expiration_config();
} else if (strcmp(key, "relay_description") == 0 ||
strcmp(key, "relay_contact") == 0 ||
strcmp(key, "relay_software") == 0 ||
strcmp(key, "relay_version") == 0 ||
strcmp(key, "max_message_length") == 0 ||
strcmp(key, "max_event_tags") == 0 ||
strcmp(key, "max_content_length") == 0) {
log_info("Relay information changed - reinitializing relay info");
init_relay_info();
}
}
// Add successful config to response array
cJSON* success_config = cJSON_CreateObject();
cJSON_AddStringToObject(success_config, "key", key);
@@ -3808,15 +3944,16 @@ int handle_config_update_unified(cJSON* event, char* error_message, size_t error
cJSON_AddStringToObject(success_config, "data_type", data_type);
cJSON_AddStringToObject(success_config, "category", category);
cJSON_AddStringToObject(success_config, "status", "updated");
cJSON_AddBoolToObject(success_config, "requires_restart", requires_restart);
cJSON_AddItemToArray(processed_configs, success_config);
log_success("Config field updated successfully");
printf(" Updated: %s = %s\n", key, value);
printf(" Updated: %s = %s (restart: %s)\n", key, value, requires_restart ? "yes" : "no");
} else {
log_error("Failed to update config field in database");
printf(" Failed to update: %s = %s\n", key, value);
validation_errors++;
// Add failed config to response array
cJSON* failed_config = cJSON_CreateObject();
cJSON_AddStringToObject(failed_config, "key", key);
@@ -4162,9 +4299,17 @@ int populate_config_table_from_event(const cJSON* event) {
category = "limits";
}
// Determine if requires restart
// Determine if requires restart (0 = dynamic, 1 = restart required)
int requires_restart = 0;
if (strcmp(key, "relay_port") == 0) {
// Restart required configs
if (strcmp(key, "relay_port") == 0 ||
strcmp(key, "max_connections") == 0 ||
strcmp(key, "auth_enabled") == 0 ||
strcmp(key, "nip42_auth_required") == 0 ||
strcmp(key, "nip42_auth_required_kinds") == 0 ||
strcmp(key, "nip42_challenge_timeout") == 0 ||
strcmp(key, "database_path") == 0) {
requires_restart = 1;
}

View File

@@ -120,6 +120,9 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length);
// Forward declaration for admin event processing (kind 23456)
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
// Forward declaration for NIP-45 COUNT message handling
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss);
// Forward declaration for enhanced admin event authorization
int is_authorized_admin_event(cJSON* event, char* error_message, size_t error_size);
@@ -881,7 +884,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
// Build SQL query based on filter - exclude ephemeral events (kinds 20000-29999) from historical queries
char sql[1024] = "SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND kind < 20000";
char sql[1024] = "SELECT id, pubkey, created_at, kind, content, sig, tags FROM events WHERE 1=1 AND (kind < 20000 OR kind >= 30000)";
char* sql_ptr = sql + strlen(sql);
int remaining = sizeof(sql) - strlen(sql);
@@ -972,6 +975,43 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
}
}
// Handle tag filters (#e, #p, #t, etc.)
cJSON* filter_item = NULL;
cJSON_ArrayForEach(filter_item, filter) {
const char* filter_key = filter_item->string;
if (filter_key && filter_key[0] == '#' && strlen(filter_key) > 1) {
// This is a tag filter like "#e", "#p", etc.
const char* tag_name = filter_key + 1; // Get the tag name (e, p, t, type, etc.)
if (cJSON_IsArray(filter_item)) {
int tag_value_count = cJSON_GetArraySize(filter_item);
if (tag_value_count > 0) {
// Use EXISTS with LIKE to check for matching tags
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = '%s' AND json_extract(value, '$[1]') IN (", tag_name);
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int i = 0; i < tag_value_count; i++) {
cJSON* tag_value = cJSON_GetArrayItem(filter_item, i);
if (cJSON_IsString(tag_value)) {
if (i > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "'%s'", cJSON_GetStringValue(tag_value));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
snprintf(sql_ptr, remaining, "))");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
}
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
if (since && cJSON_IsNumber(since)) {

View File

@@ -12,10 +12,10 @@
#define MAIN_H
// Version information (auto-updated by build_and_push.sh)
#define VERSION "v0.4.2"
#define VERSION "v0.4.4"
#define VERSION_MAJOR 0
#define VERSION_MINOR 4
#define VERSION_PATCH 2
#define VERSION_PATCH 4
// Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay"

View File

@@ -74,6 +74,7 @@ int is_event_expired(cJSON* event, time_t current_time);
// Forward declarations for subscription handling
int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss);
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss);
// Forward declarations for NOTICE message support
void send_notice_message(struct lws* wsi, const char* message);
@@ -619,6 +620,41 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
}
cJSON_Delete(eose_response);
}
} else if (strcmp(msg_type, "COUNT") == 0) {
// Check NIP-42 authentication for COUNT requests if required
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
if (!pss->auth_challenge_sent) {
send_nip42_auth_challenge(wsi, pss);
} else {
send_notice_message(wsi, "NIP-42 authentication required for count requests");
log_warning("COUNT rejected: NIP-42 authentication required");
}
cJSON_Delete(json);
free(message);
return 0;
}
// Handle COUNT message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
// Create array of filter objects from position 2 onwards
cJSON* filters = cJSON_CreateArray();
int json_size = cJSON_GetArraySize(json);
for (int i = 2; i < json_size; i++) {
cJSON* filter = cJSON_GetArrayItem(json, i);
if (filter) {
cJSON_AddItemToArray(filters, cJSON_Duplicate(filter, 1));
}
}
handle_count_message(subscription_id, filters, wsi, pss);
// Clean up the filters array we created
cJSON_Delete(filters);
}
} else if (strcmp(msg_type, "CLOSE") == 0) {
// Handle CLOSE message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
@@ -899,3 +935,226 @@ int start_websocket_relay(int port_override, int strict_port) {
log_success("WebSocket relay shut down cleanly");
return 0;
}
// Handle NIP-45 COUNT message
int handle_count_message(const char* sub_id, cJSON* filters, struct lws *wsi, struct per_session_data *pss) {
(void)pss; // Suppress unused parameter warning
log_info("Handling COUNT message for subscription");
if (!cJSON_IsArray(filters)) {
log_error("COUNT filters is not an array");
return 0;
}
int total_count = 0;
// Process each filter in the array
for (int i = 0; i < cJSON_GetArraySize(filters); i++) {
cJSON* filter = cJSON_GetArrayItem(filters, i);
if (!filter || !cJSON_IsObject(filter)) {
log_warning("Invalid filter object in COUNT");
continue;
}
// Build SQL COUNT query based on filter - exclude ephemeral events (kinds 20000-29999) from historical queries
char sql[1024] = "SELECT COUNT(*) FROM events WHERE 1=1 AND (kind < 20000 OR kind >= 30000)";
char* sql_ptr = sql + strlen(sql);
int remaining = sizeof(sql) - strlen(sql);
// Note: Expiration filtering will be done at application level
// after retrieving events to ensure compatibility with all SQLite versions
// Handle kinds filter
cJSON* kinds = cJSON_GetObjectItem(filter, "kinds");
if (kinds && cJSON_IsArray(kinds)) {
int kind_count = cJSON_GetArraySize(kinds);
if (kind_count > 0) {
snprintf(sql_ptr, remaining, " AND kind IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int k = 0; k < kind_count; k++) {
cJSON* kind = cJSON_GetArrayItem(kinds, k);
if (cJSON_IsNumber(kind)) {
if (k > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "%d", (int)cJSON_GetNumberValue(kind));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
snprintf(sql_ptr, remaining, ")");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
// Handle authors filter
cJSON* authors = cJSON_GetObjectItem(filter, "authors");
if (authors && cJSON_IsArray(authors)) {
int author_count = cJSON_GetArraySize(authors);
if (author_count > 0) {
snprintf(sql_ptr, remaining, " AND pubkey IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int a = 0; a < author_count; a++) {
cJSON* author = cJSON_GetArrayItem(authors, a);
if (cJSON_IsString(author)) {
if (a > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "'%s'", cJSON_GetStringValue(author));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
snprintf(sql_ptr, remaining, ")");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = cJSON_GetArraySize(ids);
if (id_count > 0) {
snprintf(sql_ptr, remaining, " AND id IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int i = 0; i < id_count; i++) {
cJSON* id = cJSON_GetArrayItem(ids, i);
if (cJSON_IsString(id)) {
if (i > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "'%s'", cJSON_GetStringValue(id));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
snprintf(sql_ptr, remaining, ")");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
// Handle tag filters (#e, #p, #t, etc.)
cJSON* filter_item = NULL;
cJSON_ArrayForEach(filter_item, filter) {
const char* filter_key = filter_item->string;
if (filter_key && filter_key[0] == '#' && strlen(filter_key) > 1) {
// This is a tag filter like "#e", "#p", etc.
const char* tag_name = filter_key + 1; // Get the tag name (e, p, t, type, etc.)
if (cJSON_IsArray(filter_item)) {
int tag_value_count = cJSON_GetArraySize(filter_item);
if (tag_value_count > 0) {
// Use EXISTS with JSON extraction to check for matching tags
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = '%s' AND json_extract(value, '$[1]') IN (", tag_name);
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int i = 0; i < tag_value_count; i++) {
cJSON* tag_value = cJSON_GetArrayItem(filter_item, i);
if (cJSON_IsString(tag_value)) {
if (i > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "'%s'", cJSON_GetStringValue(tag_value));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
snprintf(sql_ptr, remaining, "))");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
}
}
}
// Handle since filter
cJSON* since = cJSON_GetObjectItem(filter, "since");
if (since && cJSON_IsNumber(since)) {
snprintf(sql_ptr, remaining, " AND created_at >= %ld", (long)cJSON_GetNumberValue(since));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
// Handle until filter
cJSON* until = cJSON_GetObjectItem(filter, "until");
if (until && cJSON_IsNumber(until)) {
snprintf(sql_ptr, remaining, " AND created_at <= %ld", (long)cJSON_GetNumberValue(until));
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
// Debug: Log the SQL query being executed
char debug_msg[1280];
snprintf(debug_msg, sizeof(debug_msg), "Executing COUNT SQL: %s", sql);
log_info(debug_msg);
// Execute count query
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "Failed to prepare COUNT query: %s", sqlite3_errmsg(g_db));
log_error(error_msg);
continue;
}
int filter_count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
filter_count = sqlite3_column_int(stmt, 0);
}
char count_debug[128];
snprintf(count_debug, sizeof(count_debug), "Filter %d returned count: %d", i + 1, filter_count);
log_info(count_debug);
sqlite3_finalize(stmt);
total_count += filter_count;
}
char total_debug[128];
snprintf(total_debug, sizeof(total_debug), "Total COUNT result: %d", total_count);
log_info(total_debug);
// Send COUNT response - NIP-45 format: ["COUNT", <subscription_id>, {"count": <count>}]
cJSON* count_response = cJSON_CreateArray();
cJSON_AddItemToArray(count_response, cJSON_CreateString("COUNT"));
cJSON_AddItemToArray(count_response, cJSON_CreateString(sub_id));
// Create count object as per NIP-45 specification
cJSON* count_obj = cJSON_CreateObject();
cJSON_AddNumberToObject(count_obj, "count", total_count);
cJSON_AddItemToArray(count_response, count_obj);
char *count_str = cJSON_Print(count_response);
if (count_str) {
size_t count_len = strlen(count_str);
unsigned char *buf = malloc(LWS_PRE + count_len);
if (buf) {
memcpy(buf + LWS_PRE, count_str, count_len);
lws_write(wsi, buf + LWS_PRE, count_len, LWS_WRITE_TEXT);
free(buf);
}
free(count_str);
}
cJSON_Delete(count_response);
return total_count;
}

133
test_dynamic_config.sh Executable file
View File

@@ -0,0 +1,133 @@
#!/bin/bash
# Test dynamic config updates without restart
set -e
# Configuration from relay startup
ADMIN_PRIVKEY="ddea442930976541e199a05248eb6cd92f2a65ba366a883a8f6880add9bdc9c9"
RELAY_PUBKEY="1bd4a5e2e32401737f8c16cc0dfa89b93f25f395770a2896fe78c9fb61582dfc"
RELAY_URL="ws://localhost:8888"
# Colors
GREEN='\033[0;32m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Check if nak is available
if ! command -v nak &> /dev/null; then
log_error "nak command not found. Please install nak first."
exit 1
fi
log_info "Testing dynamic config updates without restart..."
# Test 1: Check current NIP-11 info
log_info "Checking current NIP-11 relay info..."
CURRENT_DESC=$(curl -s -H "Accept: application/nostr+json" http://localhost:8888 | jq -r '.description')
log_info "Current description: $CURRENT_DESC"
# Test 2: Update relay description dynamically
NEW_DESC="Dynamic Config Test - Updated at $(date)"
log_info "Updating relay description to: $NEW_DESC"
COMMAND="[\"config_update\", [{\"key\": \"relay_description\", \"value\": \"$NEW_DESC\", \"data_type\": \"string\", \"category\": \"relay\"}]]"
# Encrypt the command
ENCRYPTED_COMMAND=$(nak encrypt "$COMMAND" --sec "$ADMIN_PRIVKEY" --recipient-pubkey "$RELAY_PUBKEY")
if [ -z "$ENCRYPTED_COMMAND" ]; then
log_error "Failed to encrypt config update command"
exit 1
fi
# Create admin event
ADMIN_EVENT=$(nak event \
--kind 23456 \
--content "$ENCRYPTED_COMMAND" \
--sec "$ADMIN_PRIVKEY" \
--tag "p=$RELAY_PUBKEY")
# Send the admin command
log_info "Sending config update command..."
ADMIN_RESULT=$(echo "$ADMIN_EVENT" | nak event "$RELAY_URL")
if echo "$ADMIN_RESULT" | grep -q "error\|failed\|denied"; then
log_error "Failed to send config update: $ADMIN_RESULT"
exit 1
fi
log_success "Config update command sent successfully"
# Wait for processing
sleep 3
# Test 3: Check if NIP-11 info updated without restart
log_info "Checking if NIP-11 info was updated without restart..."
UPDATED_DESC=$(curl -s -H "Accept: application/nostr+json" http://localhost:8888 | jq -r '.description')
if [ "$UPDATED_DESC" = "$NEW_DESC" ]; then
log_success "SUCCESS: Relay description updated dynamically without restart!"
log_success "Old: $CURRENT_DESC"
log_success "New: $UPDATED_DESC"
else
log_error "FAILED: Relay description was not updated"
log_error "Expected: $NEW_DESC"
log_error "Got: $UPDATED_DESC"
exit 1
fi
# Test 4: Test another dynamic config - max_subscriptions_per_client
log_info "Testing another dynamic config: max_subscriptions_per_client"
# Get current value from database
OLD_LIMIT=$(sqlite3 build/*.db "SELECT value FROM config WHERE key = 'max_subscriptions_per_client';" 2>/dev/null || echo "25")
log_info "Current max_subscriptions_per_client: $OLD_LIMIT"
NEW_LIMIT=50
COMMAND2="[\"config_update\", [{\"key\": \"max_subscriptions_per_client\", \"value\": \"$NEW_LIMIT\", \"data_type\": \"integer\", \"category\": \"limits\"}]]"
ENCRYPTED_COMMAND2=$(nak encrypt "$COMMAND2" --sec "$ADMIN_PRIVKEY" --recipient-pubkey "$RELAY_PUBKEY")
ADMIN_EVENT2=$(nak event \
--kind 23456 \
--content "$ENCRYPTED_COMMAND2" \
--sec "$ADMIN_PRIVKEY" \
--tag "p=$RELAY_PUBKEY")
log_info "Updating max_subscriptions_per_client to $NEW_LIMIT..."
ADMIN_RESULT2=$(echo "$ADMIN_EVENT2" | nak event "$RELAY_URL")
if echo "$ADMIN_RESULT2" | grep -q "error\|failed\|denied"; then
log_error "Failed to send second config update: $ADMIN_RESULT2"
exit 1
fi
sleep 3
# Check updated value from database
UPDATED_LIMIT=$(sqlite3 build/*.db "SELECT value FROM config WHERE key = 'max_subscriptions_per_client';" 2>/dev/null || echo "25")
if [ "$UPDATED_LIMIT" = "$NEW_LIMIT" ]; then
log_success "SUCCESS: max_subscriptions_per_client updated dynamically!"
log_success "Old: $OLD_LIMIT, New: $UPDATED_LIMIT"
else
log_error "FAILED: max_subscriptions_per_client was not updated"
log_error "Expected: $NEW_LIMIT, Got: $UPDATED_LIMIT"
fi
log_success "Dynamic config update testing completed successfully!"

360
tests/45_nip_test.sh Executable file
View File

@@ -0,0 +1,360 @@
#!/bin/bash
# NIP-45 COUNT Message Test - Test counting functionality
# Tests COUNT messages with various filters to verify correct event counting
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 counting tests
declare -a REGULAR_EVENT_IDS=()
declare -a REPLACEABLE_EVENT_IDS=()
declare -a EPHEMERAL_EVENT_IDS=()
declare -a ADDRESSABLE_EVENT_IDS=()
# Helper function to publish event and extract ID
publish_event() {
local event_json="$1"
local event_type="$2"
local description="$3"
# 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}...)"
# Store event ID in appropriate array
case "$event_type" in
"regular") REGULAR_EVENT_IDS+=("$event_id") ;;
"replaceable") REPLACEABLE_EVENT_IDS+=("$event_id") ;;
"ephemeral") EPHEMERAL_EVENT_IDS+=("$event_id") ;;
"addressable") ADDRESSABLE_EVENT_IDS+=("$event_id") ;;
esac
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 and check response
test_count() {
local sub_id="$1"
local filter="$2"
local description="$3"
local expected_count="$4"
print_step "Testing 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
}
# Main test function
run_count_test() {
print_header "NIP-45 COUNT 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 1: Publishing Test Events"
# Test 1: Regular Events (kind 1)
print_step "Creating regular events (kind 1)..."
local regular1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Regular event #1 for counting" -k 1 --ts $(($(date +%s) - 100)) -t "type=regular" -t "test=nip45" 2>/dev/null)
local regular2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Regular event #2 for counting" -k 1 --ts $(($(date +%s) - 90)) -t "type=regular" -t "test=nip45" 2>/dev/null)
local regular3=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Regular event #3 for counting" -k 1 --ts $(($(date +%s) - 80)) -t "type=regular" -t "test=nip45" 2>/dev/null)
publish_event "$regular1" "regular" "Regular event #1"
publish_event "$regular2" "regular" "Regular event #2"
publish_event "$regular3" "regular" "Regular event #3"
# Test 2: Replaceable Events (kind 0 - metadata)
print_step "Creating replaceable events (kind 0)..."
local replaceable1=$(nak event --sec "$TEST_PRIVATE_KEY" -c '{"name":"Test User","about":"Testing NIP-45 COUNT"}' -k 0 --ts $(($(date +%s) - 70)) -t "type=replaceable" 2>/dev/null)
local replaceable2=$(nak event --sec "$TEST_PRIVATE_KEY" -c '{"name":"Test User Updated","about":"Updated for NIP-45"}' -k 0 --ts $(($(date +%s) - 60)) -t "type=replaceable" 2>/dev/null)
publish_event "$replaceable1" "replaceable" "Replaceable event #1 (metadata)"
publish_event "$replaceable2" "replaceable" "Replaceable event #2 (metadata update)"
# Test 3: Ephemeral Events (kind 20000+) - should NOT be counted
print_step "Creating ephemeral events (kind 20001)..."
local ephemeral1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Ephemeral event - should not be counted" -k 20001 --ts $(date +%s) -t "type=ephemeral" 2>/dev/null)
publish_event "$ephemeral1" "ephemeral" "Ephemeral event (should not be counted)"
# Test 4: Addressable Events (kind 30000+)
print_step "Creating addressable events (kind 30001)..."
local addressable1=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Addressable event #1" -k 30001 --ts $(($(date +%s) - 50)) -t "d=test-article" -t "type=addressable" 2>/dev/null)
local addressable2=$(nak event --sec "$TEST_PRIVATE_KEY" -c "Addressable event #2" -k 30001 --ts $(($(date +%s) - 40)) -t "d=test-article" -t "type=addressable" 2>/dev/null)
publish_event "$addressable1" "addressable" "Addressable event #1"
publish_event "$addressable2" "addressable" "Addressable event #2"
# Brief pause to let events settle
sleep 2
print_header "PHASE 2: Testing COUNT Messages"
local test_failures=0
# Test 1: Count all events
if ! test_count "count_all" '{}' "Count all events" "any"; then
((test_failures++))
fi
# Test 2: Count events by kind
if ! test_count "count_kind1" '{"kinds":[1]}' "Count kind 1 events" "3"; then
((test_failures++))
fi
if ! test_count "count_kind0" '{"kinds":[0]}' "Count kind 0 events" "1"; then
((test_failures++))
fi
if ! test_count "count_kind30001" '{"kinds":[30001]}' "Count kind 30001 events" "1"; then
((test_failures++))
fi
# Test 3: Count events by author (pubkey)
local test_pubkey=$(echo "$regular1" | jq -r '.pubkey' 2>/dev/null)
if ! test_count "count_author" "{\"authors\":[\"$test_pubkey\"]}" "Count events by specific author" "5"; then
((test_failures++))
fi
# Test 4: Count recent events (time-based)
local recent_timestamp=$(($(date +%s) - 200))
if ! test_count "count_recent" "{\"since\":$recent_timestamp}" "Count recent events" "any"; then
((test_failures++))
fi
# Test 5: Count events with specific tags
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "3"; then
((test_failures++))
fi
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "3"; then
((test_failures++))
fi
# Test 6: Count multiple kinds
if ! test_count "count_multi_kinds" '{"kinds":[0,1]}' "Count multiple kinds (0,1)" "4"; then
((test_failures++))
fi
# Test 7: Count with time range
local start_time=$(($(date +%s) - 120))
local end_time=$(($(date +%s) - 60))
if ! test_count "count_time_range" "{\"since\":$start_time,\"until\":$end_time}" "Count events in time range" "any"; then
((test_failures++))
fi
# Test 8: Count specific event IDs
if [[ ${#REGULAR_EVENT_IDS[@]} -gt 0 ]]; then
local test_event_id="${REGULAR_EVENT_IDS[0]}"
if ! test_count "count_specific_id" "{\"ids\":[\"$test_event_id\"]}" "Count specific event ID" "1"; then
((test_failures++))
fi
fi
# Test 9: Count with multiple filters combined
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "3"; then
((test_failures++))
fi
# Test 10: Count ephemeral events (should be 0 since they're not stored)
if ! test_count "count_ephemeral" '{"kinds":[20001]}' "Count ephemeral events (should be 0)" "0"; then
((test_failures++))
fi
# Test 11: Count with limit (should still count all matching, ignore limit)
if ! test_count "count_with_limit" '{"kinds":[1],"limit":1}' "Count with limit (should ignore limit)" "3"; then
((test_failures++))
fi
# Test 12: Count non-existent kind
if ! test_count "count_nonexistent" '{"kinds":[99999]}' "Count non-existent kind" "0"; then
((test_failures++))
fi
# Test 13: Count with empty filter
if ! test_count "count_empty_filter" '{}' "Count with empty filter" "any"; then
((test_failures++))
fi
# Report test results
if [[ $test_failures -gt 0 ]]; then
print_error "COUNT TESTS FAILED: $test_failures test(s) failed"
return 1
else
print_success "All COUNT tests passed"
fi
print_header "PHASE 3: Database Verification"
# Check what's actually stored in the database
print_step "Verifying database contents..."
if command -v sqlite3 &> /dev/null; then
# Find the database file (should be in build/ directory with relay pubkey as filename)
local db_file=""
if [[ -d "../build" ]]; then
db_file=$(find ../build -name "*.db" -type f | head -1)
fi
if [[ -n "$db_file" && -f "$db_file" ]]; then
print_info "Events by type in database ($db_file):"
sqlite3 "$db_file" "SELECT event_type, COUNT(*) as count FROM events GROUP BY event_type;" 2>/dev/null | while read line; do
echo " $line"
done
print_info "Total events in database:"
sqlite3 "$db_file" "SELECT COUNT(*) FROM events;" 2>/dev/null
print_success "Database verification complete"
else
print_warning "Database file not found in build/ directory"
print_info "Expected database files: build/*.db (named after relay pubkey)"
fi
else
print_warning "sqlite3 not available for database verification"
fi
return 0
}
# Run the COUNT test
print_header "Starting NIP-45 COUNT Message Test Suite"
echo
if run_count_test; then
echo
print_success "All NIP-45 COUNT tests completed successfully!"
print_info "The C-Relay COUNT functionality is working correctly"
print_info "✅ COUNT messages are processed and return correct event counts"
echo
exit 0
else
echo
print_error "❌ NIP-45 COUNT TESTS FAILED!"
print_error "The COUNT functionality has issues that need to be fixed"
echo
exit 1
fi