Files
c-relay/src/websockets.c
2025-10-29 07:39:08 -04:00

2216 lines
108 KiB
C

// Define _GNU_SOURCE to ensure all POSIX features are available
#define _GNU_SOURCE
// Includes
#include <stdio.h>
#include "debug.h"
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <time.h>
#include <pthread.h>
#include <sqlite3.h>
// Include libwebsockets after pthread.h to ensure pthread_rwlock_t is defined
#include <libwebsockets.h>
#include <errno.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
// Include nostr_core_lib for Nostr functionality
#include "../nostr_core_lib/cjson/cJSON.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nip013.h" // NIP-13: Proof of Work
#include "config.h" // Configuration management system
#include "sql_schema.h" // Embedded database schema
#include "websockets.h" // WebSocket structures and constants
#include "subscriptions.h" // Subscription structures and functions
#include "embedded_web_content.h" // Embedded web content
#include "api.h" // API for embedded files
#include "dm_admin.h" // DM admin functions including NIP-17
// Forward declarations for logging functions
// Forward declarations for configuration functions
const char* get_config_value(const char* key);
int get_config_int(const char* key, int default_value);
int get_config_bool(const char* key, int default_value);
// Forward declarations for NIP-42 authentication functions
int is_nip42_auth_globally_required(void);
int is_nip42_auth_required_for_kind(int kind);
void send_nip42_auth_challenge(struct lws* wsi, struct per_session_data* pss);
void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* pss, cJSON* auth_event);
void handle_nip42_auth_challenge_response(struct lws* wsi, struct per_session_data* pss, const char* challenge);
// Forward declaration for status posts
int generate_and_post_status_event(void);
// Forward declarations for NIP-11 relay information handling
int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
// Forward declarations for embedded file handling
int handle_embedded_file_writeable(struct lws* wsi);
// Forward declarations for database functions
int store_event(cJSON* event);
// Forward declarations for subscription management
int broadcast_event_to_subscriptions(cJSON* event);
int add_subscription_to_manager(struct subscription* sub);
int remove_subscription_from_manager(const char* sub_id, struct lws* wsi);
// Forward declarations for event handling
int handle_event_message(cJSON* event, char* error_message, size_t error_size);
int nostr_validate_unified_request(const char* json_string, size_t json_length);
// Forward declarations for admin event processing
int process_admin_event_in_config(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
int is_authorized_admin_event(cJSON* event, char* error_message, size_t error_size);
// Forward declarations for DM stats command handling
int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_size, struct lws* wsi);
// Forward declarations for NIP-09 deletion request handling
int handle_deletion_request(cJSON* event, char* error_message, size_t error_size);
// Forward declarations for NIP-13 PoW handling
int validate_event_pow(cJSON* event, char* error_message, size_t error_size);
// Forward declarations for NIP-40 expiration handling
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 rate limiting
int is_client_rate_limited_for_malformed_requests(struct per_session_data *pss);
void record_malformed_request(struct per_session_data *pss);
// Forward declarations for filter validation
int validate_filter_array(cJSON* filters, char* error_message, size_t error_size);
// Forward declarations for NOTICE message support
void send_notice_message(struct lws* wsi, const char* message);
// Configuration functions from config.c
extern int get_config_bool(const char* key, int default_value);
// Forward declarations for global state
extern sqlite3* g_db;
extern int g_server_running;
extern volatile sig_atomic_t g_shutdown_flag;
extern int g_restart_requested;
extern struct lws_context *ws_context;
// Global subscription manager
struct subscription_manager g_subscription_manager;
// Message queue functions for proper libwebsockets pattern
/**
* Queue a message for WebSocket writing following libwebsockets' proper pattern.
* This function adds messages to a per-session queue and requests writeable callback.
*
* @param wsi WebSocket instance
* @param pss Per-session data containing message queue
* @param message Message string to write
* @param length Length of message string
* @param type LWS_WRITE_* type (LWS_WRITE_TEXT, etc.)
* @return 0 on success, -1 on error
*/
int queue_message(struct lws* wsi, struct per_session_data* pss, const char* message, size_t length, enum lws_write_protocol type) {
if (!wsi || !pss || !message || length == 0) {
DEBUG_ERROR("queue_message: invalid parameters");
return -1;
}
// Allocate message queue node
struct message_queue_node* node = malloc(sizeof(struct message_queue_node));
if (!node) {
DEBUG_ERROR("queue_message: failed to allocate queue node");
return -1;
}
// Allocate buffer with LWS_PRE space
size_t buffer_size = LWS_PRE + length;
unsigned char* buffer = malloc(buffer_size);
if (!buffer) {
DEBUG_ERROR("queue_message: failed to allocate message buffer");
free(node);
return -1;
}
// Copy message to buffer with LWS_PRE offset
memcpy(buffer + LWS_PRE, message, length);
// Initialize node
node->data = buffer;
node->length = length;
node->type = type;
node->next = NULL;
// Add to queue (thread-safe)
pthread_mutex_lock(&pss->session_lock);
if (!pss->message_queue_head) {
// Queue was empty
pss->message_queue_head = node;
pss->message_queue_tail = node;
} else {
// Add to end of queue
pss->message_queue_tail->next = node;
pss->message_queue_tail = node;
}
pss->message_queue_count++;
pthread_mutex_unlock(&pss->session_lock);
// Request writeable callback (only if not already requested)
if (!pss->writeable_requested) {
pss->writeable_requested = 1;
lws_callback_on_writable(wsi);
}
DEBUG_TRACE("Queued message: len=%zu, queue_count=%d", length, pss->message_queue_count);
return 0;
}
/**
* Process message queue when the socket becomes writeable.
* This function is called from LWS_CALLBACK_SERVER_WRITEABLE.
*
* @param wsi WebSocket instance
* @param pss Per-session data containing message queue
* @return 0 on success, -1 on error
*/
int process_message_queue(struct lws* wsi, struct per_session_data* pss) {
if (!wsi || !pss) {
DEBUG_ERROR("process_message_queue: invalid parameters");
return -1;
}
// Get next message from queue (thread-safe)
pthread_mutex_lock(&pss->session_lock);
struct message_queue_node* node = pss->message_queue_head;
if (!node) {
// Queue is empty
pss->writeable_requested = 0;
pthread_mutex_unlock(&pss->session_lock);
return 0;
}
// Remove from queue
pss->message_queue_head = node->next;
if (!pss->message_queue_head) {
pss->message_queue_tail = NULL;
}
pss->message_queue_count--;
pthread_mutex_unlock(&pss->session_lock);
// Write message (libwebsockets handles partial writes internally)
int write_result = lws_write(wsi, node->data + LWS_PRE, node->length, node->type);
// Free node resources
free(node->data);
free(node);
if (write_result < 0) {
DEBUG_ERROR("process_message_queue: write failed, result=%d", write_result);
return -1;
}
DEBUG_TRACE("Processed message: wrote %d bytes, remaining in queue: %d", write_result, pss->message_queue_count);
// If queue not empty, request another callback
pthread_mutex_lock(&pss->session_lock);
if (pss->message_queue_head) {
lws_callback_on_writable(wsi);
} else {
pss->writeable_requested = 0;
}
pthread_mutex_unlock(&pss->session_lock);
return 0;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// WEBSOCKET PROTOCOL
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// WebSocket callback function for Nostr relay protocol
static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reason,
void *user, void *in, size_t len) {
struct per_session_data *pss = (struct per_session_data *)user;
switch (reason) {
case LWS_CALLBACK_HTTP:
// Handle HTTP requests
{
char *requested_uri = (char *)in;
// Check if this is an OPTIONS request
char method[16] = {0};
int method_len = lws_hdr_copy(wsi, method, sizeof(method) - 1, WSI_TOKEN_GET_URI);
if (method_len > 0) {
method[method_len] = '\0';
if (strcmp(method, "OPTIONS") == 0) {
// Handle OPTIONS request with CORS headers
unsigned char buf[LWS_PRE + 1024];
unsigned char *p = &buf[LWS_PRE];
unsigned char *start = p;
unsigned char *end = &buf[sizeof(buf) - 1];
if (lws_add_http_header_status(wsi, HTTP_STATUS_OK, &p, end)) return -1;
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-origin:", (unsigned char*)"*", 1, &p, end)) return -1;
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-headers:", (unsigned char*)"content-type, accept", 20, &p, end)) return -1;
if (lws_add_http_header_by_name(wsi, (unsigned char*)"access-control-allow-methods:", (unsigned char*)"GET, OPTIONS", 12, &p, end)) return -1;
if (lws_add_http_header_by_name(wsi, (unsigned char*)"connection:", (unsigned char*)"close", 5, &p, end)) return -1;
if (lws_finalize_http_header(wsi, &p, end)) return -1;
if (lws_write(wsi, start, p - start, LWS_WRITE_HTTP_HEADERS) < 0) return -1;
return 0;
}
}
// Check if this is a GET request to the root path
if (strcmp(requested_uri, "/") == 0) {
// Check if this is a WebSocket upgrade request
char upgrade_header[64] = {0};
int upgrade_len = lws_hdr_copy(wsi, upgrade_header, sizeof(upgrade_header) - 1, WSI_TOKEN_UPGRADE);
if (upgrade_len > 0) {
upgrade_header[upgrade_len] = '\0';
if (strstr(upgrade_header, "websocket") != NULL) {
DEBUG_LOG("WebSocket upgrade request - allowing connection");
return 0;
}
}
// Not a WebSocket upgrade, check for NIP-11 request
char accept_header[256] = {0};
int header_len = lws_hdr_copy(wsi, accept_header, sizeof(accept_header) - 1, WSI_TOKEN_HTTP_ACCEPT);
if (header_len > 0) {
accept_header[header_len] = '\0';
// Check if this is a NIP-11 request
int is_nip11_request = (strstr(accept_header, "application/nostr+json") != NULL);
if (is_nip11_request) {
// Handle NIP-11 request
if (handle_nip11_http_request(wsi, accept_header) == 0) {
return 0; // Successfully handled
}
}
}
// Root path without NIP-11 Accept header and not WebSocket - return 404
DEBUG_WARN("Rejecting root path request - not WebSocket upgrade and not NIP-11");
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
// Check for embedded API files
if (handle_embedded_file_request(wsi, requested_uri) == 0) {
return 0; // Successfully handled
}
// Return 404 for other paths
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1;
}
case LWS_CALLBACK_HTTP_WRITEABLE:
// Handle HTTP body transmission for NIP-11 or embedded files
{
void* user_data = lws_wsi_user(wsi);
if (user_data) {
int type = *(int*)user_data;
if (type == 0) {
// NIP-11
struct nip11_session_data* session_data = (struct nip11_session_data*)user_data;
if (session_data->headers_sent && !session_data->body_sent) {
// Allocate buffer for JSON body transmission (no LWS_PRE needed for body)
unsigned char *json_buf = malloc(session_data->json_length);
if (!json_buf) {
DEBUG_ERROR("Failed to allocate buffer for NIP-11 body transmission");
// Clean up session data
free(session_data->json_buffer);
free(session_data);
lws_set_wsi_user(wsi, NULL);
return -1;
}
// Copy JSON data to buffer
memcpy(json_buf, session_data->json_buffer, session_data->json_length);
// Write JSON body
int write_result = lws_write(wsi, json_buf, session_data->json_length, LWS_WRITE_HTTP);
// Free the transmission buffer immediately (it's been copied by libwebsockets)
free(json_buf);
if (write_result < 0) {
DEBUG_ERROR("Failed to write NIP-11 JSON body");
// Clean up session data
free(session_data->json_buffer);
free(session_data);
lws_set_wsi_user(wsi, NULL);
return -1;
}
// Mark body as sent and clean up session data
session_data->body_sent = 1;
free(session_data->json_buffer);
free(session_data);
lws_set_wsi_user(wsi, NULL);
return 0; // Close connection after successful transmission
}
} else if (type == 1) {
// Embedded file
return handle_embedded_file_writeable(wsi);
}
}
}
break;
case LWS_CALLBACK_ESTABLISHED:
DEBUG_TRACE("WebSocket connection established");
memset(pss, 0, sizeof(*pss));
pthread_mutex_init(&pss->session_lock, NULL);
// Get real client IP address
char client_ip[CLIENT_IP_MAX_LENGTH];
memset(client_ip, 0, sizeof(client_ip));
// Check if we should trust proxy headers
int trust_proxy = get_config_bool("trust_proxy_headers", 0);
if (trust_proxy) {
// Try to get IP from X-Forwarded-For header first
char x_forwarded_for[CLIENT_IP_MAX_LENGTH];
int header_len = lws_hdr_copy(wsi, x_forwarded_for, sizeof(x_forwarded_for) - 1, WSI_TOKEN_X_FORWARDED_FOR);
if (header_len > 0) {
x_forwarded_for[header_len] = '\0';
// X-Forwarded-For can contain multiple IPs (client, proxy1, proxy2, ...)
// We want the first (leftmost) IP which is the original client
char* comma = strchr(x_forwarded_for, ',');
if (comma) {
*comma = '\0'; // Truncate at first comma
}
// Trim leading/trailing whitespace
char* ip_start = x_forwarded_for;
while (*ip_start == ' ' || *ip_start == '\t') ip_start++;
size_t ip_len = strlen(ip_start);
while (ip_len > 0 && (ip_start[ip_len-1] == ' ' || ip_start[ip_len-1] == '\t')) {
ip_start[--ip_len] = '\0';
}
if (ip_len > 0 && ip_len < CLIENT_IP_MAX_LENGTH) {
strncpy(client_ip, ip_start, CLIENT_IP_MAX_LENGTH - 1);
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
DEBUG_TRACE("Using X-Forwarded-For IP: %s", client_ip);
}
}
// If X-Forwarded-For didn't work, try X-Real-IP
if (client_ip[0] == '\0') {
char x_real_ip[CLIENT_IP_MAX_LENGTH];
header_len = lws_hdr_copy(wsi, x_real_ip, sizeof(x_real_ip) - 1, WSI_TOKEN_HTTP_X_REAL_IP);
if (header_len > 0) {
x_real_ip[header_len] = '\0';
strncpy(client_ip, x_real_ip, CLIENT_IP_MAX_LENGTH - 1);
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
DEBUG_TRACE("Using X-Real-IP: %s", client_ip);
}
}
}
// Fall back to direct connection IP if proxy headers not available or not trusted
if (client_ip[0] == '\0') {
lws_get_peer_simple(wsi, client_ip, sizeof(client_ip));
DEBUG_TRACE("Using direct connection IP: %s", client_ip);
}
// Ensure client_ip is null-terminated and copy safely
client_ip[CLIENT_IP_MAX_LENGTH - 1] = '\0';
size_t ip_len = strlen(client_ip);
size_t copy_len = (ip_len < CLIENT_IP_MAX_LENGTH - 1) ? ip_len : CLIENT_IP_MAX_LENGTH - 1;
memcpy(pss->client_ip, client_ip, copy_len);
pss->client_ip[copy_len] = '\0';
// Record connection establishment time for duration tracking
pss->connection_established = time(NULL);
DEBUG_LOG("WebSocket connection established from %s", pss->client_ip);
// Initialize NIP-42 authentication state
pss->authenticated = 0;
pss->nip42_auth_required_events = get_config_bool("nip42_auth_required_events", 0);
pss->nip42_auth_required_subscriptions = get_config_bool("nip42_auth_required_subscriptions", 0);
pss->auth_challenge_sent = 0;
memset(pss->authenticated_pubkey, 0, sizeof(pss->authenticated_pubkey));
memset(pss->active_challenge, 0, sizeof(pss->active_challenge));
pss->challenge_created = 0;
pss->challenge_expires = 0;
DEBUG_TRACE("WebSocket connection initialization complete");
break;
case LWS_CALLBACK_RECEIVE:
if (len > 0) {
// Check if client is rate limited for malformed requests
if (is_client_rate_limited_for_malformed_requests(pss)) {
send_notice_message(wsi, "error: too many malformed requests - temporarily blocked");
return 0;
}
char *message = malloc(len + 1);
if (message) {
memcpy(message, in, len);
message[len] = '\0';
// Parse JSON message (this is the normal program flow)
cJSON* json = cJSON_Parse(message);
if (json && cJSON_IsArray(json)) {
// Get message type
cJSON* type = cJSON_GetArrayItem(json, 0);
if (type && cJSON_IsString(type)) {
const char* msg_type = cJSON_GetStringValue(type);
if (strcmp(msg_type, "EVENT") == 0) {
// Extract event for kind-specific NIP-42 authentication check
cJSON* event_obj = cJSON_GetArrayItem(json, 1);
if (event_obj && cJSON_IsObject(event_obj)) {
// Extract event kind for kind-specific NIP-42 authentication check
cJSON* kind_obj = cJSON_GetObjectItem(event_obj, "kind");
int event_kind = kind_obj && cJSON_IsNumber(kind_obj) ? (int)cJSON_GetNumberValue(kind_obj) : -1;
// Extract pubkey for debugging
cJSON* pubkey_obj = cJSON_GetObjectItem(event_obj, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : "unknown";
// Check if NIP-42 authentication is required for this event kind or globally
int auth_required = is_nip42_auth_globally_required() || is_nip42_auth_required_for_kind(event_kind);
// Special case: allow kind 14 DMs addressed to relay to bypass auth (admin commands)
int bypass_auth = 0;
if (event_kind == 14 && event_obj && cJSON_IsObject(event_obj)) {
cJSON* tags = cJSON_GetObjectItem(event_obj, "tags");
if (tags && cJSON_IsArray(tags)) {
const char* relay_pubkey = get_config_value("relay_pubkey");
if (relay_pubkey) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
cJSON* tag_value = cJSON_GetArrayItem(tag, 1);
if (tag_name && cJSON_IsString(tag_name) &&
strcmp(cJSON_GetStringValue(tag_name), "p") == 0 &&
tag_value && cJSON_IsString(tag_value) &&
strcmp(cJSON_GetStringValue(tag_value), relay_pubkey) == 0) {
bypass_auth = 1;
break;
}
}
}
}
}
}
// Special case: allow kind 23456 admin events from authorized admin to bypass auth
if (event_kind == 23456 && event_pubkey) {
const char* admin_pubkey = get_config_value("admin_pubkey");
if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
bypass_auth = 1;
} else {
DEBUG_INFO("DEBUG: Kind 23456 event but pubkey mismatch or no admin pubkey");
}
}
if (pss && auth_required && !pss->authenticated && !bypass_auth) {
if (!pss->auth_challenge_sent) {
send_nip42_auth_challenge(wsi, pss);
} else {
char auth_msg[256];
if (event_kind == 4 || event_kind == 14) {
snprintf(auth_msg, sizeof(auth_msg),
"NIP-42 authentication required for direct message events (kind %d)", event_kind);
} else {
snprintf(auth_msg, sizeof(auth_msg),
"NIP-42 authentication required for event kind %d", event_kind);
}
send_notice_message(wsi, auth_msg);
DEBUG_WARN("Event rejected: NIP-42 authentication required for kind");
}
cJSON_Delete(json);
free(message);
return 0;
}
}
// Handle EVENT message
cJSON* event = cJSON_GetArrayItem(json, 1);
if (event && cJSON_IsObject(event)) {
// Extract event JSON string for unified validator
char *event_json_str = cJSON_Print(event);
if (!event_json_str) {
DEBUG_ERROR("Failed to serialize event JSON for validation");
cJSON* error_response = cJSON_CreateArray();
cJSON_AddItemToArray(error_response, cJSON_CreateString("OK"));
cJSON_AddItemToArray(error_response, cJSON_CreateString("unknown"));
cJSON_AddItemToArray(error_response, cJSON_CreateBool(0));
cJSON_AddItemToArray(error_response, cJSON_CreateString("error: failed to process event"));
char *error_str = cJSON_Print(error_response);
if (error_str) {
size_t error_len = strlen(error_str);
// Use proper message queue system instead of direct lws_write
if (queue_message(wsi, pss, error_str, error_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue error response message");
}
free(error_str);
}
cJSON_Delete(error_response);
return 0;
}
// Call unified validator with JSON string
size_t event_json_len = strlen(event_json_str);
int validation_result = nostr_validate_unified_request(event_json_str, event_json_len);
// Map validation result to old result format (0 = success, -1 = failure)
int result = (validation_result == NOSTR_SUCCESS) ? 0 : -1;
// Generate error message based on validation result
char error_message[512] = {0};
if (result != 0) {
switch (validation_result) {
case NOSTR_ERROR_INVALID_INPUT:
strncpy(error_message, "invalid: malformed event structure", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_SIGNATURE:
strncpy(error_message, "invalid: signature verification failed", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_ID:
strncpy(error_message, "invalid: event id verification failed", sizeof(error_message) - 1);
break;
case NOSTR_ERROR_EVENT_INVALID_PUBKEY:
strncpy(error_message, "invalid: invalid pubkey format", sizeof(error_message) - 1);
break;
case -103: // NOSTR_ERROR_EVENT_EXPIRED
strncpy(error_message, "rejected: event expired", sizeof(error_message) - 1);
break;
case -102: // NOSTR_ERROR_NIP42_DISABLED
strncpy(error_message, "auth-required: NIP-42 authentication required", sizeof(error_message) - 1);
break;
case -101: // NOSTR_ERROR_AUTH_REQUIRED
strncpy(error_message, "blocked: pubkey not authorized", sizeof(error_message) - 1);
break;
default:
strncpy(error_message, "error: validation failed", sizeof(error_message) - 1);
break;
}
}
// Cleanup event JSON string
free(event_json_str);
// Check for NIP-70 protected events
if (result == 0) {
// Check if event has protected tag ["-"]
int is_protected_event = 0;
cJSON* tags = cJSON_GetObjectItem(event, "tags");
if (tags && cJSON_IsArray(tags)) {
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 1) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
if (tag_name && cJSON_IsString(tag_name) &&
strcmp(cJSON_GetStringValue(tag_name), "-") == 0) {
is_protected_event = 1;
break;
}
}
}
}
if (is_protected_event) {
// Check if protected events are enabled using config
int protected_events_enabled = get_config_bool("nip70_protected_events_enabled", 0);
if (!protected_events_enabled) {
// Protected events not supported
result = -1;
strncpy(error_message, "blocked: protected events not supported", sizeof(error_message) - 1);
error_message[sizeof(error_message) - 1] = '\0';
DEBUG_WARN("Protected event rejected: protected events not enabled");
} else {
// Protected events enabled - check authentication
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
const char* event_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
if (!pss || !pss->authenticated ||
!event_pubkey || strcmp(pss->authenticated_pubkey, event_pubkey) != 0) {
// Not authenticated or pubkey mismatch
result = -1;
strncpy(error_message, "auth-required: protected event requires authentication", sizeof(error_message) - 1);
error_message[sizeof(error_message) - 1] = '\0';
DEBUG_WARN("Protected event rejected: authentication required");
}
}
}
}
// Check for admin events (kind 23456) and intercept them
if (result == 0) {
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
if (kind_obj && cJSON_IsNumber(kind_obj)) {
int event_kind = (int)cJSON_GetNumberValue(kind_obj);
DEBUG_TRACE("Processing event kind %d", event_kind);
// Log reception of Kind 23456 events
if (event_kind == 23456) {
DEBUG_LOG("Admin event (kind 23456) received");
}
if (event_kind == 23456) {
// Enhanced admin event security - check authorization first
char auth_error[512] = {0};
int auth_result = is_authorized_admin_event(event, auth_error, sizeof(auth_error));
if (auth_result != 0) {
// Authorization failed - log and reject
DEBUG_WARN("Admin event authorization failed");
result = -1;
size_t error_len = strlen(auth_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, auth_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Authorization successful - process through admin API
char admin_error[512] = {0};
int admin_result = process_admin_event_in_config(event, admin_error, sizeof(admin_error), wsi);
// Log results for Kind 23456 events
if (event_kind == 23456) {
if (admin_result != 0) {
char error_result_msg[512];
if (strlen(admin_error) > 0) {
// Safely truncate admin_error if too long
size_t max_error_len = sizeof(error_result_msg) - 50; // Leave room for prefix
size_t error_len = strlen(admin_error);
if (error_len > max_error_len) {
error_len = max_error_len;
}
char truncated_error[512];
memcpy(truncated_error, admin_error, error_len);
truncated_error[error_len] = '\0';
// Use a safer approach to avoid truncation warning
size_t prefix_len = snprintf(error_result_msg, sizeof(error_result_msg),
"ERROR: Kind %d event processing failed: ", event_kind);
if (prefix_len < sizeof(error_result_msg)) {
size_t remaining = sizeof(error_result_msg) - prefix_len;
size_t copy_len = strlen(truncated_error);
if (copy_len >= remaining) {
copy_len = remaining - 1;
}
memcpy(error_result_msg + prefix_len, truncated_error, copy_len);
error_result_msg[prefix_len + copy_len] = '\0';
}
} else {
snprintf(error_result_msg, sizeof(error_result_msg),
"ERROR: Kind %d event processing failed", event_kind);
}
DEBUG_ERROR(error_result_msg);
}
}
if (admin_result != 0) {
DEBUG_ERROR("Failed to process admin event");
result = -1;
size_t error_len = strlen(admin_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, admin_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Admin events are processed by the admin API, not broadcast to subscriptions
}
}
} else if (event_kind == 1059) {
// Check for NIP-17 gift wrap admin messages
char nip17_error[512] = {0};
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
if (!response_event) {
// Check if this is an error or if the command was already handled
if (strlen(nip17_error) > 0) {
// There was an actual error
DEBUG_ERROR("NIP-17 admin message processing failed");
result = -1;
size_t error_len = strlen(nip17_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, nip17_error, copy_len);
error_message[copy_len] = '\0';
} else {
// No error message means the command was already handled (plain text commands)
// Store the original gift wrap event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
}
}
} else {
// Store the original gift wrap event in database (unlike kind 23456)
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store gift wrap event in database");
result = -1;
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
cJSON_Delete(response_event);
} else {
// Broadcast RESPONSE event to matching persistent subscriptions
broadcast_event_to_subscriptions(response_event);
// Clean up response event
cJSON_Delete(response_event);
}
}
} else if (event_kind == 14) {
// Check for DM stats commands addressed to relay
char dm_error[512] = {0};
int dm_result = process_dm_stats_command(event, dm_error, sizeof(dm_error), wsi);
if (dm_result != 0) {
DEBUG_ERROR("DM stats command processing failed");
result = -1;
size_t error_len = strlen(dm_error);
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
memcpy(error_message, dm_error, copy_len);
error_message[copy_len] = '\0';
} else {
// Store the DM event in database
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store DM event in database");
result = -1;
strncpy(error_message, "error: failed to store DM event", sizeof(error_message) - 1);
} else {
// Broadcast DM event to matching persistent subscriptions
broadcast_event_to_subscriptions(event);
}
}
} else {
// Check if this is an ephemeral event (kinds 20000-29999)
// Per NIP-01: ephemeral events are broadcast but never stored
if (event_kind >= 20000 && event_kind < 30000) {
DEBUG_TRACE("Ephemeral event (kind %d) - broadcasting without storage", event_kind);
// Broadcast directly to subscriptions without database storage
broadcast_event_to_subscriptions(event);
} else {
DEBUG_TRACE("Storing regular event in database");
// Regular event - store in database and broadcast
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store event in database");
result = -1;
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
DEBUG_LOG("Event stored and broadcast (kind %d)", event_kind);
// Broadcast event to matching persistent subscriptions
broadcast_event_to_subscriptions(event);
}
}
}
} else {
// Event without valid kind - try normal storage
DEBUG_WARN("Event without valid kind - trying normal storage");
if (store_event(event) != 0) {
DEBUG_ERROR("Failed to store event without kind in database");
result = -1;
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
} else {
broadcast_event_to_subscriptions(event);
}
}
}
// Send OK response
cJSON* event_id = cJSON_GetObjectItem(event, "id");
if (event_id && cJSON_IsString(event_id)) {
cJSON* response = cJSON_CreateArray();
cJSON_AddItemToArray(response, cJSON_CreateString("OK"));
cJSON_AddItemToArray(response, cJSON_CreateString(cJSON_GetStringValue(event_id)));
cJSON_AddItemToArray(response, cJSON_CreateBool(result == 0));
cJSON_AddItemToArray(response, cJSON_CreateString(strlen(error_message) > 0 ? error_message : ""));
char *response_str = cJSON_Print(response);
if (response_str) {
size_t response_len = strlen(response_str);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=OK len=%zu data=%.100s%s",
response_len,
response_str,
response_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
if (queue_message(wsi, pss, response_str, response_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue OK response message");
}
free(response_str);
}
cJSON_Delete(response);
}
}
} else if (strcmp(msg_type, "REQ") == 0) {
// Log the full REQ message for debugging
// Check NIP-42 authentication for REQ subscriptions 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 subscriptions");
DEBUG_WARN("REQ rejected: NIP-42 authentication required");
}
cJSON_Delete(json);
free(message);
return 0;
}
// Handle REQ message
cJSON* sub_id = cJSON_GetArrayItem(json, 1);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
DEBUG_TRACE("Processing REQ message for subscription %s", subscription_id);
// Validate subscription ID before processing
if (!subscription_id) {
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: NULL subscription ID");
record_malformed_request(pss);
cJSON_Delete(json);
free(message);
return 0;
}
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;
}
// Create array of filter objects from position 2 onwards
cJSON* filters = cJSON_CreateArray();
if (!filters) {
send_notice_message(wsi, "error: failed to process filters");
DEBUG_ERROR("REQ failed: could not create filters array");
cJSON_Delete(json);
free(message);
return 0;
}
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));
}
}
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
send_notice_message(wsi, filter_error);
DEBUG_WARN("REQ rejected: invalid filters");
record_malformed_request(pss);
cJSON_Delete(filters);
cJSON_Delete(json);
free(message);
return 0;
}
handle_req_message(subscription_id, filters, wsi, pss);
// Clean up the filters array we created
cJSON_Delete(filters);
DEBUG_LOG("REQ subscription %s processed, sending EOSE", subscription_id);
// Send EOSE (End of Stored Events)
cJSON* eose_response = cJSON_CreateArray();
if (eose_response) {
cJSON_AddItemToArray(eose_response, cJSON_CreateString("EOSE"));
cJSON_AddItemToArray(eose_response, cJSON_CreateString(subscription_id));
char *eose_str = cJSON_Print(eose_response);
if (eose_str) {
size_t eose_len = strlen(eose_str);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=EOSE len=%zu data=%.100s%s",
eose_len,
eose_str,
eose_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
if (queue_message(wsi, pss, eose_str, eose_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue EOSE message");
}
free(eose_str);
}
cJSON_Delete(eose_response);
}
} else {
send_notice_message(wsi, "error: missing or invalid subscription ID in REQ");
DEBUG_WARN("REQ rejected: missing or invalid subscription ID");
}
} 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");
DEBUG_WARN("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));
}
}
// Validate filters before processing
char filter_error[512] = {0};
if (!validate_filter_array(filters, filter_error, sizeof(filter_error))) {
send_notice_message(wsi, filter_error);
DEBUG_WARN("COUNT rejected: invalid filters");
record_malformed_request(pss);
cJSON_Delete(filters);
cJSON_Delete(json);
free(message);
return 0;
}
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);
if (sub_id && cJSON_IsString(sub_id)) {
const char* subscription_id = cJSON_GetStringValue(sub_id);
// Validate subscription ID before processing
if (!subscription_id) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: NULL subscription ID");
cJSON_Delete(json);
free(message);
return 0;
}
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;
}
// CRITICAL FIX: Mark subscription as inactive in global manager FIRST
// This prevents other threads from accessing it during removal
pthread_mutex_lock(&g_subscription_manager.subscriptions_lock);
subscription_t* target_sub = g_subscription_manager.active_subscriptions;
while (target_sub) {
if (strcmp(target_sub->id, subscription_id) == 0 && target_sub->wsi == wsi) {
target_sub->active = 0; // Mark as inactive immediately
break;
}
target_sub = target_sub->next;
}
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Now safe to remove from session list
if (pss) {
pthread_mutex_lock(&pss->session_lock);
struct subscription** current = &pss->subscriptions;
while (*current) {
if (strcmp((*current)->id, subscription_id) == 0) {
struct subscription* to_remove = *current;
*current = to_remove->session_next;
pss->subscription_count--;
break;
}
current = &((*current)->session_next);
}
pthread_mutex_unlock(&pss->session_lock);
}
// Finally remove from global manager (which will free it)
remove_subscription_from_manager(subscription_id, wsi);
// Subscription closed
} else {
send_notice_message(wsi, "error: missing or invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: missing or invalid subscription ID");
}
} else if (strcmp(msg_type, "AUTH") == 0) {
// Handle NIP-42 AUTH message
if (cJSON_GetArraySize(json) >= 2) {
cJSON* auth_payload = cJSON_GetArrayItem(json, 1);
if (cJSON_IsString(auth_payload)) {
// AUTH challenge response: ["AUTH", <challenge>] (unusual)
handle_nip42_auth_challenge_response(wsi, pss, cJSON_GetStringValue(auth_payload));
} else if (cJSON_IsObject(auth_payload)) {
// AUTH signed event: ["AUTH", <event>] (standard NIP-42)
handle_nip42_auth_signed_event(wsi, pss, auth_payload);
} else {
send_notice_message(wsi, "Invalid AUTH message format");
DEBUG_WARN("Received AUTH message with invalid payload type");
}
} else {
send_notice_message(wsi, "AUTH message requires payload");
DEBUG_WARN("Received AUTH message without payload");
}
} else {
// Unknown message type
char unknown_msg[128];
snprintf(unknown_msg, sizeof(unknown_msg), "Unknown message type: %.32s", msg_type);
DEBUG_WARN(unknown_msg);
send_notice_message(wsi, "Unknown message type");
}
}
}
if (json) cJSON_Delete(json);
free(message);
}
}
break;
case LWS_CALLBACK_SERVER_WRITEABLE:
// Handle message queue when socket becomes writeable
if (pss) {
process_message_queue(wsi, pss);
}
break;
case LWS_CALLBACK_CLOSED:
DEBUG_TRACE("WebSocket connection closed");
// Enhanced closure logging with detailed diagnostics
if (pss) {
// Calculate connection duration
time_t now = time(NULL);
long duration = (pss->connection_established > 0) ?
(long)(now - pss->connection_established) : 0;
// Determine closure reason
const char* reason = "client_disconnect";
if (g_shutdown_flag || !g_server_running) {
reason = "server_shutdown";
}
// Format authentication status
char auth_status[80];
if (pss->authenticated && strlen(pss->authenticated_pubkey) > 0) {
// Show first 8 chars of pubkey for identification
snprintf(auth_status, sizeof(auth_status), "yes(%.8s...)", pss->authenticated_pubkey);
} else {
snprintf(auth_status, sizeof(auth_status), "no");
}
// Log comprehensive closure information
DEBUG_LOG("WebSocket CLOSED: ip=%s duration=%lds subscriptions=%d authenticated=%s reason=%s",
pss->client_ip,
duration,
pss->subscription_count,
auth_status,
reason);
// Clean up message queue to prevent memory leaks
while (pss->message_queue_head) {
struct message_queue_node* node = pss->message_queue_head;
pss->message_queue_head = node->next;
free(node->data);
free(node);
}
pss->message_queue_tail = NULL;
pss->message_queue_count = 0;
pss->writeable_requested = 0;
// Clean up session subscriptions - copy IDs first to avoid use-after-free
pthread_mutex_lock(&pss->session_lock);
// First pass: collect subscription IDs safely
typedef struct temp_sub_id {
char id[SUBSCRIPTION_ID_MAX_LENGTH];
struct temp_sub_id* next;
} temp_sub_id_t;
temp_sub_id_t* temp_ids = NULL;
temp_sub_id_t* temp_tail = NULL;
int temp_count = 0;
struct subscription* sub = pss->subscriptions;
while (sub) {
if (sub->active) { // Only process active subscriptions
temp_sub_id_t* temp = malloc(sizeof(temp_sub_id_t));
if (temp) {
memcpy(temp->id, sub->id, SUBSCRIPTION_ID_MAX_LENGTH);
temp->id[SUBSCRIPTION_ID_MAX_LENGTH - 1] = '\0';
temp->next = NULL;
if (!temp_ids) {
temp_ids = temp;
temp_tail = temp;
} else {
temp_tail->next = temp;
temp_tail = temp;
}
temp_count++;
}
}
sub = sub->session_next;
}
// Clear session list immediately
pss->subscriptions = NULL;
pss->subscription_count = 0;
pthread_mutex_unlock(&pss->session_lock);
// Second pass: remove from global manager using copied IDs
temp_sub_id_t* current_temp = temp_ids;
while (current_temp) {
temp_sub_id_t* next_temp = current_temp->next;
remove_subscription_from_manager(current_temp->id, wsi);
free(current_temp);
current_temp = next_temp;
}
pthread_mutex_destroy(&pss->session_lock);
} else {
DEBUG_LOG("WebSocket CLOSED: ip=unknown duration=0s subscriptions=0 authenticated=no reason=unknown");
}
DEBUG_TRACE("WebSocket connection cleanup complete");
break;
default:
break;
}
return 0;
}
// WebSocket protocol definition
static struct lws_protocols protocols[] = {
{
"nostr-relay-protocol",
nostr_relay_callback,
sizeof(struct per_session_data),
4096, // rx buffer size
0, NULL, 0
},
{ NULL, NULL, 0, 0, 0, NULL, 0 } // terminator
};
// Check if a port is available for binding
int check_port_available(int port) {
int sockfd;
struct sockaddr_in addr;
int result;
int reuse = 1;
// Create a socket
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
return 0; // Cannot create socket, assume port unavailable
}
// Set SO_REUSEADDR to allow binding to ports in TIME_WAIT state
// This matches libwebsockets behavior and prevents false unavailability
if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) < 0) {
close(sockfd);
return 0; // Failed to set socket option
}
// Set up the address structure
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(port);
// Try to bind to the port
result = bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
// Close the socket
close(sockfd);
// Return 1 if bind succeeded (port available), 0 if failed (port in use)
return (result == 0) ? 1 : 0;
}
// Start libwebsockets-based WebSocket Nostr relay server
int start_websocket_relay(int port_override, int strict_port) {
struct lws_context_creation_info info;
// Starting libwebsockets-based Nostr relay server
// Set libwebsockets log level to errors only
lws_set_log_level(LLL_USER | LLL_ERR, NULL);
memset(&info, 0, sizeof(info));
// Use port override if provided, otherwise use configuration
int configured_port = (port_override > 0) ? port_override : get_config_int("relay_port", DEFAULT_PORT);
int actual_port = configured_port;
int port_attempts = 0;
const int max_port_attempts = 10; // Increased from 5 to 10
// Minimal libwebsockets configuration
info.protocols = protocols;
info.gid = -1;
info.uid = -1;
info.options = LWS_SERVER_OPTION_VALIDATE_UTF8;
// Remove interface restrictions - let system choose
// info.vhost_name = NULL;
// info.iface = NULL;
// Increase max connections for relay usage
info.max_http_header_pool = 16;
info.timeout_secs = 10;
// Max payload size for Nostr events
info.max_http_header_data = 4096;
// Find an available port with pre-checking (or fail immediately in strict mode)
while (port_attempts < (strict_port ? 1 : max_port_attempts)) {
// Checking port availability
// Pre-check if port is available
if (!check_port_available(actual_port)) {
port_attempts++;
if (strict_port) {
char error_msg[256];
snprintf(error_msg, sizeof(error_msg),
"Strict port mode: port %d is not available", actual_port);
DEBUG_ERROR(error_msg);
return -1;
} else if (port_attempts < max_port_attempts) {
char retry_msg[256];
snprintf(retry_msg, sizeof(retry_msg), "Port %d is in use, trying port %d (attempt %d/%d)",
actual_port, actual_port + 1, port_attempts + 1, max_port_attempts);
DEBUG_WARN(retry_msg);
actual_port++;
continue;
} else {
char error_msg[512];
snprintf(error_msg, sizeof(error_msg),
"Failed to find available port after %d attempts (tried ports %d-%d)",
max_port_attempts, configured_port, actual_port);
DEBUG_ERROR(error_msg);
return -1;
}
}
// Port appears available, try creating libwebsockets context
info.port = actual_port;
// Attempting to bind libwebsockets
ws_context = lws_create_context(&info);
if (ws_context) {
// Success! Port binding worked
break;
}
// libwebsockets failed even though port check passed
// This could be due to timing or different socket options
int errno_saved = errno;
char lws_error_msg[256];
snprintf(lws_error_msg, sizeof(lws_error_msg),
"libwebsockets failed to bind to port %d (errno: %d)", actual_port, errno_saved);
DEBUG_WARN(lws_error_msg);
port_attempts++;
if (strict_port) {
char error_msg[256];
snprintf(error_msg, sizeof(error_msg),
"Strict port mode: failed to bind to port %d", actual_port);
DEBUG_ERROR(error_msg);
break;
} else if (port_attempts < max_port_attempts) {
actual_port++;
continue;
}
// If we get here, we've exhausted attempts
break;
}
if (!ws_context) {
char error_msg[512];
snprintf(error_msg, sizeof(error_msg),
"Failed to create libwebsockets context after %d attempts. Last attempted port: %d",
port_attempts, actual_port);
DEBUG_ERROR(error_msg);
perror("libwebsockets creation error");
return -1;
}
char startup_msg[256];
if (actual_port != configured_port) {
snprintf(startup_msg, sizeof(startup_msg),
"WebSocket relay started on ws://127.0.0.1:%d (configured port %d was unavailable)",
actual_port, configured_port);
DEBUG_WARN(startup_msg);
} else {
snprintf(startup_msg, sizeof(startup_msg), "WebSocket relay started on ws://127.0.0.1:%d", actual_port);
}
// Static variable for status post timing (initialize to 0 for immediate first post)
static time_t last_status_post_time = 0;
// Main event loop with proper signal handling
while (g_server_running && !g_shutdown_flag) {
int result = lws_service(ws_context, 1000);
if (result < 0) {
DEBUG_ERROR("libwebsockets service error");
break;
}
// Check if it's time to post status update
time_t current_time = time(NULL);
int status_post_hours = get_config_int("kind_1_status_posts_hours", 0);
if (status_post_hours > 0) {
int seconds_interval = status_post_hours * 3600; // Convert hours to seconds
if (current_time - last_status_post_time >= seconds_interval) {
last_status_post_time = current_time;
generate_and_post_status_event();
}
}
}
lws_context_destroy(ws_context);
ws_context = NULL;
return 0;
}
// Process DM stats command
int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
if (!dm_event || !error_message) {
return -1;
}
// Check if DM is addressed to relay
cJSON* tags = cJSON_GetObjectItem(dm_event, "tags");
if (!tags || !cJSON_IsArray(tags)) {
strncpy(error_message, "DM missing or invalid tags", error_size - 1);
return -1;
}
const char* relay_pubkey = get_config_value("relay_pubkey");
if (!relay_pubkey) {
strncpy(error_message, "Could not get relay pubkey", error_size - 1);
return -1;
}
// Look for "p" tag with relay pubkey
int addressed_to_relay = 0;
cJSON* tag = NULL;
cJSON_ArrayForEach(tag, tags) {
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
cJSON* tag_name = cJSON_GetArrayItem(tag, 0);
cJSON* tag_value = cJSON_GetArrayItem(tag, 1);
if (tag_name && cJSON_IsString(tag_name) &&
strcmp(cJSON_GetStringValue(tag_name), "p") == 0 &&
tag_value && cJSON_IsString(tag_value) &&
strcmp(cJSON_GetStringValue(tag_value), relay_pubkey) == 0) {
addressed_to_relay = 1;
break;
}
}
}
if (!addressed_to_relay) {
// Not addressed to relay, allow normal processing
return 0;
}
// Get sender pubkey
cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
strncpy(error_message, "DM missing sender pubkey", error_size - 1);
return -1;
}
const char* sender_pubkey = cJSON_GetStringValue(pubkey_obj);
// Check if sender is admin
const char* admin_pubkey = get_config_value("admin_pubkey");
if (!admin_pubkey || strlen(admin_pubkey) == 0 ||
strcmp(sender_pubkey, admin_pubkey) != 0) {
strncpy(error_message, "Unauthorized: not admin", error_size - 1);
return -1;
}
// Get relay private key for decryption
char* relay_privkey_hex = get_relay_private_key();
if (!relay_privkey_hex) {
strncpy(error_message, "Could not get relay private key", error_size - 1);
return -1;
}
// Convert relay private key to bytes
unsigned char relay_privkey[32];
if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey, sizeof(relay_privkey)) != 0) {
free(relay_privkey_hex);
strncpy(error_message, "Failed to convert relay private key", error_size - 1);
return -1;
}
free(relay_privkey_hex);
// Convert sender pubkey to bytes
unsigned char sender_pubkey_bytes[32];
if (nostr_hex_to_bytes(sender_pubkey, sender_pubkey_bytes, sizeof(sender_pubkey_bytes)) != 0) {
strncpy(error_message, "Failed to convert sender pubkey", error_size - 1);
return -1;
}
// Get encrypted content
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
strncpy(error_message, "DM missing content", error_size - 1);
return -1;
}
const char* encrypted_content = cJSON_GetStringValue(content_obj);
// Decrypt content
char decrypted_content[16384];
int decrypt_result = nostr_nip44_decrypt(relay_privkey, sender_pubkey_bytes,
encrypted_content, decrypted_content, sizeof(decrypted_content));
if (decrypt_result != NOSTR_SUCCESS) {
char decrypt_error[256];
snprintf(decrypt_error, sizeof(decrypt_error), "NIP-44 decryption failed: %d", decrypt_result);
strncpy(error_message, decrypt_error, error_size - 1);
return -1;
}
// Check if content is "stats"
if (strcmp(decrypted_content, "stats") != 0) {
// Not a stats command, allow normal processing
return 0;
}
// Processing DM stats command from admin
// Generate stats JSON
char* stats_json = generate_stats_json();
if (!stats_json) {
strncpy(error_message, "Failed to generate stats", error_size - 1);
return -1;
}
// Encrypt stats for response
char encrypted_response[4096];
int encrypt_result = nostr_nip44_encrypt(relay_privkey, sender_pubkey_bytes,
stats_json, encrypted_response, sizeof(encrypted_response));
free(stats_json);
if (encrypt_result != NOSTR_SUCCESS) {
char encrypt_error[256];
snprintf(encrypt_error, sizeof(encrypt_error), "NIP-44 encryption failed: %d", encrypt_result);
strncpy(error_message, encrypt_error, error_size - 1);
return -1;
}
// Create DM response event
cJSON* dm_response = cJSON_CreateObject();
cJSON_AddStringToObject(dm_response, "id", ""); // Will be set by event creation
cJSON_AddStringToObject(dm_response, "pubkey", relay_pubkey);
cJSON_AddNumberToObject(dm_response, "created_at", (double)time(NULL));
cJSON_AddNumberToObject(dm_response, "kind", 14);
cJSON_AddStringToObject(dm_response, "content", encrypted_response);
// Add tags: p tag for recipient (admin)
cJSON* response_tags = cJSON_CreateArray();
cJSON* p_tag = cJSON_CreateArray();
cJSON_AddItemToArray(p_tag, cJSON_CreateString("p"));
cJSON_AddItemToArray(p_tag, cJSON_CreateString(sender_pubkey));
cJSON_AddItemToArray(response_tags, p_tag);
cJSON_AddItemToObject(dm_response, "tags", response_tags);
// Add signature placeholder
cJSON_AddStringToObject(dm_response, "sig", ""); // Will be set by event creation/signing
// Store and broadcast the DM response
int store_result = store_event(dm_response);
if (store_result != 0) {
cJSON_Delete(dm_response);
strncpy(error_message, "Failed to store DM response", error_size - 1);
return -1;
}
// Broadcast to subscriptions
broadcast_event_to_subscriptions(dm_response);
cJSON_Delete(dm_response);
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
if (!cJSON_IsArray(filters)) {
DEBUG_ERROR("COUNT filters is not an array");
return 0;
}
// Parameter binding helpers
char** bind_params = NULL;
int bind_param_count = 0;
int bind_param_capacity = 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)) {
DEBUG_WARN("Invalid filter object in COUNT");
continue;
}
// Reset bind params for this filter
for (int j = 0; j < bind_param_count; j++) {
free(bind_params[j]);
}
free(bind_params);
bind_params = NULL;
bind_param_count = 0;
bind_param_capacity = 0;
// 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 = 0;
// Count valid authors
for (int a = 0; a < cJSON_GetArraySize(authors); a++) {
cJSON* author = cJSON_GetArrayItem(authors, a);
if (cJSON_IsString(author)) {
author_count++;
}
}
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++) {
if (a > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "?");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
snprintf(sql_ptr, remaining, ")");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
// Add author values to bind params
for (int a = 0; a < cJSON_GetArraySize(authors); a++) {
cJSON* author = cJSON_GetArrayItem(authors, a);
if (cJSON_IsString(author)) {
if (bind_param_count >= bind_param_capacity) {
bind_param_capacity = bind_param_capacity == 0 ? 16 : bind_param_capacity * 2;
bind_params = realloc(bind_params, bind_param_capacity * sizeof(char*));
}
bind_params[bind_param_count++] = strdup(cJSON_GetStringValue(author));
}
}
}
}
// Handle ids filter
cJSON* ids = cJSON_GetObjectItem(filter, "ids");
if (ids && cJSON_IsArray(ids)) {
int id_count = 0;
// Count valid ids
for (int i = 0; i < cJSON_GetArraySize(ids); i++) {
cJSON* id = cJSON_GetArrayItem(ids, i);
if (cJSON_IsString(id)) {
id_count++;
}
}
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++) {
if (i > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "?");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
snprintf(sql_ptr, remaining, ")");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
// Add id values to bind params
for (int i = 0; i < cJSON_GetArraySize(ids); i++) {
cJSON* id = cJSON_GetArrayItem(ids, i);
if (cJSON_IsString(id)) {
if (bind_param_count >= bind_param_capacity) {
bind_param_capacity = bind_param_capacity == 0 ? 16 : bind_param_capacity * 2;
bind_params = realloc(bind_params, bind_param_capacity * sizeof(char*));
}
bind_params[bind_param_count++] = strdup(cJSON_GetStringValue(id));
}
}
}
}
// 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 = 0;
// Count valid tag values
for (int i = 0; i < cJSON_GetArraySize(filter_item); i++) {
cJSON* tag_value = cJSON_GetArrayItem(filter_item, i);
if (cJSON_IsString(tag_value)) {
tag_value_count++;
}
}
if (tag_value_count > 0) {
// Use EXISTS with parameterized query
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = ? AND json_extract(value, '$[1]') IN (");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
for (int i = 0; i < tag_value_count; i++) {
if (i > 0) {
snprintf(sql_ptr, remaining, ",");
sql_ptr++;
remaining--;
}
snprintf(sql_ptr, remaining, "?");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
}
snprintf(sql_ptr, remaining, "))");
sql_ptr += strlen(sql_ptr);
remaining = sizeof(sql) - strlen(sql);
// Add tag name and values to bind params
if (bind_param_count >= bind_param_capacity) {
bind_param_capacity = bind_param_capacity == 0 ? 16 : bind_param_capacity * 2;
bind_params = realloc(bind_params, bind_param_capacity * sizeof(char*));
}
bind_params[bind_param_count++] = strdup(tag_name);
for (int i = 0; i < cJSON_GetArraySize(filter_item); i++) {
cJSON* tag_value = cJSON_GetArrayItem(filter_item, i);
if (cJSON_IsString(tag_value)) {
if (bind_param_count >= bind_param_capacity) {
bind_param_capacity = bind_param_capacity == 0 ? 16 : bind_param_capacity * 2;
bind_params = realloc(bind_params, bind_param_capacity * sizeof(char*));
}
bind_params[bind_param_count++] = strdup(cJSON_GetStringValue(tag_value));
}
}
}
}
}
}
// 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)) {
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);
}
// Execute count query
// 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));
DEBUG_ERROR(error_msg);
continue;
}
// Bind parameters
for (int i = 0; i < bind_param_count; i++) {
sqlite3_bind_text(stmt, i + 1, bind_params[i], -1, SQLITE_TRANSIENT);
}
int filter_count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
filter_count = sqlite3_column_int(stmt, 0);
}
// Filter count calculated
sqlite3_finalize(stmt);
total_count += filter_count;
}
// Total count calculated
// 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);
// DEBUG: Log WebSocket frame details before sending
DEBUG_TRACE("WS_FRAME_SEND: type=COUNT len=%zu data=%.100s%s",
count_len,
count_str,
count_len > 100 ? "..." : "");
// Queue message for proper libwebsockets pattern
if (queue_message(wsi, pss, count_str, count_len, LWS_WRITE_TEXT) != 0) {
DEBUG_ERROR("Failed to queue COUNT message");
}
free(count_str);
}
cJSON_Delete(count_response);
// Cleanup bind params
for (int i = 0; i < bind_param_count; i++) {
free(bind_params[i]);
}
free(bind_params);
return total_count;
}
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
// RATE LIMITING FUNCTIONS
/////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////
/**
* Check if a client is currently rate limited for malformed requests
*/
int is_client_rate_limited_for_malformed_requests(struct per_session_data *pss) {
if (!pss) {
return 0;
}
time_t now = time(NULL);
// Check if currently blocked
if (pss->malformed_request_blocked_until > now) {
return 1;
}
// Reset block if expired
if (pss->malformed_request_blocked_until > 0 && pss->malformed_request_blocked_until <= now) {
pss->malformed_request_blocked_until = 0;
pss->malformed_request_count = 0;
pss->malformed_request_window_start = now;
}
// Check if within current hour window
if (pss->malformed_request_window_start == 0 ||
(now - pss->malformed_request_window_start) >= 3600) { // 1 hour
// Start new window
pss->malformed_request_window_start = now;
pss->malformed_request_count = 0;
}
// Check if exceeded limit
if (pss->malformed_request_count >= MAX_MALFORMED_REQUESTS_PER_HOUR) {
// Block for the specified duration
pss->malformed_request_blocked_until = now + MALFORMED_REQUEST_BLOCK_DURATION;
DEBUG_WARN("Client rate limited for malformed requests");
return 1;
}
return 0;
}
/**
* Record a malformed request for rate limiting purposes
*/
void record_malformed_request(struct per_session_data *pss) {
if (!pss) {
return;
}
time_t now = time(NULL);
// Initialize window if needed
if (pss->malformed_request_window_start == 0) {
pss->malformed_request_window_start = now;
pss->malformed_request_count = 0;
}
// Reset window if hour has passed
if ((now - pss->malformed_request_window_start) >= 3600) {
pss->malformed_request_window_start = now;
pss->malformed_request_count = 0;
}
// Increment count
pss->malformed_request_count++;
}
/**
* Validate if a string is valid hexadecimal of specified length
*/
int is_valid_hex_string(const char* str, size_t expected_len) {
if (!str || strlen(str) != expected_len) {
return 0;
}
for (size_t i = 0; i < expected_len; i++) {
char c = str[i];
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
return 0;
}
}
return 1;
}
/**
* Validate a filter array for REQ and COUNT messages
*/
int validate_filter_array(cJSON* filters, char* error_message, size_t error_size) {
if (!filters || !cJSON_IsArray(filters)) {
snprintf(error_message, error_size, "error: filters must be an array");
return 0;
}
int filter_count = cJSON_GetArraySize(filters);
if (filter_count > MAX_FILTERS_PER_REQUEST) {
snprintf(error_message, error_size, "error: too many filters (max %d)", MAX_FILTERS_PER_REQUEST);
return 0;
}
// Validate each filter object
for (int i = 0; i < filter_count; i++) {
cJSON* filter = cJSON_GetArrayItem(filters, i);
if (!filter || !cJSON_IsObject(filter)) {
snprintf(error_message, error_size, "error: filter %d is not an object", i);
return 0;
}
// Validate filter fields
cJSON* filter_item = NULL;
cJSON_ArrayForEach(filter_item, filter) {
const char* key = filter_item->string;
if (!key) continue;
// Validate authors array
if (strcmp(key, "authors") == 0) {
if (!cJSON_IsArray(filter_item)) {
snprintf(error_message, error_size, "error: authors must be an array");
return 0;
}
int author_count = cJSON_GetArraySize(filter_item);
if (author_count > MAX_AUTHORS_PER_FILTER) {
snprintf(error_message, error_size, "error: too many authors (max %d)", MAX_AUTHORS_PER_FILTER);
return 0;
}
for (int j = 0; j < author_count; j++) {
cJSON* author = cJSON_GetArrayItem(filter_item, j);
if (!cJSON_IsString(author)) {
snprintf(error_message, error_size, "error: author %d is not a string", j);
return 0;
}
const char* author_str = cJSON_GetStringValue(author);
if (!is_valid_hex_string(author_str, 64)) {
snprintf(error_message, error_size, "error: invalid author hex string");
return 0;
}
}
}
// Validate ids array
else if (strcmp(key, "ids") == 0) {
if (!cJSON_IsArray(filter_item)) {
snprintf(error_message, error_size, "error: ids must be an array");
return 0;
}
int id_count = cJSON_GetArraySize(filter_item);
if (id_count > MAX_IDS_PER_FILTER) {
snprintf(error_message, error_size, "error: too many ids (max %d)", MAX_IDS_PER_FILTER);
return 0;
}
for (int j = 0; j < id_count; j++) {
cJSON* id = cJSON_GetArrayItem(filter_item, j);
if (!cJSON_IsString(id)) {
snprintf(error_message, error_size, "error: id %d is not a string", j);
return 0;
}
const char* id_str = cJSON_GetStringValue(id);
if (!is_valid_hex_string(id_str, 64)) {
snprintf(error_message, error_size, "error: invalid id hex string");
return 0;
}
}
}
// Validate kinds array
else if (strcmp(key, "kinds") == 0) {
if (!cJSON_IsArray(filter_item)) {
snprintf(error_message, error_size, "error: kinds must be an array");
return 0;
}
int kind_count = cJSON_GetArraySize(filter_item);
if (kind_count > MAX_KINDS_PER_FILTER) {
snprintf(error_message, error_size, "error: too many kinds (max %d)", MAX_KINDS_PER_FILTER);
return 0;
}
for (int j = 0; j < kind_count; j++) {
cJSON* kind = cJSON_GetArrayItem(filter_item, j);
if (!cJSON_IsNumber(kind)) {
snprintf(error_message, error_size, "error: kind %d is not a number", j);
return 0;
}
int kind_val = (int)cJSON_GetNumberValue(kind);
if (kind_val < 0 || kind_val > MAX_KIND_VALUE) {
snprintf(error_message, error_size, "error: invalid kind value %d", kind_val);
return 0;
}
}
}
// Validate since/until timestamps
else if (strcmp(key, "since") == 0 || strcmp(key, "until") == 0) {
if (!cJSON_IsNumber(filter_item)) {
snprintf(error_message, error_size, "error: %s must be a number", key);
return 0;
}
double timestamp = cJSON_GetNumberValue(filter_item);
if (timestamp < 0 || timestamp > MAX_TIMESTAMP_VALUE) {
snprintf(error_message, error_size, "error: invalid %s timestamp", key);
return 0;
}
}
// Validate limit
else if (strcmp(key, "limit") == 0) {
if (!cJSON_IsNumber(filter_item)) {
snprintf(error_message, error_size, "error: limit must be a number");
return 0;
}
int limit_val = (int)cJSON_GetNumberValue(filter_item);
if (limit_val < 0 || limit_val > MAX_LIMIT_VALUE) {
snprintf(error_message, error_size, "error: invalid limit value %d", limit_val);
return 0;
}
}
// Validate search term
else if (strcmp(key, "search") == 0) {
if (!cJSON_IsString(filter_item)) {
snprintf(error_message, error_size, "error: search must be a string");
return 0;
}
const char* search_str = cJSON_GetStringValue(filter_item);
size_t search_len = strlen(search_str);
if (search_len > MAX_SEARCH_LENGTH) {
snprintf(error_message, error_size, "error: search term too long (max %d)", MAX_SEARCH_LENGTH);
return 0;
}
// Check for SQL injection characters
if (strchr(search_str, ';') || strstr(search_str, "--") || strstr(search_str, "/*") || strstr(search_str, "*/")) {
snprintf(error_message, error_size, "error: invalid characters in search term");
return 0;
}
}
// Validate tag filters (#e, #p, #t, etc.)
else if (key[0] == '#' && strlen(key) > 1) {
if (!cJSON_IsArray(filter_item)) {
snprintf(error_message, error_size, "error: %s must be an array", key);
return 0;
}
int tag_count = cJSON_GetArraySize(filter_item);
if (tag_count > MAX_TAG_VALUES_PER_FILTER) {
snprintf(error_message, error_size, "error: too many %s values (max %d)", key, MAX_TAG_VALUES_PER_FILTER);
return 0;
}
for (int j = 0; j < tag_count; j++) {
cJSON* tag_value = cJSON_GetArrayItem(filter_item, j);
if (!cJSON_IsString(tag_value)) {
snprintf(error_message, error_size, "error: %s[%d] is not a string", key, j);
return 0;
}
const char* tag_str = cJSON_GetStringValue(tag_value);
size_t tag_len = strlen(tag_str);
if (tag_len > MAX_TAG_VALUE_LENGTH) {
snprintf(error_message, error_size, "error: %s value too long (max %d)", key, MAX_TAG_VALUE_LENGTH);
return 0;
}
}
}
// Unknown filter keys are allowed but ignored
}
}
return 1; // All filters valid
}