v0.4.9 - Working on dm admin

This commit is contained in:
Your Name
2025-10-04 19:04:12 -04:00
parent 64b418a551
commit c63fd04c92
14 changed files with 2331 additions and 56 deletions

313
clean_schema.sql Normal file
View File

@@ -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);

View File

@@ -1 +1 @@
716467
802896

696
schema.sql Normal file
View File

@@ -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 */

447
src/api.c
View File

@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
@@ -9,6 +9,18 @@
#include <libwebsockets.h>
#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;
}

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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

348
temp_schema.sql Normal file
View File

@@ -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);

26
test_stats_query.sh Executable file
View File

@@ -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."

114
tests/17_nip_test.sh Executable file
View File

@@ -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/tcp/localhost/8888" 2>/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 <<EOF
{
"kind": 14,
"pubkey": "$ADMIN_PUBLIC_KEY",
"created_at": $(date +%s),
"tags": [["p", "$RELAY_PUBLIC_KEY"]],
"content": "[\"stats\"]"
}
EOF
)
echo "Inner DM JSON:"
echo "$INNER_DM_JSON"
# Encrypt the inner DM JSON with NIP-44 using relay pubkey
echo ""
echo "Encrypting inner DM with NIP-44..."
ENCRYPTED_CONTENT=$(nak encrypt -p "$RELAY_PUBLIC_KEY" --sec "$ADMIN_PRIVATE_KEY" "$INNER_DM_JSON" 2>&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."