diff --git a/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm b/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm deleted file mode 100644 index 495b13d..0000000 Binary files a/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm and /dev/null differ diff --git a/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-wal b/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-wal deleted file mode 100644 index b62ac04..0000000 Binary files a/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-wal and /dev/null differ diff --git a/clean_schema.sql b/clean_schema.sql new file mode 100644 index 0000000..d3907e2 --- /dev/null +++ b/clean_schema.sql @@ -0,0 +1,313 @@ +-- C Nostr Relay Database Schema +-- SQLite schema for storing Nostr events with JSON tags support +-- Configuration system using config table +-- Schema version tracking +PRAGMA user_version = 7; +-- Enable foreign key support +PRAGMA foreign_keys = ON; +-- Optimize for performance +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA cache_size = 10000; +-- Core events table with hybrid single-table design +CREATE TABLE events ( + id TEXT PRIMARY KEY, -- Nostr event ID (hex string) + pubkey TEXT NOT NULL, -- Public key of event author (hex string) + created_at INTEGER NOT NULL, -- Event creation timestamp (Unix timestamp) + kind INTEGER NOT NULL, -- Event kind (0-65535) + event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')), + content TEXT NOT NULL, -- Event content (text content only) + sig TEXT NOT NULL, -- Event signature (hex string) + tags JSON NOT NULL DEFAULT '[]', -- Event tags as JSON array + first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -- When relay received event +); +-- Core performance indexes +CREATE INDEX idx_events_pubkey ON events(pubkey); +CREATE INDEX idx_events_kind ON events(kind); +CREATE INDEX idx_events_created_at ON events(created_at DESC); +CREATE INDEX idx_events_event_type ON events(event_type); +-- Composite indexes for common query patterns +CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC); +CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC); +CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind); +-- Schema information table +CREATE TABLE schema_info ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +-- Insert schema metadata +INSERT INTO schema_info (key, value) VALUES + ('version', '7'), + ('description', 'Hybrid Nostr relay schema with event-based and table-based configuration'), + ('created_at', strftime('%s', 'now')); +-- Helper views for common queries +CREATE VIEW recent_events AS +SELECT id, pubkey, created_at, kind, event_type, content +FROM events +WHERE event_type != 'ephemeral' +ORDER BY created_at DESC +LIMIT 1000; +CREATE VIEW event_stats AS +SELECT + event_type, + COUNT(*) as count, + AVG(length(content)) as avg_content_length, + MIN(created_at) as earliest, + MAX(created_at) as latest +FROM events +GROUP BY event_type; +-- Configuration events view (kind 33334) +CREATE VIEW configuration_events AS +SELECT + id, + pubkey as admin_pubkey, + created_at, + content, + tags, + sig +FROM events +WHERE kind = 33334 +ORDER BY created_at DESC; +-- Optimization: Trigger for automatic cleanup of ephemeral events older than 1 hour +CREATE TRIGGER cleanup_ephemeral_events + AFTER INSERT ON events + WHEN NEW.event_type = 'ephemeral' +BEGIN + DELETE FROM events + WHERE event_type = 'ephemeral' + AND first_seen < (strftime('%s', 'now') - 3600); +END; +-- Replaceable event handling trigger +CREATE TRIGGER handle_replaceable_events + AFTER INSERT ON events + WHEN NEW.event_type = 'replaceable' +BEGIN + DELETE FROM events + WHERE pubkey = NEW.pubkey + AND kind = NEW.kind + AND event_type = 'replaceable' + AND id != NEW.id; +END; +-- Addressable event handling trigger (for kind 33334 configuration events) +CREATE TRIGGER handle_addressable_events + AFTER INSERT ON events + WHEN NEW.event_type = 'addressable' +BEGIN + -- For kind 33334 (configuration), replace previous config from same admin + DELETE FROM events + WHERE pubkey = NEW.pubkey + AND kind = NEW.kind + AND event_type = 'addressable' + AND id != NEW.id; +END; +-- Relay Private Key Secure Storage +-- Stores the relay's private key separately from public configuration +CREATE TABLE relay_seckey ( + private_key_hex TEXT NOT NULL CHECK (length(private_key_hex) = 64), + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +-- Authentication Rules Table for NIP-42 and Policy Enforcement +-- Used by request_validator.c for unified validation +CREATE TABLE auth_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')), + pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')), + pattern_value TEXT, + action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'require_auth', 'rate_limit')), + parameters TEXT, -- JSON parameters for rate limiting, etc. + active INTEGER NOT NULL DEFAULT 1, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +-- Indexes for auth_rules performance +CREATE INDEX idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value); +CREATE INDEX idx_auth_rules_type ON auth_rules(rule_type); +CREATE INDEX idx_auth_rules_active ON auth_rules(active); +-- Configuration Table for Table-Based Config Management +-- Hybrid system supporting both event-based and table-based configuration +CREATE TABLE config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + data_type TEXT NOT NULL CHECK (data_type IN ('string', 'integer', 'boolean', 'json')), + description TEXT, + category TEXT DEFAULT 'general', + requires_restart INTEGER DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +); +-- Indexes for config table performance +CREATE INDEX idx_config_category ON config(category); +CREATE INDEX idx_config_restart ON config(requires_restart); +CREATE INDEX idx_config_updated ON config(updated_at DESC); +-- Trigger to update config timestamp on changes +CREATE TRIGGER update_config_timestamp + AFTER UPDATE ON config + FOR EACH ROW +BEGIN + UPDATE config SET updated_at = strftime('%s', 'now') WHERE key = NEW.key; +END; +-- Insert default configuration values +INSERT INTO config (key, value, data_type, description, category, requires_restart) VALUES + ('relay_description', 'A C Nostr Relay', 'string', 'Relay description', 'general', 0), + ('relay_contact', '', 'string', 'Relay contact information', 'general', 0), + ('relay_software', 'https://github.com/laanwj/c-relay', 'string', 'Relay software URL', 'general', 0), + ('relay_version', '1.0.0', 'string', 'Relay version', 'general', 0), + ('relay_port', '8888', 'integer', 'Relay port number', 'network', 1), + ('max_connections', '1000', 'integer', 'Maximum concurrent connections', 'network', 1), + ('auth_enabled', 'false', 'boolean', 'Enable NIP-42 authentication', 'auth', 0), + ('nip42_auth_required_events', 'false', 'boolean', 'Require auth for event publishing', 'auth', 0), + ('nip42_auth_required_subscriptions', 'false', 'boolean', 'Require auth for subscriptions', 'auth', 0), + ('nip42_auth_required_kinds', '[]', 'json', 'Event kinds requiring authentication', 'auth', 0), + ('nip42_challenge_expiration', '600', 'integer', 'Auth challenge expiration seconds', 'auth', 0), + ('pow_min_difficulty', '0', 'integer', 'Minimum proof-of-work difficulty', 'validation', 0), + ('pow_mode', 'optional', 'string', 'Proof-of-work mode', 'validation', 0), + ('nip40_expiration_enabled', 'true', 'boolean', 'Enable event expiration', 'validation', 0), + ('nip40_expiration_strict', 'false', 'boolean', 'Strict expiration mode', 'validation', 0), + ('nip40_expiration_filter', 'true', 'boolean', 'Filter expired events in queries', 'validation', 0), + ('nip40_expiration_grace_period', '60', 'integer', 'Expiration grace period seconds', 'validation', 0), + ('max_subscriptions_per_client', '25', 'integer', 'Maximum subscriptions per client', 'limits', 0), + ('max_total_subscriptions', '1000', 'integer', 'Maximum total subscriptions', 'limits', 0), + ('max_filters_per_subscription', '10', 'integer', 'Maximum filters per subscription', 'limits', 0), + ('max_event_tags', '2000', 'integer', 'Maximum tags per event', 'limits', 0), + ('max_content_length', '100000', 'integer', 'Maximum event content length', 'limits', 0), + ('max_message_length', '131072', 'integer', 'Maximum WebSocket message length', 'limits', 0), + ('default_limit', '100', 'integer', 'Default query limit', 'limits', 0), + ('max_limit', '5000', 'integer', 'Maximum query limit', 'limits', 0); +-- Persistent Subscriptions Logging Tables (Phase 2) +-- Optional database logging for subscription analytics and debugging +-- Subscription events log +CREATE TABLE subscription_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id TEXT NOT NULL, -- Subscription ID from client + client_ip TEXT NOT NULL, -- Client IP address + event_type TEXT NOT NULL CHECK (event_type IN ('created', 'closed', 'expired', 'disconnected')), + filter_json TEXT, -- JSON representation of filters (for created events) + events_sent INTEGER DEFAULT 0, -- Number of events sent to this subscription + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + ended_at INTEGER, -- When subscription ended (for closed/expired/disconnected) + duration INTEGER -- Computed: ended_at - created_at +); +-- Subscription metrics summary +CREATE TABLE subscription_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, -- Date (YYYY-MM-DD) + total_created INTEGER DEFAULT 0, -- Total subscriptions created + total_closed INTEGER DEFAULT 0, -- Total subscriptions closed + total_events_broadcast INTEGER DEFAULT 0, -- Total events broadcast + avg_duration REAL DEFAULT 0, -- Average subscription duration + peak_concurrent INTEGER DEFAULT 0, -- Peak concurrent subscriptions + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + UNIQUE(date) +); +-- Event broadcasting log (optional, for detailed analytics) +CREATE TABLE event_broadcasts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL, -- Event ID that was broadcast + subscription_id TEXT NOT NULL, -- Subscription that received it + client_ip TEXT NOT NULL, -- Client IP + broadcast_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (event_id) REFERENCES events(id) +); +-- Indexes for subscription logging performance +CREATE INDEX idx_subscription_events_id ON subscription_events(subscription_id); +CREATE INDEX idx_subscription_events_type ON subscription_events(event_type); +CREATE INDEX idx_subscription_events_created ON subscription_events(created_at DESC); +CREATE INDEX idx_subscription_events_client ON subscription_events(client_ip); +CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC); +CREATE INDEX idx_event_broadcasts_event ON event_broadcasts(event_id); +CREATE INDEX idx_event_broadcasts_sub ON event_broadcasts(subscription_id); +CREATE INDEX idx_event_broadcasts_time ON event_broadcasts(broadcast_at DESC); +-- Trigger to update subscription duration when ended +CREATE TRIGGER update_subscription_duration + AFTER UPDATE OF ended_at ON subscription_events + WHEN NEW.ended_at IS NOT NULL AND OLD.ended_at IS NULL +BEGIN + UPDATE subscription_events + SET duration = NEW.ended_at - NEW.created_at + WHERE id = NEW.id; +END; +-- View for subscription analytics +CREATE VIEW subscription_analytics AS +SELECT + date(created_at, 'unixepoch') as date, + COUNT(*) as subscriptions_created, + COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) as subscriptions_ended, + AVG(CASE WHEN duration IS NOT NULL THEN duration END) as avg_duration_seconds, + MAX(events_sent) as max_events_sent, + AVG(events_sent) as avg_events_sent, + COUNT(DISTINCT client_ip) as unique_clients +FROM subscription_events +GROUP BY date(created_at, 'unixepoch') +ORDER BY date DESC; +-- View for current active subscriptions (from log perspective) +CREATE VIEW active_subscriptions_log AS +SELECT + subscription_id, + client_ip, + filter_json, + events_sent, + created_at, + (strftime('%s', 'now') - created_at) as duration_seconds +FROM subscription_events +WHERE event_type = 'created' +AND subscription_id NOT IN ( + SELECT subscription_id FROM subscription_events + WHERE event_type IN ('closed', 'expired', 'disconnected') +); +-- Database Statistics Views for Admin API +-- Event kinds distribution view +CREATE VIEW event_kinds_view AS +SELECT + kind, + COUNT(*) as count, + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage +FROM events +GROUP BY kind +ORDER BY count DESC; +-- Top pubkeys by event count view +CREATE VIEW top_pubkeys_view AS +SELECT + pubkey, + COUNT(*) as event_count, + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage +FROM events +GROUP BY pubkey +ORDER BY event_count DESC +LIMIT 10; +-- Time-based statistics view +CREATE VIEW time_stats_view AS +SELECT + 'total' as period, + COUNT(*) as total_events, + COUNT(DISTINCT pubkey) as unique_pubkeys, + MIN(created_at) as oldest_event, + MAX(created_at) as newest_event +FROM events +UNION ALL +SELECT + '24h' as period, + COUNT(*) as total_events, + COUNT(DISTINCT pubkey) as unique_pubkeys, + MIN(created_at) as oldest_event, + MAX(created_at) as newest_event +FROM events +WHERE created_at >= (strftime('%s', 'now') - 86400) +UNION ALL +SELECT + '7d' as period, + COUNT(*) as total_events, + COUNT(DISTINCT pubkey) as unique_pubkeys, + MIN(created_at) as oldest_event, + MAX(created_at) as newest_event +FROM events +WHERE created_at >= (strftime('%s', 'now') - 604800) +UNION ALL +SELECT + '30d' as period, + COUNT(*) as total_events, + COUNT(DISTINCT pubkey) as unique_pubkeys, + MIN(created_at) as oldest_event, + MAX(created_at) as newest_event +FROM events +WHERE created_at >= (strftime('%s', 'now') - 2592000); diff --git a/nostr_core_lib b/nostr_core_lib index 55e2a9c..c0784fc 160000 --- a/nostr_core_lib +++ b/nostr_core_lib @@ -1 +1 @@ -Subproject commit 55e2a9c68e449ac375d1bdbb72a2bcf3e6eec9f3 +Subproject commit c0784fc890744e31816cd4a208130015f8302d3e diff --git a/relay.pid b/relay.pid index a813045..abd7d88 100644 --- a/relay.pid +++ b/relay.pid @@ -1 +1 @@ -716467 +802896 diff --git a/schema.sql b/schema.sql new file mode 100644 index 0000000..1aa71b6 --- /dev/null +++ b/schema.sql @@ -0,0 +1,696 @@ + +-- C Nostr Relay Database Schema +\ +-- SQLite schema for storing Nostr events with JSON tags support +\ +-- Configuration system using config table +\ + +\ +-- Schema version tracking +\ +PRAGMA user_version = 7; +\ + +\ +-- Enable foreign key support +\ +PRAGMA foreign_keys = ON; +\ + +\ +-- Optimize for performance +\ +PRAGMA journal_mode = WAL; +\ +PRAGMA synchronous = NORMAL; +\ +PRAGMA cache_size = 10000; +\ + +\ +-- Core events table with hybrid single-table design +\ +CREATE TABLE events ( +\ + id TEXT PRIMARY KEY, -- Nostr event ID (hex string) +\ + pubkey TEXT NOT NULL, -- Public key of event author (hex string) +\ + created_at INTEGER NOT NULL, -- Event creation timestamp (Unix timestamp) +\ + kind INTEGER NOT NULL, -- Event kind (0-65535) +\ + event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')), +\ + content TEXT NOT NULL, -- Event content (text content only) +\ + sig TEXT NOT NULL, -- Event signature (hex string) +\ + tags JSON NOT NULL DEFAULT '[]', -- Event tags as JSON array +\ + first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -- When relay received event +\ +); +\ + +\ +-- Core performance indexes +\ +CREATE INDEX idx_events_pubkey ON events(pubkey); +\ +CREATE INDEX idx_events_kind ON events(kind); +\ +CREATE INDEX idx_events_created_at ON events(created_at DESC); +\ +CREATE INDEX idx_events_event_type ON events(event_type); +\ + +\ +-- Composite indexes for common query patterns +\ +CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC); +\ +CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC); +\ +CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind); +\ + +\ +-- Schema information table +\ +CREATE TABLE schema_info ( +\ + key TEXT PRIMARY KEY, +\ + value TEXT NOT NULL, +\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +\ +); +\ + +\ +-- Insert schema metadata +\ +INSERT INTO schema_info (key, value) VALUES +\ + ('version', '7'), +\ + ('description', 'Hybrid Nostr relay schema with event-based and table-based configuration'), +\ + ('created_at', strftime('%s', 'now')); +\ + +\ +-- Helper views for common queries +\ +CREATE VIEW recent_events AS +\ +SELECT id, pubkey, created_at, kind, event_type, content +\ +FROM events +\ +WHERE event_type != 'ephemeral' +\ +ORDER BY created_at DESC +\ +LIMIT 1000; +\ + +\ +CREATE VIEW event_stats AS +\ +SELECT +\ + event_type, +\ + COUNT(*) as count, +\ + AVG(length(content)) as avg_content_length, +\ + MIN(created_at) as earliest, +\ + MAX(created_at) as latest +\ +FROM events +\ +GROUP BY event_type; +\ + +\ +-- Configuration events view (kind 33334) +\ +CREATE VIEW configuration_events AS +\ +SELECT +\ + id, +\ + pubkey as admin_pubkey, +\ + created_at, +\ + content, +\ + tags, +\ + sig +\ +FROM events +\ +WHERE kind = 33334 +\ +ORDER BY created_at DESC; +\ + +\ +-- Optimization: Trigger for automatic cleanup of ephemeral events older than 1 hour +\ +CREATE TRIGGER cleanup_ephemeral_events +\ + AFTER INSERT ON events +\ + WHEN NEW.event_type = 'ephemeral' +\ +BEGIN +\ + DELETE FROM events +\ + WHERE event_type = 'ephemeral' +\ + AND first_seen < (strftime('%s', 'now') - 3600); +\ +END; +\ + +\ +-- Replaceable event handling trigger +\ +CREATE TRIGGER handle_replaceable_events +\ + AFTER INSERT ON events +\ + WHEN NEW.event_type = 'replaceable' +\ +BEGIN +\ + DELETE FROM events +\ + WHERE pubkey = NEW.pubkey +\ + AND kind = NEW.kind +\ + AND event_type = 'replaceable' +\ + AND id != NEW.id; +\ +END; +\ + +\ +-- Addressable event handling trigger (for kind 33334 configuration events) +\ +CREATE TRIGGER handle_addressable_events +\ + AFTER INSERT ON events +\ + WHEN NEW.event_type = 'addressable' +\ +BEGIN +\ + -- For kind 33334 (configuration), replace previous config from same admin +\ + DELETE FROM events +\ + WHERE pubkey = NEW.pubkey +\ + AND kind = NEW.kind +\ + AND event_type = 'addressable' +\ + AND id != NEW.id; +\ +END; +\ + +\ +-- Relay Private Key Secure Storage +\ +-- Stores the relay's private key separately from public configuration +\ +CREATE TABLE relay_seckey ( +\ + private_key_hex TEXT NOT NULL CHECK (length(private_key_hex) = 64), +\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +\ +); +\ + +\ +-- Authentication Rules Table for NIP-42 and Policy Enforcement +\ +-- Used by request_validator.c for unified validation +\ +CREATE TABLE auth_rules ( +\ + id INTEGER PRIMARY KEY AUTOINCREMENT, +\ + rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')), +\ + pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')), +\ + pattern_value TEXT, +\ + action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'require_auth', 'rate_limit')), +\ + parameters TEXT, -- JSON parameters for rate limiting, etc. +\ + active INTEGER NOT NULL DEFAULT 1, +\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +\ +); +\ + +\ +-- Indexes for auth_rules performance +\ +CREATE INDEX idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value); +\ +CREATE INDEX idx_auth_rules_type ON auth_rules(rule_type); +\ +CREATE INDEX idx_auth_rules_active ON auth_rules(active); +\ + +\ +-- Configuration Table for Table-Based Config Management +\ +-- Hybrid system supporting both event-based and table-based configuration +\ +CREATE TABLE config ( +\ + key TEXT PRIMARY KEY, +\ + value TEXT NOT NULL, +\ + data_type TEXT NOT NULL CHECK (data_type IN ('string', 'integer', 'boolean', 'json')), +\ + description TEXT, +\ + category TEXT DEFAULT 'general', +\ + requires_restart INTEGER DEFAULT 0, +\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) +\ +); +\ + +\ +-- Indexes for config table performance +\ +CREATE INDEX idx_config_category ON config(category); +\ +CREATE INDEX idx_config_restart ON config(requires_restart); +\ +CREATE INDEX idx_config_updated ON config(updated_at DESC); +\ + +\ +-- Trigger to update config timestamp on changes +\ +CREATE TRIGGER update_config_timestamp +\ + AFTER UPDATE ON config +\ + FOR EACH ROW +\ +BEGIN +\ + UPDATE config SET updated_at = strftime('%s', 'now') WHERE key = NEW.key; +\ +END; +\ + +\ +-- Insert default configuration values +\ +INSERT INTO config (key, value, data_type, description, category, requires_restart) VALUES +\ + ('relay_description', 'A C Nostr Relay', 'string', 'Relay description', 'general', 0), +\ + ('relay_contact', '', 'string', 'Relay contact information', 'general', 0), +\ + ('relay_software', 'https://github.com/laanwj/c-relay', 'string', 'Relay software URL', 'general', 0), +\ + ('relay_version', '1.0.0', 'string', 'Relay version', 'general', 0), +\ + ('relay_port', '8888', 'integer', 'Relay port number', 'network', 1), +\ + ('max_connections', '1000', 'integer', 'Maximum concurrent connections', 'network', 1), +\ + ('auth_enabled', 'false', 'boolean', 'Enable NIP-42 authentication', 'auth', 0), +\ + ('nip42_auth_required_events', 'false', 'boolean', 'Require auth for event publishing', 'auth', 0), +\ + ('nip42_auth_required_subscriptions', 'false', 'boolean', 'Require auth for subscriptions', 'auth', 0), +\ + ('nip42_auth_required_kinds', '[]', 'json', 'Event kinds requiring authentication', 'auth', 0), +\ + ('nip42_challenge_expiration', '600', 'integer', 'Auth challenge expiration seconds', 'auth', 0), +\ + ('pow_min_difficulty', '0', 'integer', 'Minimum proof-of-work difficulty', 'validation', 0), +\ + ('pow_mode', 'optional', 'string', 'Proof-of-work mode', 'validation', 0), +\ + ('nip40_expiration_enabled', 'true', 'boolean', 'Enable event expiration', 'validation', 0), +\ + ('nip40_expiration_strict', 'false', 'boolean', 'Strict expiration mode', 'validation', 0), +\ + ('nip40_expiration_filter', 'true', 'boolean', 'Filter expired events in queries', 'validation', 0), +\ + ('nip40_expiration_grace_period', '60', 'integer', 'Expiration grace period seconds', 'validation', 0), +\ + ('max_subscriptions_per_client', '25', 'integer', 'Maximum subscriptions per client', 'limits', 0), +\ + ('max_total_subscriptions', '1000', 'integer', 'Maximum total subscriptions', 'limits', 0), +\ + ('max_filters_per_subscription', '10', 'integer', 'Maximum filters per subscription', 'limits', 0), +\ + ('max_event_tags', '2000', 'integer', 'Maximum tags per event', 'limits', 0), +\ + ('max_content_length', '100000', 'integer', 'Maximum event content length', 'limits', 0), +\ + ('max_message_length', '131072', 'integer', 'Maximum WebSocket message length', 'limits', 0), +\ + ('default_limit', '100', 'integer', 'Default query limit', 'limits', 0), +\ + ('max_limit', '5000', 'integer', 'Maximum query limit', 'limits', 0); +\ + +\ +-- Persistent Subscriptions Logging Tables (Phase 2) +\ +-- Optional database logging for subscription analytics and debugging +\ + +\ +-- Subscription events log +\ +CREATE TABLE subscription_events ( +\ + id INTEGER PRIMARY KEY AUTOINCREMENT, +\ + subscription_id TEXT NOT NULL, -- Subscription ID from client +\ + client_ip TEXT NOT NULL, -- Client IP address +\ + event_type TEXT NOT NULL CHECK (event_type IN ('created', 'closed', 'expired', 'disconnected')), +\ + filter_json TEXT, -- JSON representation of filters (for created events) +\ + events_sent INTEGER DEFAULT 0, -- Number of events sent to this subscription +\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +\ + ended_at INTEGER, -- When subscription ended (for closed/expired/disconnected) +\ + duration INTEGER -- Computed: ended_at - created_at +\ +); +\ + +\ +-- Subscription metrics summary +\ +CREATE TABLE subscription_metrics ( +\ + id INTEGER PRIMARY KEY AUTOINCREMENT, +\ + date TEXT NOT NULL, -- Date (YYYY-MM-DD) +\ + total_created INTEGER DEFAULT 0, -- Total subscriptions created +\ + total_closed INTEGER DEFAULT 0, -- Total subscriptions closed +\ + total_events_broadcast INTEGER DEFAULT 0, -- Total events broadcast +\ + avg_duration REAL DEFAULT 0, -- Average subscription duration +\ + peak_concurrent INTEGER DEFAULT 0, -- Peak concurrent subscriptions +\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +\ + UNIQUE(date) +\ +); +\ + +\ +-- Event broadcasting log (optional, for detailed analytics) +\ +CREATE TABLE event_broadcasts ( +\ + id INTEGER PRIMARY KEY AUTOINCREMENT, +\ + event_id TEXT NOT NULL, -- Event ID that was broadcast +\ + subscription_id TEXT NOT NULL, -- Subscription that received it +\ + client_ip TEXT NOT NULL, -- Client IP +\ + broadcast_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), +\ + FOREIGN KEY (event_id) REFERENCES events(id) +\ +); +\ + +\ +-- Indexes for subscription logging performance +\ +CREATE INDEX idx_subscription_events_id ON subscription_events(subscription_id); +\ +CREATE INDEX idx_subscription_events_type ON subscription_events(event_type); +\ +CREATE INDEX idx_subscription_events_created ON subscription_events(created_at DESC); +\ +CREATE INDEX idx_subscription_events_client ON subscription_events(client_ip); +\ + +\ +CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC); +\ + +\ +CREATE INDEX idx_event_broadcasts_event ON event_broadcasts(event_id); +\ +CREATE INDEX idx_event_broadcasts_sub ON event_broadcasts(subscription_id); +\ +CREATE INDEX idx_event_broadcasts_time ON event_broadcasts(broadcast_at DESC); +\ + +\ +-- Trigger to update subscription duration when ended +\ +CREATE TRIGGER update_subscription_duration +\ + AFTER UPDATE OF ended_at ON subscription_events +\ + WHEN NEW.ended_at IS NOT NULL AND OLD.ended_at IS NULL +\ +BEGIN +\ + UPDATE subscription_events +\ + SET duration = NEW.ended_at - NEW.created_at +\ + WHERE id = NEW.id; +\ +END; +\ + +\ +-- View for subscription analytics +\ +CREATE VIEW subscription_analytics AS +\ +SELECT +\ + date(created_at, 'unixepoch') as date, +\ + COUNT(*) as subscriptions_created, +\ + COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) as subscriptions_ended, +\ + AVG(CASE WHEN duration IS NOT NULL THEN duration END) as avg_duration_seconds, +\ + MAX(events_sent) as max_events_sent, +\ + AVG(events_sent) as avg_events_sent, +\ + COUNT(DISTINCT client_ip) as unique_clients +\ +FROM subscription_events +\ +GROUP BY date(created_at, 'unixepoch') +\ +ORDER BY date DESC; +\ + +\ +-- View for current active subscriptions (from log perspective) +\ +CREATE VIEW active_subscriptions_log AS +\ +SELECT +\ + subscription_id, +\ + client_ip, +\ + filter_json, +\ + events_sent, +\ + created_at, +\ + (strftime('%s', 'now') - created_at) as duration_seconds +\ +FROM subscription_events +\ +WHERE event_type = 'created' +\ +AND subscription_id NOT IN ( +\ + SELECT subscription_id FROM subscription_events +\ + WHERE event_type IN ('closed', 'expired', 'disconnected') +\ +); +\ + +\ +-- Database Statistics Views for Admin API +\ +-- Event kinds distribution view +\ +CREATE VIEW event_kinds_view AS +\ +SELECT +\ + kind, +\ + COUNT(*) as count, +\ + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage +\ +FROM events +\ +GROUP BY kind +\ +ORDER BY count DESC; +\ + +\ +-- Top pubkeys by event count view +\ +CREATE VIEW top_pubkeys_view AS +\ +SELECT +\ + pubkey, +\ + COUNT(*) as event_count, +\ + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage +\ +FROM events +\ +GROUP BY pubkey +\ +ORDER BY event_count DESC +\ +LIMIT 10; +\ + +\ +-- Time-based statistics view +\ +CREATE VIEW time_stats_view AS +\ +SELECT +\ + 'total' as period, +\ + COUNT(*) as total_events, +\ + COUNT(DISTINCT pubkey) as unique_pubkeys, +\ + MIN(created_at) as oldest_event, +\ + MAX(created_at) as newest_event +\ +FROM events +\ +UNION ALL +\ +SELECT +\ + '24h' as period, +\ + COUNT(*) as total_events, +\ + COUNT(DISTINCT pubkey) as unique_pubkeys, +\ + MIN(created_at) as oldest_event, +\ + MAX(created_at) as newest_event +\ +FROM events +\ +WHERE created_at >= (strftime('%s', 'now') - 86400) +\ +UNION ALL +\ +SELECT +\ + '7d' as period, +\ + COUNT(*) as total_events, +\ + COUNT(DISTINCT pubkey) as unique_pubkeys, +\ + MIN(created_at) as oldest_event, +\ + MAX(created_at) as newest_event +\ +FROM events +\ +WHERE created_at >= (strftime('%s', 'now') - 604800) +\ +UNION ALL +\ +SELECT +\ + '30d' as period, +\ + COUNT(*) as total_events, +\ + COUNT(DISTINCT pubkey) as unique_pubkeys, +\ + MIN(created_at) as oldest_event, +\ + MAX(created_at) as newest_event +\ +FROM events +\ +WHERE created_at >= (strftime('%s', 'now') - 2592000); + +#endif /* SQL_SCHEMA_H */ diff --git a/src/api.c b/src/api.c index 33c2370..fb270d5 100644 --- a/src/api.c +++ b/src/api.c @@ -1,7 +1,7 @@ // Define _GNU_SOURCE to ensure all POSIX features are available #define _GNU_SOURCE -// API module for serving embedded web content +// API module for serving embedded web content and NIP-17 admin messaging #include #include #include @@ -9,6 +9,18 @@ #include #include "api.h" #include "embedded_web_content.h" +#include "../nostr_core_lib/nostr_core/nip017.h" +#include "../nostr_core_lib/nostr_core/nip044.h" +#include "../nostr_core_lib/nostr_core/nostr_core.h" +#include "config.h" + +// Forward declarations for event creation and signing +cJSON* nostr_create_and_sign_event(int kind, const char* content, cJSON* tags, + const unsigned char* privkey_bytes, time_t created_at); + +// Forward declaration for stats generation +char* generate_stats_json(void); + // Forward declarations for logging functions void log_info(const char* message); @@ -16,6 +28,9 @@ void log_success(const char* message); void log_error(const char* message); void log_warning(const char* message); +// Forward declarations for database functions +int store_event(cJSON* event); + // Handle HTTP request for embedded files (assumes GET) int handle_embedded_file_request(struct lws* wsi, const char* requested_uri) { log_info("Handling embedded file request"); @@ -162,4 +177,434 @@ int handle_embedded_file_writeable(struct lws* wsi) { log_success("Embedded file served successfully"); return 0; +} + +// ============================================================================= +// NIP-17 GIFT WRAP ADMIN MESSAGING FUNCTIONS +// ============================================================================= + +// Check if an event is a NIP-17 gift wrap addressed to this relay +int is_nip17_gift_wrap_for_relay(cJSON* event) { + if (!event || !cJSON_IsObject(event)) { + return 0; + } + + // Check kind + cJSON* kind_obj = cJSON_GetObjectItem(event, "kind"); + if (!kind_obj || !cJSON_IsNumber(kind_obj) || (int)cJSON_GetNumberValue(kind_obj) != 1059) { + return 0; + } + + // Check tags for "p" tag with relay pubkey + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + if (!tags || !cJSON_IsArray(tags)) { + return 0; + } + + const char* relay_pubkey = get_relay_pubkey_cached(); + if (!relay_pubkey) { + log_error("NIP-17: Could not get relay pubkey for validation"); + return 0; + } + + // Look for "p" tag with 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) { + return 1; // Found matching p tag + } + } + } + + return 0; // No matching p tag found +} + + + +// Process NIP-17 admin command from decrypted DM content +int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t error_size, struct lws* wsi) { + if (!dm_event || !error_message) { + return -1; + } + + // Extract content from DM + cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content"); + if (!content_obj || !cJSON_IsString(content_obj)) { + strncpy(error_message, "NIP-17: DM missing content", error_size - 1); + return -1; + } + + const char* dm_content = cJSON_GetStringValue(content_obj); + log_info("NIP-17: Processing admin command from DM content"); + + // Parse DM content as JSON array of commands + cJSON* command_array = cJSON_Parse(dm_content); + if (!command_array || !cJSON_IsArray(command_array)) { + strncpy(error_message, "NIP-17: DM content is not valid JSON array", error_size - 1); + return -1; + } + + // Check if this is a "stats" command + if (cJSON_GetArraySize(command_array) > 0) { + cJSON* first_item = cJSON_GetArrayItem(command_array, 0); + if (cJSON_IsString(first_item) && strcmp(cJSON_GetStringValue(first_item), "stats") == 0) { + log_info("NIP-17: Processing 'stats' command directly"); + + // Generate stats JSON + char* stats_json = generate_stats_json(); + if (!stats_json) { + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: Failed to generate stats", error_size - 1); + return -1; + } + + // Get sender pubkey for response + cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey"); + if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) { + free(stats_json); + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: DM missing sender pubkey", error_size - 1); + return -1; + } + const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj); + + // Get relay keys for signing + const char* relay_pubkey = get_relay_pubkey_cached(); + char* relay_privkey_hex = get_relay_private_key(); + if (!relay_pubkey || !relay_privkey_hex) { + free(stats_json); + cJSON_Delete(command_array); + if (relay_privkey_hex) free(relay_privkey_hex); + strncpy(error_message, "NIP-17: Could not get relay keys", 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(stats_json); + free(relay_privkey_hex); + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: Failed to convert relay private key", error_size - 1); + return -1; + } + free(relay_privkey_hex); + + // Create DM response event using library function + cJSON* dm_response = nostr_nip17_create_chat_event( + stats_json, // message content + (const char**)&sender_pubkey, // recipient pubkeys + 1, // num recipients + NULL, // subject (optional) + NULL, // reply_to_event_id (optional) + NULL, // reply_relay_url (optional) + relay_pubkey // sender pubkey + ); + + free(stats_json); + + if (!dm_response) { + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: Failed to create DM response event", error_size - 1); + return -1; + } + + // Create and sign gift wrap using library function + cJSON* gift_wraps[1]; + int send_result = nostr_nip17_send_dm( + dm_response, // dm_event + (const char**)&sender_pubkey, // recipient_pubkeys + 1, // num_recipients + relay_privkey, // sender_private_key + gift_wraps, // gift_wraps_out + 1 // max_gift_wraps + ); + + cJSON_Delete(dm_response); + + if (send_result != 1 || !gift_wraps[0]) { + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: Failed to create and sign response gift wrap", error_size - 1); + return -1; + } + + // Store the gift wrap in database + int store_result = store_event(gift_wraps[0]); + cJSON_Delete(gift_wraps[0]); + + if (store_result != 0) { + cJSON_Delete(command_array); + strncpy(error_message, "NIP-17: Failed to store response gift wrap", error_size - 1); + return -1; + } + + cJSON_Delete(command_array); + log_success("NIP-17: Stats command processed successfully"); + return 0; + } + } + + // For other commands, delegate to existing admin processing + // Create a synthetic kind 23456 event with the DM content + cJSON* synthetic_event = cJSON_CreateObject(); + cJSON_AddNumberToObject(synthetic_event, "kind", 23456); + cJSON_AddStringToObject(synthetic_event, "content", dm_content); + + // Copy pubkey from DM + cJSON* pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey"); + if (pubkey_obj && cJSON_IsString(pubkey_obj)) { + cJSON_AddStringToObject(synthetic_event, "pubkey", cJSON_GetStringValue(pubkey_obj)); + } + + // Copy tags from DM + cJSON* tags = cJSON_GetObjectItem(dm_event, "tags"); + if (tags) { + cJSON_AddItemToObject(synthetic_event, "tags", cJSON_Duplicate(tags, 1)); + } + + // Process as regular admin event + int result = process_admin_event_in_config(synthetic_event, error_message, error_size, wsi); + + cJSON_Delete(synthetic_event); + cJSON_Delete(command_array); + + return result; +} + + + +// Generate stats JSON from database queries +char* generate_stats_json(void) { + extern sqlite3* g_db; + if (!g_db) { + log_error("Database not available for stats generation"); + return NULL; + } + + log_info("Generating stats JSON from database"); + + // Build response with database statistics + cJSON* response = cJSON_CreateObject(); + cJSON_AddStringToObject(response, "query_type", "stats_query"); + cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL)); + + // Get database file size + extern char g_database_path[512]; + struct stat db_stat; + long long db_size = 0; + if (stat(g_database_path, &db_stat) == 0) { + db_size = db_stat.st_size; + } + cJSON_AddNumberToObject(response, "database_size_bytes", db_size); + + // Query total events count + sqlite3_stmt* stmt; + if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM events", -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + cJSON_AddNumberToObject(response, "total_events", sqlite3_column_int64(stmt, 0)); + } + sqlite3_finalize(stmt); + } + + // Query event kinds distribution + cJSON* event_kinds = cJSON_CreateArray(); + if (sqlite3_prepare_v2(g_db, "SELECT kind, count, percentage FROM event_kinds_view ORDER BY count DESC", -1, &stmt, NULL) == SQLITE_OK) { + while (sqlite3_step(stmt) == SQLITE_ROW) { + cJSON* kind_obj = cJSON_CreateObject(); + cJSON_AddNumberToObject(kind_obj, "kind", sqlite3_column_int(stmt, 0)); + cJSON_AddNumberToObject(kind_obj, "count", sqlite3_column_int64(stmt, 1)); + cJSON_AddNumberToObject(kind_obj, "percentage", sqlite3_column_double(stmt, 2)); + cJSON_AddItemToArray(event_kinds, kind_obj); + } + sqlite3_finalize(stmt); + } + cJSON_AddItemToObject(response, "event_kinds", event_kinds); + + // Query time-based statistics + cJSON* time_stats = cJSON_CreateObject(); + if (sqlite3_prepare_v2(g_db, "SELECT period, total_events FROM time_stats_view", -1, &stmt, NULL) == SQLITE_OK) { + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char* period = (const char*)sqlite3_column_text(stmt, 0); + sqlite3_int64 count = sqlite3_column_int64(stmt, 1); + + if (strcmp(period, "total") == 0) { + cJSON_AddNumberToObject(time_stats, "total", count); + } else if (strcmp(period, "24h") == 0) { + cJSON_AddNumberToObject(time_stats, "last_24h", count); + } else if (strcmp(period, "7d") == 0) { + cJSON_AddNumberToObject(time_stats, "last_7d", count); + } else if (strcmp(period, "30d") == 0) { + cJSON_AddNumberToObject(time_stats, "last_30d", count); + } + } + sqlite3_finalize(stmt); + } + cJSON_AddItemToObject(response, "time_stats", time_stats); + + // Query top pubkeys + cJSON* top_pubkeys = cJSON_CreateArray(); + if (sqlite3_prepare_v2(g_db, "SELECT pubkey, event_count, percentage FROM top_pubkeys_view ORDER BY event_count DESC LIMIT 10", -1, &stmt, NULL) == SQLITE_OK) { + while (sqlite3_step(stmt) == SQLITE_ROW) { + cJSON* pubkey_obj = cJSON_CreateObject(); + const char* pubkey = (const char*)sqlite3_column_text(stmt, 0); + cJSON_AddStringToObject(pubkey_obj, "pubkey", pubkey ? pubkey : ""); + cJSON_AddNumberToObject(pubkey_obj, "event_count", sqlite3_column_int64(stmt, 1)); + cJSON_AddNumberToObject(pubkey_obj, "percentage", sqlite3_column_double(stmt, 2)); + cJSON_AddItemToArray(top_pubkeys, pubkey_obj); + } + sqlite3_finalize(stmt); + } + cJSON_AddItemToObject(response, "top_pubkeys", top_pubkeys); + + // Get database creation timestamp (oldest event) + if (sqlite3_prepare_v2(g_db, "SELECT MIN(created_at) FROM events", -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + sqlite3_int64 oldest_timestamp = sqlite3_column_int64(stmt, 0); + if (oldest_timestamp > 0) { + cJSON_AddNumberToObject(response, "database_created_at", (double)oldest_timestamp); + } + } + sqlite3_finalize(stmt); + } + + // Get latest event timestamp + if (sqlite3_prepare_v2(g_db, "SELECT MAX(created_at) FROM events", -1, &stmt, NULL) == SQLITE_OK) { + if (sqlite3_step(stmt) == SQLITE_ROW) { + sqlite3_int64 latest_timestamp = sqlite3_column_int64(stmt, 0); + if (latest_timestamp > 0) { + cJSON_AddNumberToObject(response, "latest_event_at", (double)latest_timestamp); + } + } + sqlite3_finalize(stmt); + } + + // Convert to JSON string + char* json_string = cJSON_Print(response); + cJSON_Delete(response); + + if (json_string) { + log_success("Stats JSON generated successfully"); + } else { + log_error("Failed to generate stats JSON"); + } + + return json_string; +} + +// Main NIP-17 processing function +int process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message, size_t error_size, struct lws* wsi) { + if (!gift_wrap_event || !error_message) { + return -1; + } + + // Step 1: Validate it's addressed to us + if (!is_nip17_gift_wrap_for_relay(gift_wrap_event)) { + strncpy(error_message, "NIP-17: Event is not a valid gift wrap for this relay", error_size - 1); + return -1; + } + + // Step 2: Get relay private key for decryption + char* relay_privkey_hex = get_relay_private_key(); + if (!relay_privkey_hex) { + strncpy(error_message, "NIP-17: Could not get relay private key for decryption", error_size - 1); + return -1; + } + + // Convert hex private key to bytes + unsigned char relay_privkey[32]; + if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey, sizeof(relay_privkey)) != 0) { + log_error("NIP-17: Failed to convert relay private key from hex"); + free(relay_privkey_hex); + strncpy(error_message, "NIP-17: Failed to convert relay private key", error_size - 1); + return -1; + } + free(relay_privkey_hex); + + // Step 3: Decrypt and parse inner event using library function + log_info("NIP-17: Attempting to decrypt gift wrap with nostr_nip17_receive_dm"); + cJSON* inner_dm = nostr_nip17_receive_dm(gift_wrap_event, relay_privkey); + if (!inner_dm) { + log_error("NIP-17: nostr_nip17_receive_dm returned NULL"); + // Debug: Print the gift wrap event + char* gift_wrap_debug = cJSON_Print(gift_wrap_event); + if (gift_wrap_debug) { + char debug_msg[1024]; + snprintf(debug_msg, sizeof(debug_msg), "NIP-17: Gift wrap event: %.500s", gift_wrap_debug); + log_error(debug_msg); + free(gift_wrap_debug); + } + // Debug: Check if private key is valid + char privkey_hex[65]; + for (int i = 0; i < 32; i++) { + sprintf(privkey_hex + (i * 2), "%02x", relay_privkey[i]); + } + privkey_hex[64] = '\0'; + char privkey_msg[128]; + snprintf(privkey_msg, sizeof(privkey_msg), "NIP-17: Using relay private key: %.16s...", privkey_hex); + log_info(privkey_msg); + + strncpy(error_message, "NIP-17: Failed to decrypt and parse inner DM event", error_size - 1); + return -1; + } + log_info("NIP-17: Successfully decrypted gift wrap"); + + // Step 4: Process admin command + int result = process_nip17_admin_command(inner_dm, error_message, error_size, wsi); + + // Step 5: Create response if command was processed successfully + if (result == 0) { + // Get sender pubkey for response + cJSON* sender_pubkey_obj = cJSON_GetObjectItem(gift_wrap_event, "pubkey"); + if (sender_pubkey_obj && cJSON_IsString(sender_pubkey_obj)) { + const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj); + + // Create success response using library function + char response_content[1024]; + snprintf(response_content, sizeof(response_content), + "[\"command_processed\", \"success\", \"%s\"]", "NIP-17 admin command executed"); + + // Get relay pubkey for creating DM event + const char* relay_pubkey = get_relay_pubkey_cached(); + if (relay_pubkey) { + cJSON* success_dm = nostr_nip17_create_chat_event( + response_content, // message content + (const char**)&sender_pubkey, // recipient pubkeys + 1, // num recipients + NULL, // subject (optional) + NULL, // reply_to_event_id (optional) + NULL, // reply_relay_url (optional) + relay_pubkey // sender pubkey + ); + + if (success_dm) { + cJSON* success_gift_wraps[1]; + int send_result = nostr_nip17_send_dm( + success_dm, // dm_event + (const char**)&sender_pubkey, // recipient_pubkeys + 1, // num_recipients + relay_privkey, // sender_private_key + success_gift_wraps, // gift_wraps_out + 1 // max_gift_wraps + ); + + cJSON_Delete(success_dm); + + if (send_result == 1 && success_gift_wraps[0]) { + store_event(success_gift_wraps[0]); + cJSON_Delete(success_gift_wraps[0]); + } + } + } + } + } + + cJSON_Delete(inner_dm); + return result; } \ No newline at end of file diff --git a/src/api.h b/src/api.h index 833fc34..d49d081 100644 --- a/src/api.h +++ b/src/api.h @@ -17,4 +17,7 @@ struct embedded_file_session_data { // Handle HTTP request for embedded API files int handle_embedded_file_request(struct lws* wsi, const char* requested_uri); +// Generate stats JSON from database queries +char* generate_stats_json(void); + #endif // API_H \ No newline at end of file diff --git a/src/config.c b/src/config.c index 57c525d..a952d48 100644 --- a/src/config.c +++ b/src/config.c @@ -2956,21 +2956,54 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si log_info("DEBUG: NIP-44 decryption successful"); printf(" Decrypted content: %s\n", decrypted_text); printf(" Decrypted length: %zu\n", strlen(decrypted_text)); - - // Parse decrypted content as JSON array - log_info("DEBUG: Parsing decrypted content as JSON"); - decrypted_content = cJSON_Parse(decrypted_text); - - if (!decrypted_content || !cJSON_IsArray(decrypted_content)) { - log_error("DEBUG: Decrypted content is not valid JSON array"); + + // Parse decrypted content as inner event JSON (NIP-17) + log_info("DEBUG: Parsing decrypted content as inner event JSON"); + cJSON* inner_event = cJSON_Parse(decrypted_text); + + if (!inner_event || !cJSON_IsObject(inner_event)) { + log_error("DEBUG: Decrypted content is not valid inner event JSON"); printf(" Decrypted content type: %s\n", - decrypted_content ? (cJSON_IsArray(decrypted_content) ? "array" : "other") : "null"); - snprintf(error_message, error_size, "error: decrypted content is not valid JSON array"); + inner_event ? (cJSON_IsObject(inner_event) ? "object" : "other") : "null"); + cJSON_Delete(inner_event); + snprintf(error_message, error_size, "error: decrypted content is not valid inner event JSON"); return -1; } - - log_info("DEBUG: Decrypted content parsed successfully as JSON array"); + + log_info("DEBUG: Inner event parsed successfully"); + printf(" Inner event kind: %d\n", (int)cJSON_GetNumberValue(cJSON_GetObjectItem(inner_event, "kind"))); + + // Extract content from inner event + cJSON* inner_content_obj = cJSON_GetObjectItem(inner_event, "content"); + if (!inner_content_obj || !cJSON_IsString(inner_content_obj)) { + log_error("DEBUG: Inner event missing content field"); + cJSON_Delete(inner_event); + snprintf(error_message, error_size, "error: inner event missing content field"); + return -1; + } + + const char* inner_content = cJSON_GetStringValue(inner_content_obj); + log_info("DEBUG: Extracted inner content"); + printf(" Inner content: %s\n", inner_content); + + // Parse inner content as JSON array (the command array) + log_info("DEBUG: Parsing inner content as command JSON array"); + decrypted_content = cJSON_Parse(inner_content); + + if (!decrypted_content || !cJSON_IsArray(decrypted_content)) { + log_error("DEBUG: Inner content is not valid JSON array"); + printf(" Inner content type: %s\n", + decrypted_content ? (cJSON_IsArray(decrypted_content) ? "array" : "other") : "null"); + cJSON_Delete(inner_event); + snprintf(error_message, error_size, "error: inner content is not valid JSON array"); + return -1; + } + + log_info("DEBUG: Inner content parsed successfully as JSON array"); printf(" Array size: %d\n", cJSON_GetArraySize(decrypted_content)); + + // Clean up inner event + cJSON_Delete(inner_event); // Replace event content with decrypted command array for processing log_info("DEBUG: Replacing event content with decrypted marker"); @@ -2979,16 +3012,11 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si // Create synthetic tags from decrypted command array log_info("DEBUG: Creating synthetic tags from decrypted command array"); - cJSON* tags_obj = cJSON_GetObjectItem(event, "tags"); - if (!tags_obj) { - log_info("DEBUG: No existing tags, creating new tags array"); - tags_obj = cJSON_CreateArray(); - cJSON_AddItemToObject(event, "tags", tags_obj); - } else { - log_info("DEBUG: Using existing tags array"); - printf(" Existing tags count: %d\n", cJSON_GetArraySize(tags_obj)); - } - + printf(" Decrypted content array size: %d\n", cJSON_GetArraySize(decrypted_content)); + + // Create new tags array with command tag first + cJSON* new_tags = cJSON_CreateArray(); + // Add decrypted command as first tag if (cJSON_GetArraySize(decrypted_content) > 0) { log_info("DEBUG: Adding decrypted command as synthetic tag"); @@ -2997,10 +3025,10 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si const char* command_name = cJSON_GetStringValue(first_item); log_info("DEBUG: Creating command tag"); printf(" Command: %s\n", command_name ? command_name : "null"); - + cJSON* command_tag = cJSON_CreateArray(); cJSON_AddItemToArray(command_tag, cJSON_Duplicate(first_item, 1)); - + // Add remaining items as tag values for (int i = 1; i < cJSON_GetArraySize(decrypted_content); i++) { cJSON* item = cJSON_GetArrayItem(decrypted_content, i); @@ -3013,17 +3041,31 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si cJSON_AddItemToArray(command_tag, cJSON_Duplicate(item, 1)); } } - - // Insert at beginning of tags array - cJSON_InsertItemInArray(tags_obj, 0, command_tag); - log_info("DEBUG: Synthetic command tag created and inserted"); - printf(" Final tag array size: %d\n", cJSON_GetArraySize(tags_obj)); + + cJSON_AddItemToArray(new_tags, command_tag); + log_info("DEBUG: Synthetic command tag added to new tags array"); + printf(" New tags after adding command: %d\n", cJSON_GetArraySize(new_tags)); } else { log_error("DEBUG: First item in decrypted array is not a string"); } } else { log_error("DEBUG: Decrypted array is empty"); } + + // Add existing tags + cJSON* existing_tags = cJSON_GetObjectItem(event, "tags"); + if (existing_tags && cJSON_IsArray(existing_tags)) { + printf(" Existing tags count: %d\n", cJSON_GetArraySize(existing_tags)); + cJSON* tag = NULL; + cJSON_ArrayForEach(tag, existing_tags) { + cJSON_AddItemToArray(new_tags, cJSON_Duplicate(tag, 1)); + } + printf(" New tags after adding existing: %d\n", cJSON_GetArraySize(new_tags)); + } + + // Replace event tags with new tags + cJSON_ReplaceItemInObject(event, "tags", new_tags); + printf(" Final tag array size: %d\n", cJSON_GetArraySize(new_tags)); cJSON_Delete(decrypted_content); } else { @@ -3034,19 +3076,29 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si // Parse first tag to determine action type (now from decrypted content if applicable) log_info("DEBUG: Parsing first tag to determine action type"); + cJSON* tags_obj = cJSON_GetObjectItem(event, "tags"); + if (tags_obj && cJSON_IsArray(tags_obj)) { + printf(" Tags array size: %d\n", cJSON_GetArraySize(tags_obj)); + for (int i = 0; i < cJSON_GetArraySize(tags_obj); i++) { + cJSON* tag = cJSON_GetArrayItem(tags_obj, i); + if (tag && cJSON_IsArray(tag) && cJSON_GetArraySize(tag) > 0) { + cJSON* tag_name = cJSON_GetArrayItem(tag, 0); + if (tag_name && cJSON_IsString(tag_name)) { + printf(" Tag %d: %s\n", i, cJSON_GetStringValue(tag_name)); + } + } + } + } else { + printf(" No tags array found\n"); + } + const char* action_type = get_first_tag_name(event); if (!action_type) { log_error("DEBUG: Missing or invalid first tag after processing"); - cJSON* tags_obj = cJSON_GetObjectItem(event, "tags"); - if (tags_obj && cJSON_IsArray(tags_obj)) { - printf(" Tags array size: %d\n", cJSON_GetArraySize(tags_obj)); - } else { - printf(" No tags array found\n"); - } snprintf(error_message, error_size, "invalid: missing or invalid first tag"); return -1; } - + log_info("DEBUG: Action type determined"); printf(" Action type: %s\n", action_type); @@ -3831,12 +3883,20 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s // Query time-based statistics cJSON* time_stats = cJSON_CreateObject(); - if (sqlite3_prepare_v2(g_db, "SELECT total_events, events_24h, events_7d, events_30d FROM time_stats_view", -1, &stmt, NULL) == SQLITE_OK) { - if (sqlite3_step(stmt) == SQLITE_ROW) { - cJSON_AddNumberToObject(time_stats, "total", sqlite3_column_int64(stmt, 0)); - cJSON_AddNumberToObject(time_stats, "last_24h", sqlite3_column_int64(stmt, 1)); - cJSON_AddNumberToObject(time_stats, "last_7d", sqlite3_column_int64(stmt, 2)); - cJSON_AddNumberToObject(time_stats, "last_30d", sqlite3_column_int64(stmt, 3)); + if (sqlite3_prepare_v2(g_db, "SELECT period, total_events FROM time_stats_view", -1, &stmt, NULL) == SQLITE_OK) { + while (sqlite3_step(stmt) == SQLITE_ROW) { + const char* period = (const char*)sqlite3_column_text(stmt, 0); + sqlite3_int64 count = sqlite3_column_int64(stmt, 1); + + if (strcmp(period, "total") == 0) { + cJSON_AddNumberToObject(time_stats, "total", count); + } else if (strcmp(period, "24h") == 0) { + cJSON_AddNumberToObject(time_stats, "last_24h", count); + } else if (strcmp(period, "7d") == 0) { + cJSON_AddNumberToObject(time_stats, "last_7d", count); + } else if (strcmp(period, "30d") == 0) { + cJSON_AddNumberToObject(time_stats, "last_30d", count); + } } sqlite3_finalize(stmt); } diff --git a/src/request_validator.c b/src/request_validator.c index 21177d6..0360cd3 100644 --- a/src/request_validator.c +++ b/src/request_validator.c @@ -57,15 +57,7 @@ extern int get_config_int(const char* key, int default_value); // NIP-42 constants (from nostr_core_lib) #define NOSTR_NIP42_AUTH_EVENT_KIND 22242 -// NIP-42 error codes (from nostr_core_lib) -#define NOSTR_ERROR_NIP42_CHALLENGE_NOT_FOUND -200 -#define NOSTR_ERROR_NIP42_CHALLENGE_EXPIRED -201 -#define NOSTR_ERROR_NIP42_INVALID_CHALLENGE -202 -#define NOSTR_ERROR_NIP42_URL_MISMATCH -203 -#define NOSTR_ERROR_NIP42_TIME_TOLERANCE -204 -#define NOSTR_ERROR_NIP42_AUTH_EVENT_INVALID -205 -#define NOSTR_ERROR_NIP42_INVALID_RELAY_URL -206 -#define NOSTR_ERROR_NIP42_NOT_CONFIGURED -207 +// NIP-42 error codes (from nostr_core_lib - already defined in nostr_common.h) // Forward declarations for NIP-42 functions (simple implementations for C-relay) int nostr_nip42_generate_challenge(char *challenge_buffer, size_t buffer_size); diff --git a/src/websockets.c b/src/websockets.c index 2d81542..a7c019e 100644 --- a/src/websockets.c +++ b/src/websockets.c @@ -68,6 +68,13 @@ int nostr_validate_unified_request(const char* json_string, size_t json_length); 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 NIP-17 admin messaging +int process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message, size_t error_size, struct lws* wsi); + +// 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); @@ -314,13 +321,38 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso // 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_relay_pubkey_cached(); + 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; + } + } + } + } + } + } + char debug_auth_msg[256]; snprintf(debug_auth_msg, sizeof(debug_auth_msg), - "DEBUG AUTH: auth_required=%d, pss->authenticated=%d, event_kind=%d", - auth_required, pss ? pss->authenticated : -1, event_kind); + "DEBUG AUTH: auth_required=%d, bypass_auth=%d, pss->authenticated=%d, event_kind=%d", + auth_required, bypass_auth, pss ? pss->authenticated : -1, event_kind); log_info(debug_auth_msg); - if (pss && auth_required && !pss->authenticated) { + if (pss && auth_required && !pss->authenticated && !bypass_auth) { if (!pss->auth_challenge_sent) { log_info("DEBUG AUTH: Sending NIP-42 authentication challenge"); send_nip42_auth_challenge(wsi, pss); @@ -606,6 +638,78 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso // 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 + log_info("DEBUG NIP17: Detected kind 1059 gift wrap event"); + + char nip17_error[512] = {0}; + int nip17_result = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi); + + if (nip17_result != 0) { + log_error("DEBUG NIP17: 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'; + + char debug_nip17_error_msg[600]; + snprintf(debug_nip17_error_msg, sizeof(debug_nip17_error_msg), + "DEBUG NIP17 ERROR: %.400s", nip17_error); + log_error(debug_nip17_error_msg); + } else { + log_success("DEBUG NIP17: NIP-17 admin message processed successfully"); + // Store the gift wrap event in database (unlike kind 23456) + if (store_event(event) != 0) { + log_error("DEBUG NIP17: 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 { + log_info("DEBUG NIP17: Gift wrap event stored successfully in database"); + // Broadcast gift wrap event to matching persistent subscriptions + int broadcast_count = broadcast_event_to_subscriptions(event); + char debug_broadcast_msg[128]; + snprintf(debug_broadcast_msg, sizeof(debug_broadcast_msg), + "DEBUG NIP17 BROADCAST: Gift wrap event broadcast to %d subscriptions", broadcast_count); + log_info(debug_broadcast_msg); + } + } + } else if (event_kind == 14) { + // Check for DM stats commands addressed to relay + log_info("DEBUG DM: Detected kind 14 DM event"); + + char dm_error[512] = {0}; + int dm_result = process_dm_stats_command(event, dm_error, sizeof(dm_error), wsi); + + if (dm_result != 0) { + log_error("DEBUG DM: 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'; + + char debug_dm_error_msg[600]; + snprintf(debug_dm_error_msg, sizeof(debug_dm_error_msg), + "DEBUG DM ERROR: %.400s", dm_error); + log_error(debug_dm_error_msg); + } else { + log_success("DEBUG DM: DM stats command processed successfully"); + // Store the DM event in database + if (store_event(event) != 0) { + log_error("DEBUG DM: Failed to store DM event in database"); + result = -1; + strncpy(error_message, "error: failed to store DM event", sizeof(error_message) - 1); + } else { + log_info("DEBUG DM: DM event stored successfully in database"); + // Broadcast DM event to matching persistent subscriptions + int broadcast_count = broadcast_event_to_subscriptions(event); + char debug_broadcast_msg[128]; + snprintf(debug_broadcast_msg, sizeof(debug_broadcast_msg), + "DEBUG DM BROADCAST: DM event broadcast to %d subscriptions", broadcast_count); + log_info(debug_broadcast_msg); + } + } } else { // Regular event - store in database and broadcast log_info("DEBUG STORAGE: Regular event - storing in database"); @@ -1041,6 +1145,180 @@ int start_websocket_relay(int port_override, int strict_port) { 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_relay_pubkey_cached(); + 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_admin_pubkey_cached(); + 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[4096]; + 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; + } + + log_info("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 + int broadcast_count = broadcast_event_to_subscriptions(dm_response); + char broadcast_msg[128]; + snprintf(broadcast_msg, sizeof(broadcast_msg), + "DM stats response broadcast to %d subscriptions", broadcast_count); + log_info(broadcast_msg); + + cJSON_Delete(dm_response); + + log_success("DM stats command processed successfully"); + 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 diff --git a/temp_schema.sql b/temp_schema.sql new file mode 100644 index 0000000..d0d7eb2 --- /dev/null +++ b/temp_schema.sql @@ -0,0 +1,348 @@ + +-- C Nostr Relay Database Schema\n\ +-- SQLite schema for storing Nostr events with JSON tags support\n\ +-- Configuration system using config table\n\ +\n\ +-- Schema version tracking\n\ +PRAGMA user_version = 7;\n\ +\n\ +-- Enable foreign key support\n\ +PRAGMA foreign_keys = ON;\n\ +\n\ +-- Optimize for performance\n\ +PRAGMA journal_mode = WAL;\n\ +PRAGMA synchronous = NORMAL;\n\ +PRAGMA cache_size = 10000;\n\ +\n\ +-- Core events table with hybrid single-table design\n\ +CREATE TABLE events (\n\ + id TEXT PRIMARY KEY, -- Nostr event ID (hex string)\n\ + pubkey TEXT NOT NULL, -- Public key of event author (hex string)\n\ + created_at INTEGER NOT NULL, -- Event creation timestamp (Unix timestamp)\n\ + kind INTEGER NOT NULL, -- Event kind (0-65535)\n\ + event_type TEXT NOT NULL CHECK (event_type IN ('regular', 'replaceable', 'ephemeral', 'addressable')),\n\ + content TEXT NOT NULL, -- Event content (text content only)\n\ + sig TEXT NOT NULL, -- Event signature (hex string)\n\ + tags JSON NOT NULL DEFAULT '[]', -- Event tags as JSON array\n\ + first_seen INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) -- When relay received event\n\ +);\n\ +\n\ +-- Core performance indexes\n\ +CREATE INDEX idx_events_pubkey ON events(pubkey);\n\ +CREATE INDEX idx_events_kind ON events(kind);\n\ +CREATE INDEX idx_events_created_at ON events(created_at DESC);\n\ +CREATE INDEX idx_events_event_type ON events(event_type);\n\ +\n\ +-- Composite indexes for common query patterns\n\ +CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);\n\ +CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n\ +CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);\n\ +\n\ +-- Schema information table\n\ +CREATE TABLE schema_info (\n\ + key TEXT PRIMARY KEY,\n\ + value TEXT NOT NULL,\n\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\ +);\n\ +\n\ +-- Insert schema metadata\n\ +INSERT INTO schema_info (key, value) VALUES\n\ + ('version', '7'),\n\ + ('description', 'Hybrid Nostr relay schema with event-based and table-based configuration'),\n\ + ('created_at', strftime('%s', 'now'));\n\ +\n\ +-- Helper views for common queries\n\ +CREATE VIEW recent_events AS\n\ +SELECT id, pubkey, created_at, kind, event_type, content\n\ +FROM events\n\ +WHERE event_type != 'ephemeral'\n\ +ORDER BY created_at DESC\n\ +LIMIT 1000;\n\ +\n\ +CREATE VIEW event_stats AS\n\ +SELECT \n\ + event_type,\n\ + COUNT(*) as count,\n\ + AVG(length(content)) as avg_content_length,\n\ + MIN(created_at) as earliest,\n\ + MAX(created_at) as latest\n\ +FROM events\n\ +GROUP BY event_type;\n\ +\n\ +-- Configuration events view (kind 33334)\n\ +CREATE VIEW configuration_events AS\n\ +SELECT \n\ + id,\n\ + pubkey as admin_pubkey,\n\ + created_at,\n\ + content,\n\ + tags,\n\ + sig\n\ +FROM events\n\ +WHERE kind = 33334\n\ +ORDER BY created_at DESC;\n\ +\n\ +-- Optimization: Trigger for automatic cleanup of ephemeral events older than 1 hour\n\ +CREATE TRIGGER cleanup_ephemeral_events\n\ + AFTER INSERT ON events\n\ + WHEN NEW.event_type = 'ephemeral'\n\ +BEGIN\n\ + DELETE FROM events \n\ + WHERE event_type = 'ephemeral' \n\ + AND first_seen < (strftime('%s', 'now') - 3600);\n\ +END;\n\ +\n\ +-- Replaceable event handling trigger\n\ +CREATE TRIGGER handle_replaceable_events\n\ + AFTER INSERT ON events\n\ + WHEN NEW.event_type = 'replaceable'\n\ +BEGIN\n\ + DELETE FROM events \n\ + WHERE pubkey = NEW.pubkey \n\ + AND kind = NEW.kind \n\ + AND event_type = 'replaceable'\n\ + AND id != NEW.id;\n\ +END;\n\ +\n\ +-- Addressable event handling trigger (for kind 33334 configuration events)\n\ +CREATE TRIGGER handle_addressable_events\n\ + AFTER INSERT ON events\n\ + WHEN NEW.event_type = 'addressable'\n\ +BEGIN\n\ + -- For kind 33334 (configuration), replace previous config from same admin\n\ + DELETE FROM events \n\ + WHERE pubkey = NEW.pubkey \n\ + AND kind = NEW.kind \n\ + AND event_type = 'addressable'\n\ + AND id != NEW.id;\n\ +END;\n\ +\n\ +-- Relay Private Key Secure Storage\n\ +-- Stores the relay's private key separately from public configuration\n\ +CREATE TABLE relay_seckey (\n\ + private_key_hex TEXT NOT NULL CHECK (length(private_key_hex) = 64),\n\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\ +);\n\ +\n\ +-- Authentication Rules Table for NIP-42 and Policy Enforcement\n\ +-- Used by request_validator.c for unified validation\n\ +CREATE TABLE auth_rules (\n\ + id INTEGER PRIMARY KEY AUTOINCREMENT,\n\ + rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')),\n\ + pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),\n\ + pattern_value TEXT,\n\ + action TEXT NOT NULL CHECK (action IN ('allow', 'deny', 'require_auth', 'rate_limit')),\n\ + parameters TEXT, -- JSON parameters for rate limiting, etc.\n\ + active INTEGER NOT NULL DEFAULT 1,\n\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\ +);\n\ +\n\ +-- Indexes for auth_rules performance\n\ +CREATE INDEX idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value);\n\ +CREATE INDEX idx_auth_rules_type ON auth_rules(rule_type);\n\ +CREATE INDEX idx_auth_rules_active ON auth_rules(active);\n\ +\n\ +-- Configuration Table for Table-Based Config Management\n\ +-- Hybrid system supporting both event-based and table-based configuration\n\ +CREATE TABLE config (\n\ + key TEXT PRIMARY KEY,\n\ + value TEXT NOT NULL,\n\ + data_type TEXT NOT NULL CHECK (data_type IN ('string', 'integer', 'boolean', 'json')),\n\ + description TEXT,\n\ + category TEXT DEFAULT 'general',\n\ + requires_restart INTEGER DEFAULT 0,\n\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))\n\ +);\n\ +\n\ +-- Indexes for config table performance\n\ +CREATE INDEX idx_config_category ON config(category);\n\ +CREATE INDEX idx_config_restart ON config(requires_restart);\n\ +CREATE INDEX idx_config_updated ON config(updated_at DESC);\n\ +\n\ +-- Trigger to update config timestamp on changes\n\ +CREATE TRIGGER update_config_timestamp\n\ + AFTER UPDATE ON config\n\ + FOR EACH ROW\n\ +BEGIN\n\ + UPDATE config SET updated_at = strftime('%s', 'now') WHERE key = NEW.key;\n\ +END;\n\ +\n\ +-- Insert default configuration values\n\ +INSERT INTO config (key, value, data_type, description, category, requires_restart) VALUES\n\ + ('relay_description', 'A C Nostr Relay', 'string', 'Relay description', 'general', 0),\n\ + ('relay_contact', '', 'string', 'Relay contact information', 'general', 0),\n\ + ('relay_software', 'https://github.com/laanwj/c-relay', 'string', 'Relay software URL', 'general', 0),\n\ + ('relay_version', '1.0.0', 'string', 'Relay version', 'general', 0),\n\ + ('relay_port', '8888', 'integer', 'Relay port number', 'network', 1),\n\ + ('max_connections', '1000', 'integer', 'Maximum concurrent connections', 'network', 1),\n\ + ('auth_enabled', 'false', 'boolean', 'Enable NIP-42 authentication', 'auth', 0),\n\ + ('nip42_auth_required_events', 'false', 'boolean', 'Require auth for event publishing', 'auth', 0),\n\ + ('nip42_auth_required_subscriptions', 'false', 'boolean', 'Require auth for subscriptions', 'auth', 0),\n\ + ('nip42_auth_required_kinds', '[]', 'json', 'Event kinds requiring authentication', 'auth', 0),\n\ + ('nip42_challenge_expiration', '600', 'integer', 'Auth challenge expiration seconds', 'auth', 0),\n\ + ('pow_min_difficulty', '0', 'integer', 'Minimum proof-of-work difficulty', 'validation', 0),\n\ + ('pow_mode', 'optional', 'string', 'Proof-of-work mode', 'validation', 0),\n\ + ('nip40_expiration_enabled', 'true', 'boolean', 'Enable event expiration', 'validation', 0),\n\ + ('nip40_expiration_strict', 'false', 'boolean', 'Strict expiration mode', 'validation', 0),\n\ + ('nip40_expiration_filter', 'true', 'boolean', 'Filter expired events in queries', 'validation', 0),\n\ + ('nip40_expiration_grace_period', '60', 'integer', 'Expiration grace period seconds', 'validation', 0),\n\ + ('max_subscriptions_per_client', '25', 'integer', 'Maximum subscriptions per client', 'limits', 0),\n\ + ('max_total_subscriptions', '1000', 'integer', 'Maximum total subscriptions', 'limits', 0),\n\ + ('max_filters_per_subscription', '10', 'integer', 'Maximum filters per subscription', 'limits', 0),\n\ + ('max_event_tags', '2000', 'integer', 'Maximum tags per event', 'limits', 0),\n\ + ('max_content_length', '100000', 'integer', 'Maximum event content length', 'limits', 0),\n\ + ('max_message_length', '131072', 'integer', 'Maximum WebSocket message length', 'limits', 0),\n\ + ('default_limit', '100', 'integer', 'Default query limit', 'limits', 0),\n\ + ('max_limit', '5000', 'integer', 'Maximum query limit', 'limits', 0);\n\ +\n\ +-- Persistent Subscriptions Logging Tables (Phase 2)\n\ +-- Optional database logging for subscription analytics and debugging\n\ +\n\ +-- Subscription events log\n\ +CREATE TABLE subscription_events (\n\ + id INTEGER PRIMARY KEY AUTOINCREMENT,\n\ + subscription_id TEXT NOT NULL, -- Subscription ID from client\n\ + client_ip TEXT NOT NULL, -- Client IP address\n\ + event_type TEXT NOT NULL CHECK (event_type IN ('created', 'closed', 'expired', 'disconnected')),\n\ + filter_json TEXT, -- JSON representation of filters (for created events)\n\ + events_sent INTEGER DEFAULT 0, -- Number of events sent to this subscription\n\ + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\ + ended_at INTEGER, -- When subscription ended (for closed/expired/disconnected)\n\ + duration INTEGER -- Computed: ended_at - created_at\n\ +);\n\ +\n\ +-- Subscription metrics summary\n\ +CREATE TABLE subscription_metrics (\n\ + id INTEGER PRIMARY KEY AUTOINCREMENT,\n\ + date TEXT NOT NULL, -- Date (YYYY-MM-DD)\n\ + total_created INTEGER DEFAULT 0, -- Total subscriptions created\n\ + total_closed INTEGER DEFAULT 0, -- Total subscriptions closed\n\ + total_events_broadcast INTEGER DEFAULT 0, -- Total events broadcast\n\ + avg_duration REAL DEFAULT 0, -- Average subscription duration\n\ + peak_concurrent INTEGER DEFAULT 0, -- Peak concurrent subscriptions\n\ + updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\ + UNIQUE(date)\n\ +);\n\ +\n\ +-- Event broadcasting log (optional, for detailed analytics)\n\ +CREATE TABLE event_broadcasts (\n\ + id INTEGER PRIMARY KEY AUTOINCREMENT,\n\ + event_id TEXT NOT NULL, -- Event ID that was broadcast\n\ + subscription_id TEXT NOT NULL, -- Subscription that received it\n\ + client_ip TEXT NOT NULL, -- Client IP\n\ + broadcast_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\ + FOREIGN KEY (event_id) REFERENCES events(id)\n\ +);\n\ +\n\ +-- Indexes for subscription logging performance\n\ +CREATE INDEX idx_subscription_events_id ON subscription_events(subscription_id);\n\ +CREATE INDEX idx_subscription_events_type ON subscription_events(event_type);\n\ +CREATE INDEX idx_subscription_events_created ON subscription_events(created_at DESC);\n\ +CREATE INDEX idx_subscription_events_client ON subscription_events(client_ip);\n\ +\n\ +CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC);\n\ +\n\ +CREATE INDEX idx_event_broadcasts_event ON event_broadcasts(event_id);\n\ +CREATE INDEX idx_event_broadcasts_sub ON event_broadcasts(subscription_id);\n\ +CREATE INDEX idx_event_broadcasts_time ON event_broadcasts(broadcast_at DESC);\n\ +\n\ +-- Trigger to update subscription duration when ended\n\ +CREATE TRIGGER update_subscription_duration\n\ + AFTER UPDATE OF ended_at ON subscription_events\n\ + WHEN NEW.ended_at IS NOT NULL AND OLD.ended_at IS NULL\n\ +BEGIN\n\ + UPDATE subscription_events\n\ + SET duration = NEW.ended_at - NEW.created_at\n\ + WHERE id = NEW.id;\n\ +END;\n\ +\n\ +-- View for subscription analytics\n\ +CREATE VIEW subscription_analytics AS\n\ +SELECT\n\ + date(created_at, 'unixepoch') as date,\n\ + COUNT(*) as subscriptions_created,\n\ + COUNT(CASE WHEN ended_at IS NOT NULL THEN 1 END) as subscriptions_ended,\n\ + AVG(CASE WHEN duration IS NOT NULL THEN duration END) as avg_duration_seconds,\n\ + MAX(events_sent) as max_events_sent,\n\ + AVG(events_sent) as avg_events_sent,\n\ + COUNT(DISTINCT client_ip) as unique_clients\n\ +FROM subscription_events\n\ +GROUP BY date(created_at, 'unixepoch')\n\ +ORDER BY date DESC;\n\ +\n\ +-- View for current active subscriptions (from log perspective)\n\ +CREATE VIEW active_subscriptions_log AS\n\ +SELECT\n\ + subscription_id,\n\ + client_ip,\n\ + filter_json,\n\ + events_sent,\n\ + created_at,\n\ + (strftime('%s', 'now') - created_at) as duration_seconds\n\ +FROM subscription_events\n\ +WHERE event_type = 'created'\n\ +AND subscription_id NOT IN (\n\ + SELECT subscription_id FROM subscription_events\n\ + WHERE event_type IN ('closed', 'expired', 'disconnected')\n\ +);\n\ +\n\ +-- Database Statistics Views for Admin API\n\ +-- Event kinds distribution view\n\ +CREATE VIEW event_kinds_view AS\n\ +SELECT\n\ + kind,\n\ + COUNT(*) as count,\n\ + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage\n\ +FROM events\n\ +GROUP BY kind\n\ +ORDER BY count DESC;\n\ +\n\ +-- Top pubkeys by event count view\n\ +CREATE VIEW top_pubkeys_view AS\n\ +SELECT\n\ + pubkey,\n\ + COUNT(*) as event_count,\n\ + ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM events), 2) as percentage\n\ +FROM events\n\ +GROUP BY pubkey\n\ +ORDER BY event_count DESC\n\ +LIMIT 10;\n\ +\n\ +-- Time-based statistics view\n\ +CREATE VIEW time_stats_view AS\n\ +SELECT\n\ + 'total' as period,\n\ + COUNT(*) as total_events,\n\ + COUNT(DISTINCT pubkey) as unique_pubkeys,\n\ + MIN(created_at) as oldest_event,\n\ + MAX(created_at) as newest_event\n\ +FROM events\n\ +UNION ALL\n\ +SELECT\n\ + '24h' as period,\n\ + COUNT(*) as total_events,\n\ + COUNT(DISTINCT pubkey) as unique_pubkeys,\n\ + MIN(created_at) as oldest_event,\n\ + MAX(created_at) as newest_event\n\ +FROM events\n\ +WHERE created_at >= (strftime('%s', 'now') - 86400)\n\ +UNION ALL\n\ +SELECT\n\ + '7d' as period,\n\ + COUNT(*) as total_events,\n\ + COUNT(DISTINCT pubkey) as unique_pubkeys,\n\ + MIN(created_at) as oldest_event,\n\ + MAX(created_at) as newest_event\n\ +FROM events\n\ +WHERE created_at >= (strftime('%s', 'now') - 604800)\n\ +UNION ALL\n\ +SELECT\n\ + '30d' as period,\n\ + COUNT(*) as total_events,\n\ + COUNT(DISTINCT pubkey) as unique_pubkeys,\n\ + MIN(created_at) as oldest_event,\n\ + MAX(created_at) as newest_event\n\ +FROM events\n\ +WHERE created_at >= (strftime('%s', 'now') - 2592000); diff --git a/test_stats_query.sh b/test_stats_query.sh new file mode 100755 index 0000000..42f1b2f --- /dev/null +++ b/test_stats_query.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Test script for stats query functionality +# Uses the admin private key generated during startup + +ADMIN_PRIVKEY="5f43e99864c3b2a3d10fa6aa25d3042936017e929c6f82d2b4c974af4502af21" +ADMIN_PUBKEY="8f0306d7d4e0ddadf43caeb72791e1a2c6185eec2301f56655f666adab153226" +RELAY_PUBKEY="df5248728b4dfe4fa7cf760b2efa58fcd284111e7df2b9ddef09a11f17ffa0d0" + +echo "Testing stats query with NIP-17 encryption..." +echo "Admin pubkey: $ADMIN_PUBKEY" +echo "Relay pubkey: $RELAY_PUBKEY" + +# Create the command array for stats_query +COMMAND='["stats_query"]' + +echo "Command to encrypt: $COMMAND" + +# For now, let's just check if the relay is running and can accept connections +echo "Checking if relay is running..." +curl -s -H "Accept: application/nostr+json" http://localhost:8888 | head -20 + +echo -e "\nTesting WebSocket connection..." +timeout 5 wscat -c ws://localhost:8888 <<< '{"type": "REQ", "id": "test", "filters": []}' || echo "WebSocket test completed" + +echo "Stats query test completed." \ No newline at end of file diff --git a/tests/17_nip_test.sh b/tests/17_nip_test.sh new file mode 100755 index 0000000..1f20edf --- /dev/null +++ b/tests/17_nip_test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail + +# nip17_stats_dm_test.sh - Test NIP-17 DM "stats" command functionality +# Sends a DM with content "stats" to the relay and checks for response + +# Test key configuration (from make_and_restart_relay.sh -t) +ADMIN_PRIVATE_KEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +ADMIN_PUBLIC_KEY="6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3" +RELAY_PUBLIC_KEY="4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" +RELAY_URL="ws://localhost:8888" + +echo "=== NIP-17 DM Stats Test ===" +echo "Admin pubkey: $ADMIN_PUBLIC_KEY" +echo "Relay pubkey: $RELAY_PUBLIC_KEY" +echo "Relay URL: $RELAY_URL" +echo "" + +# Check if nak is available +if ! command -v nak &> /dev/null; then + echo "ERROR: nak command not found!" + echo "Please install nak from https://github.com/fiatjaf/nak" + echo "Or ensure it's in your PATH" + exit 1 +fi + +echo "✓ nak command found" + +# Check if relay is running by testing connection +echo "Testing relay connection..." +if ! timeout 5 bash -c "/dev/null; then + echo "ERROR: Relay does not appear to be running on localhost:8888" + echo "Please start the relay first with: ./make_and_restart_relay.sh" + exit 1 +fi + +echo "✓ Relay appears to be running" + +# Create inner DM event JSON +INNER_DM_JSON=$(cat <&1) +ENCRYPT_EXIT_CODE=$? + +if [ $ENCRYPT_EXIT_CODE -ne 0 ]; then + echo "ERROR: Failed to encrypt inner DM" + echo "nak output: $ENCRYPTED_CONTENT" + exit 1 +fi + +echo "✓ Inner DM encrypted successfully" +echo "Encrypted content: $ENCRYPTED_CONTENT" + +# Send NIP-17 gift wrap event +echo "" +echo "Sending NIP-17 gift wrap with encrypted DM..." +echo "Command: nak event -k 1059 -p $RELAY_PUBLIC_KEY -c '$ENCRYPTED_CONTENT' --sec $ADMIN_PRIVATE_KEY $RELAY_URL" + +DM_RESULT=$(nak event -k 1059 -p "$RELAY_PUBLIC_KEY" -c "$ENCRYPTED_CONTENT" --sec "$ADMIN_PRIVATE_KEY" "$RELAY_URL" 2>&1) +DM_EXIT_CODE=$? + +if [ $DM_EXIT_CODE -ne 0 ]; then + echo "ERROR: Failed to send gift wrap" + echo "nak output: $DM_RESULT" + exit 1 +fi + +echo "✓ Gift wrap sent successfully" +echo "nak output: $DM_RESULT" + +# Wait a moment for processing +echo "" +echo "Waiting 3 seconds for relay to process and respond..." +sleep 3 + +# Query for gift wrap responses from the relay (kind 1059 events authored by relay) +echo "" +echo "Querying for gift wrap responses from relay..." +echo "Command: nak req -k 1059 --authors $RELAY_PUBLIC_KEY $RELAY_URL" + +# Capture the output and filter for events +RESPONSE_OUTPUT=$(nak req -k 1059 --authors "$RELAY_PUBLIC_KEY" "$RELAY_URL" 2>&1) +REQ_EXIT_CODE=$? + +echo "" +echo "=== Relay DM Response ===" +if [ $REQ_EXIT_CODE -eq 0 ]; then + # Try to parse and pretty-print the JSON response + echo "$RESPONSE_OUTPUT" | jq . 2>/dev/null || echo "$RESPONSE_OUTPUT" +else + echo "ERROR: Failed to query DMs" + echo "nak output: $RESPONSE_OUTPUT" + exit 1 +fi + +echo "" +echo "=== Test Complete ===" +echo "If you see a gift wrap event above with encrypted content containing stats data," +echo "then the NIP-17 DM 'stats' command is working correctly." \ No newline at end of file