From c63fd04c929232e82223c66855ef350c77e6e468 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 4 Oct 2025 19:04:12 -0400 Subject: [PATCH] v0.4.9 - Working on dm admin --- ...51589a19b7749911f5344bb85e5fa3163f0.db-shm | Bin 32768 -> 0 bytes ...51589a19b7749911f5344bb85e5fa3163f0.db-wal | Bin 486192 -> 0 bytes clean_schema.sql | 313 ++++++++ nostr_core_lib | 2 +- relay.pid | 2 +- schema.sql | 696 ++++++++++++++++++ src/api.c | 447 ++++++++++- src/api.h | 3 + src/config.c | 142 ++-- src/request_validator.c | 10 +- src/websockets.c | 284 ++++++- temp_schema.sql | 348 +++++++++ test_stats_query.sh | 26 + tests/17_nip_test.sh | 114 +++ 14 files changed, 2331 insertions(+), 56 deletions(-) delete mode 100644 b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm delete mode 100644 b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-wal create mode 100644 clean_schema.sql create mode 100644 schema.sql create mode 100644 temp_schema.sql create mode 100755 test_stats_query.sh create mode 100755 tests/17_nip_test.sh diff --git a/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm b/b1cfc168265a1b699187f9fd417cb51589a19b7749911f5344bb85e5fa3163f0.db-shm deleted file mode 100644 index 495b13d4af2c13040a8dbaf021b2ee2d2342f378..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeI5$5K^66h$|Ph=_=Ql0*T)42l^8m@_EqGv|PqvsM0q|DpruP96IZPW%W5+K1|^ zUDk8(s%S#rwX3?$>3)4r_g?kJr<=d`%Cy0aGO6yJi>ustKQKOA{^8rF!I6r-uOIts z#tL74ejfc%XfAQTD>Wgj`zw%N{uA8UDtBX!8HYALsd4<>P!Gt9@MHVvQSF zv|9fg3;l1kE-rGT&W*)>&GPynZwT_nAYbD0rNO)@$eV+_CCIaHY_ILfe#`FA8tiRz z`7%GxesBM0zTC}M_%(|iLEahUT|vGw$h(8QC&=x8VC=%$#pc<)vVGy)eU2ai0SG_< z0uX=z1Rwwb2tWV=5P$##AOHafKmY;|fB*y_009U<00Izz00bZa0SG_<0uX=z1Rwwb z2tWV=5P$##AOHafKmY;|fB*y_009U<00Oxrut|G!>AQ5(WvV|IXo*w9-9_y)|>vidu z;EPEmutkHDS})yV)^7!+MPR#z(lQjyjddDAX%QIEzO)QQb7OtAP+9~IrDZ3g8|xy6 z(jss)Ejtn2SkFY17J*}F*@@`JI)kFL2%JjGPDD4>^A@E=;7nR}BD%58#3(HSqdKPx zx~yxusXMx_aXr!#J<}^$&vEpZfb}*iv_|XIr_I`?o!YH~I--+0t(SURWIszYpTOA6zv2wJp}lg5AdpW2_I@XV fKt2hq&8KZ#L%w#ELR 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 TP9fTq-@bL$#C^-?M*jZ+3dbc= 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