Compare commits

...
161 Commits
Author SHA1 Message Date
Laan Tungir 81c5339063 v2.1.28 - Add kind-3 fallback for relay discovery (NIP-65 pre-standard relay lists in contact list r tags) 2026-07-31 13:58:24 -04:00
Laan Tungir 5ec5ed9bd4 v2.1.27 - Caching page: active backfill row highlight (red border), increase follows limit to 1000, accept npub or hex for caching_root_npubs, fix static caching binary build with PostgreSQL 2026-07-31 11:42:27 -04:00
Laan Tungir bd5150cd09 v2.1.26 - Accept both npub and hex format for caching_root_npubs, fix static caching binary build with PostgreSQL support 2026-07-31 11:23:09 -04:00
Laan Tungir 2e1d731de4 v2.1.25 - Fix: insert missing config keys on existing-relay startup, preserve config.php in deploy_lt.sh 2026-07-31 11:12:21 -04:00
Laan Tungir 75d983b2df v2.1.24 - Reactive caching service start/stop via LISTEN/NOTIFY, per-relay progress display, re-run/refresh caching controls 2026-07-31 10:08:45 -04:00
Laan Tungir a766f08738 v2.1.23 - Caching service: descriptive error reporting, retry limit, inbox poller fix, config reload fix, PHP admin page 2026-07-28 16:37:08 -04:00
Laan Tungir 23c37abe3a Update nostr_core_lib: increase receive timeout to 1000ms 2026-07-28 10:14:47 -04:00
Laan Tungir 5b3ccfd1f4 Update nostr_core_lib: handle NOTICE/CLOSED in synchronous_query 2026-07-28 09:39:22 -04:00
Laan Tungir 38a027e626 Update nostr_core_lib submodule: increase receive buffer to 256KB 2026-07-28 07:52:08 -04:00
Laan Tungir a042aca524 v2.1.22 - Added --start-caching and --reset-backfill CLI options, redirected caching_relay output to log file, increased backfill page size to 200 and max cap to 1000 for better completeness 2026-07-26 18:35:35 -04:00
Laan Tungir 20902e1c5d v2.1.21 - Fixed caching_service_binary_path to ./caching_relay (relative to relay CWD), added caching_relay build step to make_and_restart_relay.sh, added default caching config values (admin npub, 4 bootstrap relays, kinds 0 and 10002) 2026-07-26 14:56:16 -04:00
Laan Tungir 7233818da9 v2.1.20 - Added default caching config values: admin npub as root, 4 bootstrap relays, kinds 0 and 10002 added to default kinds list 2026-07-25 18:21:28 -04:00
Laan Tungir 86cbf7ff16 v2.1.19 - Consolidated caching_relay code into caching/ directory, switched launcher to PostgreSQL mode (-p pg_conn) so all config comes from the config table instead of a JSON config file 2026-07-25 16:27:56 -04:00
Laan Tungir 3035e71ca5 v2.1.18 - Fixed caching service integration: aligned caching_status response schema, fixed launcher to use -c config_path instead of --pg-conn, added caching service config fields to UI, replaced misleading not-implemented placeholder with accurate auth-failure messaging 2026-07-25 11:19:37 -04:00
Laan Tungir 179b160cf3 v2.1.17 - Verified api-worker LISTEN/NOTIFY monitoring on v2.1.16 relay (port 7777): subscriber-cadence, NOTIFY-reactive, and zero-overhead idle tests all pass 2026-07-25 10:17:30 -04:00
Laan Tungir aedd22436e v2.1.16 - Implemented api-worker timer + LISTEN/NOTIFY monitoring (Phases 1,2,5) and fixed thread-connection binding bug that stopped dashboard updates 2026-07-25 10:07:08 -04:00
Laan Tungir 8fdc362a8b v2.1.15 - Fix PG addressable d-tag derivation and replaceable/addressable transactional replace semantics 2026-05-01 19:01:06 -04:00
Laan Tungir 67cf3973c5 v2.1.14 - Fix PostgreSQL replaceable/addressable upsert semantics with NIP-01 conflict ordering 2026-05-01 14:38:57 -04:00
Laan Tungir c02ae2be5b v2.1.13 - Fix PostgreSQL startup detection for existing relays and make admin_pubkey parsing whitespace-tolerant 2026-04-03 16:36:56 -04:00
Laan Tungir fd3efdd01f v2.1.12 - Implement multi-admin auth cache and checks, add admin management commands, and add multi-admin integration test 2026-04-03 15:46:33 -04:00
Laan Tungir 232f93c16f v2.1.11 - Fix api-worker DB lifecycle and NIP test reliability: add dedicated worker DB connection, guard PG expiration cast in REQ path, correct NIP-45 baseline-aware expectation, and harden run_all_tests skip handling 2026-04-03 07:32:14 -04:00
Laan Tungir 9a9365dcfa v2.1.10 - Fix NIP-50 PG search semantics and NIP-17 test compatibility 2026-04-02 13:00:34 -04:00
Laan Tungir 5577b57149 v2.1.9 - Rebrand repository, docs, scripts, and tooling from c-relay to c-relay-pg 2026-04-02 07:06:41 -04:00
Laan Tungir 22c943d495 v2.1.8 - Implement explicit WAL checkpoint scheduling and disable SQLite auto-checkpoint 2026-04-01 21:03:24 -04:00
Laan Tungir 7e3d9b6825 v2.1.7 - Refactor SQLite connection ownership into db_ops, remove direct sqlite usage from runtime modules, and document agent-browser admin testing flow 2026-04-01 19:48:29 -04:00
Laan Tungir 547d22d09f v2.1.6 - Fix strict-mode admin API key access by prewarming config and relay private key caches 2026-04-01 19:08:42 -04:00
Laan Tungir ce9ae5a488 v2.1.5 - Offload duplicate and COUNT paths, add config/NIP-11 caching, and reduce DB reader threads 2026-04-01 16:15:46 -04:00
Laan Tungir 9b248b740f v2.1.4 - Harden async EVENT fallback handling and verify relay/nginx 503 behavior 2026-04-01 15:19:28 -04:00
Laan Tungir 0f77aeb72b v2.1.3 - Fix async EVENT thread safety by splitting store core/post-actions and naming lws main thread 2026-04-01 11:29:14 -04:00
Laan Tungir bb64651a0c v2.1.2 - Fix async REQ callback leak and validate Phase 2+3 subscription offload path 2026-04-01 11:01:38 -04:00
Laan Tungir 17be9c2b03 v2.1.1 - Wire REQ/COUNT/EVENT paths through thread pool sync helpers and stabilize filter limit tests 2026-04-01 09:38:25 -04:00
Laan Tungir f396c622b6 v2.1.0 - Bump to v2.1.0 as SQLite baseline for PostgreSQL fork handoff 2026-04-01 09:14:23 -04:00
Laan Tungir cbd260c1ae v2.0.4 - Complete Phase 1 DB abstraction: opaque db_stmt API, ip_ban migration, and SQLite leakage cleanup 2026-04-01 09:10:42 -04:00
Laan Tungir bc9ac290ab v2.0.3 - Complete Phase 1 SQLite abstraction cleanup across config/api/websockets and add db path accessor 2026-04-01 07:47:21 -04:00
Laan Tungir 192eeb248d v2.0.2 - Stabilize websocket queue draining, add db config count helper, and align NIP-45/50 test expectations 2026-04-01 07:04:58 -04:00
Laan Tungir da6c505420 v2.0.1 - Fix test-mode build/restart pipeline and NIP-11 root path handling; validate core NIP tests 2026-04-01 05:43:57 -04:00
Laan Tungir c077c209e5 v2.0.0 - Major architecture refactor: db_ops abstraction, low-risk SQLite refactor, and thread-pool scaffolding 2026-04-01 05:25:03 -04:00
Laan Tungir 9bf02702c2 v1.2.56 - Update codebase and documentation 2026-04-01 04:51:44 -04:00
Your Name 0920cc092d v1.2.55 - Preserve relay version metadata for API header/title and accept JSON NIP-11 content type 2026-03-23 09:09:50 -04:00
Your Name a89460be5d v1.2.54 - Sync relay_version to compiled CRELAY_VERSION on startup and show version in API header/title 2026-03-23 09:04:49 -04:00
Your Name ff2a3aa335 v1.2.53 - Fix P0 config-value memory leaks in hot paths and add investigation plan 2026-03-19 07:59:40 -04:00
Your Name 94b61e8a7c v1.2.52 - Update nostr_core_lib to v0.4.13 and route nostr logs through relay callback logger 2026-03-13 14:33:08 -04:00
Your Name b0c0754e83 v1.2.51 - Fix NIP-01 non-compliance: limit:0 now correctly returns zero stored events instead of falling through to default_limit 2026-03-04 11:23:58 -04:00
Your Name 3265e3d114 v1.2.50 - Fix NIP-42 relay URL verification and add onauth to all publish flows 2026-03-01 12:20:44 -04:00
Your Name 927659ece1 v1.2.49 - Fix: MEM% bar format (bar then MB), move CPU Core row after CPU Usage 2026-02-25 07:37:50 -04:00
Your Name b6ff4150b4 v1.2.48 - Add CPU% and MEM% ASCII bar graphs to API page stats 2026-02-25 07:33:06 -04:00
Your Name 63bc526163 v1.2.47 - Fix build: use getter function for g_connection_count (was static, can't extern) 2026-02-25 07:24:17 -04:00
Your Name a1f712236a v1.2.46 - Fix: move extern g_connection_count to file scope in api.c 2026-02-25 07:22:14 -04:00
Your Name 06e6c17b7b v1.2.45 - Add WebSocket Connections to system status: api.c sends active_connections, HTML+JS display it 2026-02-25 07:18:42 -04:00
Your Name 0751a7c55c v1.2.44 - IP Bans: show idle+auth bans in stats/status, fix filter buttons, add idle ban columns to query 2026-02-25 07:12:07 -04:00
Your Name f1728932a9 v1.2.43 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:20 -04:00
Your Name ef8bdef2a8 v1.2.42 - IP Bans page: fix filter buttons, add whitelist management UI, compact table rows 2026-02-25 07:08:12 -04:00
Your Name c11a8ba292 v1.2.41 - Fix IP Bans page: route ip_bans SQL responses to handleIpBansResponse 2026-02-25 06:59:22 -04:00
Your Name 0f124fe575 v1.2.40 - IP Bans page: show whitelisted IPs with star icon, hide Unban button for whitelisted IPs 2026-02-25 06:54:35 -04:00
Your Name 6b20452fab v1.2.39 - Fix IP Bans page: convert rows+columns SQL response format to objects for display 2026-02-25 06:53:50 -04:00
Your Name 10c19bc243 v1.2.38 - Add idle_ban_whitelist config: comma-separated IPs that are never idle-banned 2026-02-25 06:48:41 -04:00
Your Name 3d7aa2196f v1.2.37 - Fix: executeSqlQueryRaw uses relayPool/sendAdminCommand instead of undefined pool variable 2026-02-25 06:43:29 -04:00
Your Name a416c3f275 v1.2.36 - Fix: don't reset relay connection state when external relays time out in subscription onclose 2026-02-25 06:33:25 -04:00
Your Name bba9baabc3 v1.2.35 - Fix: only record idle ban failures for WebSocket connections, not HTTP requests (NIP-11/embedded files) 2026-02-24 17:41:59 -04:00
Your Name 31187c4c4f v1.2.34 - Fix: mark HTTP requests (NIP-11, embedded files) as session_active to prevent idle ban on legitimate HTTP clients 2026-02-24 17:07:33 -04:00
Your Name 55f862b879 v1.2.33 - Set default debug_level to 3 (INFO) 2026-02-24 16:06:19 -04:00
Your Name 76c9b3fcf0 v1.2.32 - Set idle_ban_threshold default to 1 and idle_ban_window_sec default to 30 2026-02-24 15:45:40 -04:00
Your Name 929bd09164 v1.2.31 - Fix idle connection timeout: use global connection list + periodic timer instead of lws_set_timeout which was not firing 2026-02-24 15:44:11 -04:00
Your Name d17f1dd8d5 v1.2.30 - Temporarily bypass admin verification on API page (idle timeout fix pending) 2026-02-24 14:55:39 -04:00
Your Name 207a949835 v1.2.29 - Added idle connection ban system - bans IPs that connect but never send REQ/EVENT (idle timeout or early disconnect) with separate rate limiting from auth failures 2026-02-24 14:18:27 -04:00
Your Name d08f4e4221 v1.2.28 - Add debug_level config setting: live update every 60s without restart, default 0 2026-02-24 08:56:32 -04:00
Your Name c96736fa6a v1.2.27 - Permanent ban escalation: ban_count never resets, IPs with history always get 24h ban on next failure 2026-02-23 18:18:55 -04:00
Your Name f8ec4ae924 v1.2.26 - Persistent IP ban table: save/load to ip_bans DB, auth success tracking, lifetime stats per IP 2026-02-23 18:16:14 -04:00
Your Name fe7304ac7f v1.2.25 - Add NIP-42 AUTH support to web UI: onauth callback in subscribeMany and publish calls 2026-02-23 18:03:29 -04:00
Your Name e5d39c984b v1.2.24 - Fix: run ip_ban maintenance unconditionally every 60s regardless of max_connection_seconds setting 2026-02-23 17:57:20 -04:00
Your Name 5e45f21e35 v1.2.23 - ip_ban: retain ban_count for 24 hours after last ban expiry, then fully clean entry 2026-02-23 17:16:02 -04:00
Your Name 5321a238b8 v1.2.22 - Fix ip_ban cleanup: preserve ban_count on cleanup so exponential backoff persists across ban expiry 2026-02-23 17:12:15 -04:00
Your Name 083bc14972 v1.2.21 - Fix IP ban check: use pss->client_ip (proxy-aware) instead of raw socket IP to match failure recording 2026-02-23 16:26:23 -04:00
Your Name 11aaccba9b v1.2.20 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:02:07 -04:00
Your Name bd1bbd763d v1.2.19 - Add IP auth failure ban system: track failures per IP, ban after threshold with exponential backoff, periodic log stats 2026-02-23 16:00:53 -04:00
Your Name 2bd7aa5a10 v1.2.18 - Proactive auth timeout via lws_set_timeout: close idle unauthenticated connections after nip42_auth_timeout_sec even without REQ 2026-02-23 15:37:45 -04:00
Your Name 361912ec85 v1.2.17 - Add nip42_auth_timeout_sec (default 10s): close unauthenticated connections after timeout to prevent connection accumulation 2026-02-23 15:22:18 -04:00
Your Name 0de491382e v1.2.16 - Fix auth rules UI: change label/placeholder/errors from nsec to npub (public key, not private key) 2026-02-23 14:36:19 -04:00
Your Name 3148bbbee7 v1.2.15 - Admin verification: show 'Waiting for response' with status updates, 60s timeout, no premature access denied 2026-02-23 14:26:02 -04:00
Your Name 3965ba04d8 v1.2.14 - Handle late admin verification response: grant access even if timeout already fired and access-denied overlay was shown 2026-02-23 14:24:55 -04:00
Your Name b96af938bd v1.2.13 - Add WoT status to NIP-11 response: restricted_writes, auth_required, and web_of_trust object when WoT enabled 2026-02-23 14:22:08 -04:00
Your Name 89c8248013 v1.2.12 - Increase admin verification timeout from 5s to 30s; continue waiting after publish timeout under heavy load 2026-02-23 14:16:56 -04:00
Your Name d8f477c6cf v1.2.11 - Early duplicate check before secp256k1 signature verification — skip crypto for events already in DB 2026-02-23 14:01:23 -04:00
Your Name 040eeadb13 v1.2.10 - Zero-copy message queue: eliminate memcpy in broadcast and REQ paths via queue_message_take_ownership() 2026-02-23 13:45:56 -04:00
Your Name 83f8b0ab88 v1.2.9 - Replace cJSON_GetObjectItem with CaseSensitive variant (178 calls) — eliminates 14% CPU from tolower+linear scan in hot path 2026-02-23 13:25:50 -04:00
Your Name b82f7eaaf3 v1.2.8 - Add debug build support: _debug suffix in build_static.sh, deploy_lt_debug.sh with perf profiling workflow 2026-02-23 13:07:53 -04:00
Your Name 0dc5b75d7c v1.2.7 - Add configurable SQLite mmap_size (256MB) and cache_size (64MB) PRAGMAs for ~20% CPU reduction from DB read overhead 2026-02-23 09:48:01 -04:00
Your Name e94b1a81e3 v1.2.6 - Add MAX_MESSAGE_QUEUE_SIZE=500 limit to prevent OOM from unbounded write queue; fix wasted buf allocation in broadcast 2026-02-23 09:12:09 -04:00
Your Name 1adabdbc4e v1.2.5 - Add nip17_admin_enabled config toggle (default off) to prevent OOM from NIP-17 gift wrap decryption flood 2026-02-23 08:58:10 -04:00
Your Name 81d44c3d8c v1.2.4 - Add more characters to valid subscription characters 2026-02-11 05:56:09 -04:00
Your Name 7acc0bdd90 v1.2.3 - Handle rate limiting properly on kind 99999 request 2026-02-09 07:49:43 -04:00
Your Name c4ef71d673 v1.2.2 - Add + to allowed subscription characters 2026-02-07 13:32:00 -04:00
Your Name 086d2af56c v1.2.1 - Handle NDKs pings of kind 99999 2026-02-03 13:19:33 -04:00
Your Name 18a7deec54 v1.2.0 - Schema v11: Added event_json storage for 2500x performance improvement 2026-02-02 20:27:50 -04:00
Your Name c794370a3f v1.1.9 - Add composite index for active_subscriptions_log view optimization (schema v10) - reduces monitoring queries from 540ms to <10ms 2026-02-02 11:38:57 -04:00
Your Name 2ed4b96058 v1.1.8 - Add comprehensive database query logging with timing at debug level 3, fix schema version compatibility (v6-v9), add version logging at startup, allow monitoring throttle=0 to disable monitoring 2026-02-02 11:20:11 -04:00
Your Name c0051b22be v1.1.7 - Add per-connection database query tracking for abuse detection
Implemented comprehensive database query tracking to identify clients causing
high CPU usage through excessive database queries. The relay now tracks and
displays query statistics per WebSocket connection in the admin UI.

Features Added:
- Track db_queries_executed and db_rows_returned per connection
- Calculate query rate (queries/minute) and row rate (rows/minute)
- Display stats in admin UI grouped by IP address and WebSocket
- Show: IP, Subscriptions, Queries, Rows, Query Rate, Duration

Implementation:
- Added tracking fields to per_session_data structure
- Increment counters in handle_req_message() and handle_count_message()
- Extract stats from pss in query_subscription_details()
- Updated admin UI to display IP address and query metrics

Use Case:
Admins can now identify abusive clients by monitoring:
- High query rates (>50 queries/min indicates polling abuse)
- High row counts (>10K rows/min indicates broad filter abuse)
- Query patterns (high queries + low rows = targeted, high both = crawler)

This enables informed decisions about which IPs to blacklist based on
actual resource consumption rather than just connection count.
2026-02-01 16:26:37 -04:00
Your Name 4cc2d2376e v1.1.6 - Optimize: Deduplicate kinds in subscription index to prevent redundant operations
The kind index was adding subscriptions multiple times when filters contained
duplicate kinds (e.g., 'kinds': [1, 1, 1] or multiple filters with same kind).
This caused:
- Redundant malloc/free operations during add/remove
- Multiple index entries for same subscription+kind pair
- Excessive TRACE logging (7+ removals for single subscription)
- Wasted CPU cycles on duplicate operations

Fix:
- Added bitmap-based deduplication in add_subscription_to_kind_index()
- Uses 8KB bitmap (65536 bits) to track which kinds already added
- Prevents adding same subscription to same kind index multiple times
- Reduces index operations by 3-10x for subscriptions with duplicate kinds

Performance Impact:
- Eliminates redundant malloc/free cycles
- Reduces lock contention on kind index operations
- Decreases log volume significantly
- Should reduce CPU usage by 20-40% under production load
2026-02-01 15:59:54 -04:00
Your Name 30dc4bf67d v1.1.5 - Fix CRITICAL segfault: Use wrapper nodes for no-kind-filter subscriptions
The kind index optimization in v1.1.4 introduced a critical bug that caused
segmentation faults in production. The bug was in add_subscription_to_kind_index()
which directly assigned sub->next for no-kind-filter subscriptions, corrupting
the main active_subscriptions linked list.

Root Cause:
- subscription_t has only ONE 'next' pointer used by active_subscriptions list
- Code tried to reuse 'next' for no_kind_filter_subs list
- This overwrote the active_subscriptions linkage, breaking list traversal
- Result: segfaults when iterating subscriptions

Fix:
- Added no_kind_filter_node_t wrapper structure (like kind_subscription_node_t)
- Changed no_kind_filter_subs from subscription_t* to no_kind_filter_node_t*
- Updated add/remove functions to use wrapper nodes
- Updated broadcast function to iterate through wrapper nodes

This follows the same pattern already used for kind_index entries and
prevents any corruption of the subscription structure's next pointer.
2026-02-01 12:37:07 -04:00
Your Name a1928cc5d7 v1.1.4 - Add kind-based index for 10x subscription matching performance improvement 2026-02-01 11:15:26 -04:00
Your Name 7bf0757b1f v1.1.3 - Rename VERSION to CRELAY_VERSION to avoid conflict with nostr_core_lib 2026-02-01 10:02:19 -04:00
Your Name 11b0a88cdd v1.1.2 - Fix hardcoded version string in print_version() - use VERSION define from main.h
The print_version() function was displaying a hardcoded 'v1.0.0' string instead
of using the VERSION define from main.h. This caused version mismatches where
the git tag and main.h showed v1.1.1 but the binary reported v1.0.0.

Now print_version() uses the VERSION macro, ensuring all version displays are
consistent and automatically updated when increment_and_push.sh updates main.h.
2026-01-31 16:48:11 -04:00
Your Name e8f8e3b0cf v1.1.1 - Fix NOTICE message silent failure - add pss parameter to send_notice_message()
Previously, send_notice_message() called queue_message() with NULL pss, causing
all NOTICE messages to fail silently. This affected filter validation errors
(e.g., invalid kinds > 65535 per NIP-01) where clients received no response.

Changes:
- Updated send_notice_message() signature to accept struct per_session_data* pss
- Updated 37 call sites across websockets.c (31) and nip042.c (6)
- Updated forward declarations in main.c, websockets.c, and nip042.c
- Added tests/invalid_kind_test.sh to verify NOTICE responses for invalid filters

Fixes issue where REQ with kinds:[99999] received no response instead of NOTICE.
2026-01-31 15:48:29 -04:00
Your Name 35b1461ff6 v1.1.0 - Documentation restructure: New README.md and comprehensive API.md 2026-01-23 06:58:14 -04:00
Your Name 87c6aa5e16 . 2025-12-21 09:30:48 -04:00
Your Name f0462929ea v1.0.9 - Fix active_subscriptions_log view to check ended_at column instead of looking for separate close events (schema v9) 2025-12-09 06:29:36 -04:00
Your Name 0ccf1959c2 v1.0.8 - Remove limit from subscription count 2025-12-07 07:40:17 -04:00
Your Name ef172c8356 v1.0.7 - Added a clean startup to erase subscriptions. 2025-12-06 18:26:22 -04:00
Your Name ae0370b47f v1.0.6 - Working on cleaning up subscriptions which were piling up. Set a startup cleanup, and a connection age limit. 2025-12-05 07:37:57 -04:00
Your Name 137df6253d v1.0.5 - Just catching up 2025-11-11 17:01:39 -04:00
Your Name 79f06bf1ed v1.0.4 - Fixed web socket limitation with the number of npubs in a subscription 2025-11-07 19:59:34 -05:00
laan tungir 9e5a42fd3c v1.0.3 - From remote 2025-11-07 14:07:46 -05:00
laan tungir 553d398ef5 removed some files 2025-11-03 07:33:12 -05:00
Your Name ac0bf17c4f v1.0.0 - First major release 2025-11-01 07:04:56 -04:00
Your Name 4efd7140d7 Reset version back to v0.8.6 2025-11-01 07:03:39 -04:00
Your Name 43b9027d4a v7.0.0 - Version 1.0.0 2025-11-01 06:56:02 -04:00
Your Name 9a0b2714f5 v7.0.0 - Version 1.0.0 2025-10-31 11:17:34 -04:00
Your Name 10fc80aa07 v6.0.0 - Test fixed output redirection 2025-10-31 11:17:19 -04:00
Your Name 9fa4e1dcf1 v6.0.0 - Version 1.0.0 2025-10-31 11:15:32 -04:00
Your Name 11c9f8c2a4 v5.0.0 - Test fixed release ID extraction 2025-10-31 11:15:16 -04:00
Your Name 17ddf3f2b8 v5.0.0 - Version 1.0.0 2025-10-31 11:13:41 -04:00
Your Name f70d5077e8 v4.0.0 - Debug upload with existing binary 2025-10-31 11:13:21 -04:00
Your Name 19cc1fa804 v4.0.0 - Debug upload issue 2025-10-31 11:12:58 -04:00
Your Name ed1de7d32d v4.0.0 - Version 1.0.0 2025-10-31 11:11:31 -04:00
Your Name 7b02c6a2cb v3.0.0 - Test release upload fix 2025-10-31 11:10:53 -04:00
Your Name dba229040d v3.0.0 - Version 1.0.0 2025-10-31 11:08:18 -04:00
Your Name d8b09e0dbb v2.0.0 - Version 1.0.0 2025-10-31 11:06:27 -04:00
Your Name 73fb681118 v1.0.0 - Version 1.0.0) 2025-10-31 10:39:06 -04:00
Your Name cf0f606dd9 v0.8.5 - cleanup 2025-10-30 07:06:48 -04:00
Your Name cb165ddab1 v0.8.4 - Updated increment_and_push.sh 2025-10-30 07:03:22 -04:00
Your Name 58eb19e4e7 v0.8.3 - --dry-run 2025-10-30 06:52:05 -04:00
Your Name aeaecddb5a v0.8.2 - markdown intro 2025-10-29 07:53:56 -04:00
Your Name 09dff8e91f v0.8.1 - added screenshots 2025-10-29 07:39:08 -04:00
Your Name f49aae8ab0 v0.7.44 - Release v0.8.0 with NIP-59 timestamp randomization and status command fixes 2025-10-27 13:21:47 -04:00
Your Name f6debcf799 v0.7.44 - Release v0.8.0 with NIP-59 timestamp randomization and status command fixes 2025-10-27 13:19:58 -04:00
Your Name edbc4f1359 v0.7.43 - Add plain text 'status' command handler for NIP-17 DMs 2025-10-27 13:19:10 -04:00
Your Name 5242f066e7 Update nostr_core_lib with timestamp randomization feature 2025-10-27 12:59:19 -04:00
Your Name af186800fa v0.7.42 - Fix ephemeral event storage and document monitoring system 2025-10-26 15:02:00 -04:00
Your Name 2bff4a5f44 v0.7.41 - Fix SQL query routing in admin API - add missing sql_query case to handle_kind_23456_unified 2025-10-26 13:34:16 -04:00
Your Name edb73d50cf v0.7.40 - Removed event_broadcasts table and related code to fix FOREIGN KEY constraint failures preventing event insertion 2025-10-25 15:26:31 -04:00
Your Name 3dc09d55fd v0.7.39 - Set dm's back 2 days to adjust for timestamp ramdomization of giftwraps. 2025-10-23 18:43:45 -03:00
Your Name 079fb1b0f5 v0.7.38 - Fixed error upon startup with existing db 2025-10-23 11:17:16 -04:00
Your Name 17b2aa8111 v0.7.37 - Enhanced admin interface with sliding sidebar navigation, moved dark mode and logout to sidebar footer, improved button styling consistency 2025-10-22 12:43:09 -04:00
Your Name 78d484cfe0 v0.7.36 - Implement sliding side navigation menu with page switching for admin sections 2025-10-22 11:01:30 -04:00
Your Name 182e12817d v0.7.35 - Implement event-driven monitoring system with dual triggers for events and subscriptions 2025-10-22 10:48:57 -04:00
Your Name 9179d57cc9 v0.7.34 - We seemed to maybe finally fixed the monitoring error? 2025-10-22 10:19:43 -04:00
Your Name 9cb9b746d8 v0.7.33 - Refactor monitoring system to use subscription-based activation with ephemeral events - fixes recursive crash bug 2025-10-19 10:26:09 -04:00
Your Name 57a0089664 v0.7.32 - Implement ephemeral event bypass (NIP-01) - events with kinds 20000-29999 are now broadcast to subscriptions but never stored in database, preventing recursive monitoring event loops 2025-10-19 09:38:02 -04:00
Your Name 53f7608872 v0.7.31 - Fixed production crash by replacing in-memory subscription iteration with database queries in monitoring system 2025-10-18 18:09:13 -04:00
Your Name 838ce5b45a v0.7.30 - Update increment and push script 2025-10-18 15:04:45 -04:00
Your Name e878b9557e v0.7.29 - Update increment and push script 2025-10-18 14:57:34 -04:00
Your Name 6638d37d6f v0.7.28 - Update increment and push script 2025-10-18 14:55:51 -04:00
Your Name 4c29e15329 v0.7.27 - Update increment and push script 2025-10-18 14:53:37 -04:00
Your Name 48890a2121 v0.7.26 - Tidy up api 2025-10-18 14:48:16 -04:00
Your Name e312d7e18c v0.7.25 - Implement SQL Query Admin API
- Move non-NIP-17 admin functions from dm_admin.c to api.c for better architecture
- Add NIP-44 encryption to send_admin_response() for secure admin responses
- Implement SQL query validation and execution with safety limits
- Add unified SQL query handler for admin API
- Fix buffer size for encrypted content to handle larger responses
- Update function declarations and includes across files
- Successfully test frontend query execution through web interface
2025-10-16 15:41:21 -04:00
Your Name 6c38aaebf3 v0.7.24 - Fix admin API subscription issues: NIP-17 historical events and relay pubkey timing 2025-10-16 06:27:01 -04:00
Your Name 18b0ac44bf v0.7.23 - Remove sticky positioning from main header to prevent floating behavior 2025-10-15 19:22:42 -04:00
Your Name b6749eff2f v0.7.22 - Fix compiler warnings in c_utils_lib version.c by adding proper includes for popen/pclose functions 2025-10-15 19:20:14 -04:00
Your Name c73a103280 v0.7.21 - Remove manual relay connection UI and implement automatic background connection with seamless data loading 2025-10-15 16:50:22 -04:00
Your Name a5d194f730 v0.7.20 - Fix automatic relay connection for restored authentication state 2025-10-15 15:47:02 -04:00
Your Name 6320436b88 v0.7.19 - Implement automatic relay connection after login with authentication error handling 2025-10-15 15:41:18 -04:00
264 changed files with 94517 additions and 5626 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ copy_executable_local.sh
nostr_login_lite/
style_guide/
nostr-tools
.test_keysadmin/cache/
+3 -3
View File
@@ -1,6 +1,6 @@
[submodule "nostr_core_lib"]
path = nostr_core_lib
url = https://git.laantungir.net/laantungir/nostr_core_lib.git
[submodule "c_utils_lib"]
path = c_utils_lib
url = ssh://git@git.laantungir.net:2222/laantungir/c_utils_lib.git
[submodule "text_graph"]
path = text_graph
url = ssh://git@git.laantungir.net:2222/laantungir/text_graph.git
+2
View File
@@ -0,0 +1,2 @@
ADMIN_PUBKEY='6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3'
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
+11 -11
View File
@@ -1,12 +1,12 @@
# AGENTS.md - AI Agent Integration Guide for Architect Mode
**Project-Specific Information for AI Agents Working with C-Relay in Architect Mode**
**Project-Specific Information for AI Agents Working with C-Relay-PG in Architect Mode**
## Critical Architecture Understanding
### System Architecture Overview
C-Relay implements a **unique event-based configuration architecture** that fundamentally differs from traditional Nostr relays:
C-Relay-PG implements a **unique event-based configuration architecture** that fundamentally differs from traditional Nostr relays:
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
@@ -51,9 +51,9 @@ C-Relay implements a **unique event-based configuration architecture** that fund
## Architectural Decision Analysis
### Configuration System Design
**Traditional Approach vs C-Relay:**
**Traditional Approach vs C-Relay-PG:**
```
Traditional: C-Relay:
Traditional: C-Relay-PG:
config.json → kind 33334 events
ENV variables → cryptographically signed tags
File watching → database polling/restart
@@ -119,18 +119,18 @@ File watching → database polling/restart
```
Developer Machine:
├── ./make_and_restart_relay.sh
├── build/c_relay_x86
├── build/c_relay_pg_x86
├── build/<relay_pubkey>.db
└── relay.log
```
### Production SystemD Deployment
```
/opt/c-relay/:
├── c_relay_x86
/opt/c-relay-pg/:
├── c_relay_pg_x86
├── <relay_pubkey>.db
├── systemd service (c-relay.service)
└── c-relay user isolation
├── systemd service (c-relay-pg.service)
└── c-relay-pg user isolation
```
### Container Deployment Architecture
@@ -144,7 +144,7 @@ Container:
### Reverse Proxy Architecture
```
Internet → Nginx/HAProxy → C-Relay
Internet → Nginx/HAProxy → C-Relay-PG
├── WebSocket upgrade handling
├── SSL termination
└── Rate limiting
@@ -292,7 +292,7 @@ Admin Signs Event → WebSocket Submit → Validate → Store → Restart Requir
**Decision**: Same port serves both protocols
**Consequences**: Simplified deployment, protocol detection overhead, libwebsockets dependency
These architectural decisions form the foundation of C-Relay's unique approach to Nostr relay implementation and should be carefully considered when planning extensions or modifications.
These architectural decisions form the foundation of C-Relay-PG's unique approach to Nostr relay implementation and should be carefully considered when planning extensions or modifications.
**
[Response interrupted by a tool use result. Only one tool may be used at a time and should be placed at the end of the message.]
+2 -2
View File
@@ -2,6 +2,6 @@
description: "Brief description of what this command does"
---
Run build_and_push.sh, and supply a good git commit message. For example:
Run increment_and_push.sh, and supply a good git commit message. For example:
./build_and_push.sh "Fixed the bug with nip05 implementation"
./increment_and_push.sh "Fixed the bug with nip05 implementation"
+1 -1
View File
@@ -1 +1 @@
src/embedded_web_content.c
src/embedded_web_content.c
+2
View File
@@ -0,0 +1,2 @@
ADMIN_PUBKEY='8ff74724ed641b3c28e5a86d7c5cbc49c37638ace8c6c38935860e7a5eedde0e'
SERVER_PRIVKEY='1111111111111111111111111111111111111111111111111111111111111111'
Executable
BIN
View File
Binary file not shown.
+10 -10
View File
@@ -1,6 +1,6 @@
# AGENTS.md - AI Agent Integration Guide
**Project-Specific Information for AI Agents Working with C-Relay**
**Project-Specific Information for AI Agents Working with C-Relay-PG**
## Critical Build Commands
@@ -15,9 +15,9 @@
- Starts relay in background with proper logging
### Architecture-Specific Binary Outputs
- **x86_64**: `./build/c_relay_x86`
- **ARM64**: `./build/c_relay_arm64`
- **Other**: `./build/c_relay_$(ARCH)`
- **x86_64**: `./build/c_relay_pg_x86`
- **ARM64**: `./build/c_relay_pg_arm64`
- **Other**: `./build/c_relay_pg_$(ARCH)`
### Database File Naming Convention
- **Format**: `<relay_pubkey>.db` (NOT `.nrdb` as shown in docs)
@@ -75,10 +75,10 @@
### Process Management
```bash
# Kill existing relay processes
pkill -f "c_relay_"
pkill -f "c_relay_pg_"
# Check running processes
ps aux | grep c_relay_
ps aux | grep c_relay_pg_
# Force kill port binding
fuser -k 8888/tcp
@@ -95,7 +95,7 @@ fuser -k 8888/tcp
- Event configuration tests: `tests/event_config_tests.sh`
### SystemD Integration Considerations
- Service runs as `c-relay` user in `/opt/c-relay`
- Service runs as `c-relay-pg` user in `/opt/c-relay-pg`
- Database files created in WorkingDirectory automatically
- No environment variables needed (event-based config)
- Resource limits: 65536 file descriptors, 4096 processes
@@ -121,8 +121,8 @@ fuser -k 8888/tcp
- Event filtering done at C level, not SQL level for NIP-40 expiration
### Configuration Override Behavior
- CLI port override only affects first-time startup
- After database creation, all config comes from events
- CLI port override applies during first-time startup and existing relay restarts
- After database creation, all config comes from events (but CLI overrides can still be applied)
- Database path cannot be changed after initialization
## Non-Obvious Pitfalls
@@ -137,7 +137,7 @@ fuser -k 8888/tcp
## Quick Debugging Commands
```bash
# Check relay status
ps aux | grep c_relay_ && netstat -tln | grep 8888
ps aux | grep c_relay_pg_ && netstat -tln | grep 8888
# View logs
tail -f relay.log
+995
View File
@@ -0,0 +1,995 @@
# C-Relay-PG API Documentation
Complete API reference for the C-Relay-PG event-based administration system and advanced features.
## Table of Contents
- [Overview](#overview)
- [Authentication](#authentication)
- [Admin API](#admin-api)
- [Event Structure](#event-structure)
- [Configuration Management](#configuration-management)
- [Auth Rules Management](#auth-rules-management)
- [System Commands](#system-commands)
- [Database Queries](#database-queries)
- [Configuration Reference](#configuration-reference)
- [Real-time Monitoring](#real-time-monitoring)
- [Direct Message Admin](#direct-message-admin)
- [Response Formats](#response-formats)
- [Error Handling](#error-handling)
- [Examples](#examples)
## Overview
C-Relay-PG uses an innovative **event-based administration system** where all configuration and management commands are sent as cryptographically signed Nostr events. This provides:
- **Cryptographic security**: All commands must be signed with the admin private key
- **Audit trail**: Complete history of all administrative actions
- **Real-time updates**: Configuration changes applied instantly
- **Standard protocol**: Uses Nostr events, no custom protocols
- **NIP-44 encryption**: All admin commands and responses are encrypted
### Key Concepts
1. **Admin Keypair**: Generated on first startup, used to sign all admin commands
2. **Relay Keypair**: The relay's identity on the Nostr network
3. **Admin Events**: Kind 23456 events with NIP-44 encrypted commands
4. **Response Events**: Kind 23457 events with NIP-44 encrypted responses
5. **Event-Based Config**: All settings stored as events in the database
## Authentication
### Admin Private Key
The admin private key is displayed **only once** during first startup:
```
========================================
IMPORTANT: SAVE THIS ADMIN PRIVATE KEY
========================================
Admin Private Key: nsec1abc123...
Admin Public Key: npub1def456...
========================================
```
**Critical**: Save this key immediately. It cannot be recovered and is required for all administrative operations.
### Secure Storage
Store the admin private key securely:
```bash
# Environment variable
export C_RELAY_ADMIN_KEY="nsec1abc123..."
# Secure file
echo "nsec1abc123..." > ~/.c-relay-pg-admin
chmod 600 ~/.c-relay-pg-admin
# Password manager (recommended)
# Store in 1Password, Bitwarden, etc.
```
### Key Loss Recovery
If you lose the admin private key:
1. Stop the relay
2. Delete the database file (`*.db`)
3. Restart the relay (generates new keys)
4. **Note**: This deletes all events and configuration
## Admin API
### Event Structure
All admin commands use the same event structure with NIP-44 encrypted content.
#### Admin Command Event (Kind 23456)
```json
{
"id": "computed_event_id",
"pubkey": "admin_public_key_hex",
"created_at": 1234567890,
"kind": 23456,
"content": "AqHBUgcM7dXFYLQuDVzGwMST1G8jtWYyVvYxXhVGEu4nAb4LVw...",
"tags": [
["p", "relay_public_key_hex"]
],
"sig": "event_signature"
}
```
**Fields**:
- `kind`: Must be `23456` for admin commands
- `pubkey`: Admin public key (hex format)
- `content`: NIP-44 encrypted JSON array containing the command
- `tags`: Must include `["p", "relay_pubkey"]` tag
- `sig`: Valid signature from admin private key
#### Encrypted Content Format
The `content` field contains a NIP-44 encrypted JSON array:
```json
["command_name", "param1", "param2", ...]
```
Examples:
```json
["config_query", "all"]
["config_update", [{"key": "relay_name", "value": "My Relay", ...}]]
["blacklist", "pubkey", "abc123..."]
["auth_query", "all"]
["sql_query", "SELECT * FROM events LIMIT 10"]
```
#### Admin Response Event (Kind 23457)
```json
{
"id": "response_event_id",
"pubkey": "relay_public_key_hex",
"created_at": 1234567890,
"kind": 23457,
"content": "BpKCVhfN8eYtRmPqSvWxZnMkL2gHjUiOp3rTyEwQaS5dFg...",
"tags": [
["p", "admin_public_key_hex"],
["e", "request_event_id"]
],
"sig": "response_signature"
}
```
**Fields**:
- `kind`: Always `23457` for admin responses
- `pubkey`: Relay public key (hex format)
- `content`: NIP-44 encrypted JSON response object
- `tags`: Includes `["p", "admin_pubkey"]` and optionally `["e", "request_id"]`
- `sig`: Valid signature from relay private key
### Configuration Management
#### Query All Configuration
**Command**:
```json
["config_query", "all"]
```
**Response** (decrypted):
```json
{
"query_type": "config_all",
"total_results": 27,
"timestamp": 1234567890,
"data": [
{
"key": "relay_name",
"value": "C-Relay-PG",
"data_type": "string",
"category": "relay",
"description": "Relay name displayed in NIP-11"
},
{
"key": "auth_enabled",
"value": "false",
"data_type": "boolean",
"category": "auth",
"description": "Enable whitelist/blacklist authentication"
}
]
}
```
#### Update Configuration
**Command**:
```json
["config_update", [
{
"key": "relay_name",
"value": "My Awesome Relay",
"data_type": "string",
"category": "relay"
},
{
"key": "max_subscriptions_per_client",
"value": "50",
"data_type": "integer",
"category": "limits"
}
]]
```
**Response** (decrypted):
```json
{
"query_type": "config_update",
"status": "success",
"total_results": 2,
"timestamp": 1234567890,
"data": [
{
"key": "relay_name",
"value": "My Awesome Relay",
"status": "updated"
},
{
"key": "max_subscriptions_per_client",
"value": "50",
"status": "updated"
}
]
}
```
### Auth Rules Management
#### Add Blacklist Rule
**Command**:
```json
["blacklist", "pubkey", "abc123def456..."]
```
**Response** (decrypted):
```json
{
"query_type": "auth_add",
"status": "success",
"message": "Blacklist rule added successfully",
"timestamp": 1234567890
}
```
#### Add Whitelist Rule
**Command**:
```json
["whitelist", "pubkey", "def456abc123..."]
```
#### Delete Auth Rule
**Command**:
```json
["delete_auth_rule", "blacklist", "pubkey", "abc123def456..."]
```
**Response** (decrypted):
```json
{
"query_type": "auth_delete",
"status": "success",
"message": "Auth rule deleted successfully",
"timestamp": 1234567890
}
```
#### Query All Auth Rules
**Command**:
```json
["auth_query", "all"]
```
**Response** (decrypted):
```json
{
"query_type": "auth_rules_all",
"total_results": 5,
"timestamp": 1234567890,
"data": [
{
"rule_type": "blacklist",
"pattern_type": "pubkey",
"pattern_value": "abc123...",
"action": "deny"
},
{
"rule_type": "whitelist",
"pattern_type": "pubkey",
"pattern_value": "def456...",
"action": "allow"
}
]
}
```
#### Query Specific Rule Type
**Command**:
```json
["auth_query", "whitelist"]
```
#### Query Specific Pattern
**Command**:
```json
["auth_query", "pattern", "abc123..."]
```
### System Commands
#### System Status
**Command**:
```json
["system_command", "system_status"]
```
**Response** (decrypted):
```json
{
"query_type": "system_status",
"timestamp": 1234567890,
"status": "running",
"uptime_seconds": 86400,
"version": "0.6.0",
"relay_pubkey": "relay_public_key_hex",
"database_size_bytes": 10485760,
"total_events": 15432,
"active_connections": 42,
"active_subscriptions": 156
}
```
#### Clear All Auth Rules
**Command**:
```json
["system_command", "clear_all_auth_rules"]
```
**Response** (decrypted):
```json
{
"query_type": "system_command",
"status": "success",
"message": "All auth rules cleared",
"timestamp": 1234567890
}
```
#### Database Statistics
**Command**:
```json
["stats_query"]
```
**Response** (decrypted):
```json
{
"query_type": "stats_query",
"timestamp": 1234567890,
"database_size_bytes": 10485760,
"total_events": 15432,
"database_created_at": 1234567800,
"latest_event_at": 1234567890,
"event_kinds": [
{
"kind": 1,
"count": 12000,
"percentage": 77.8
},
{
"kind": 0,
"count": 2500,
"percentage": 16.2
}
],
"time_stats": {
"total": 15432,
"last_24h": 234,
"last_7d": 1456,
"last_30d": 5432
},
"top_pubkeys": [
{
"pubkey": "abc123...",
"event_count": 1234,
"percentage": 8.0
}
]
}
```
### Database Queries
#### SQL Query Command
Execute read-only SQL queries against the relay database.
**Command**:
```json
["sql_query", "SELECT * FROM events ORDER BY created_at DESC LIMIT 10"]
```
**Response** (decrypted):
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "SELECT * FROM events ORDER BY created_at DESC LIMIT 10",
"execution_time_ms": 45,
"row_count": 10,
"columns": ["id", "pubkey", "created_at", "kind", "content", "tags", "sig"],
"rows": [
["abc123...", "def456...", 1234567890, 1, "Hello world", "[]", "sig123..."],
["ghi789...", "jkl012...", 1234567880, 0, "{\"name\":\"Alice\"}", "[]", "sig456..."]
]
}
```
#### Security Features
- **Read-only**: Only SELECT statements allowed
- **Query timeout**: 5 seconds maximum
- **Result limit**: 1000 rows maximum
- **Logging**: All queries logged with execution time
- **Validation**: SQL injection protection
#### Available Tables and Views
**Core Tables**:
- `events` - All Nostr events
- `config` - Configuration parameters
- `auth_rules` - Authentication rules
- `subscription_events` - Subscription lifecycle log
- `event_broadcasts` - Event broadcast log
**Views**:
- `recent_events` - Last 1000 events
- `event_stats` - Event statistics by type
- `subscription_analytics` - Subscription metrics
- `active_subscriptions_log` - Currently active subscriptions
- `event_kinds_view` - Event distribution by kind
- `top_pubkeys_view` - Top 10 pubkeys by event count
- `time_stats_view` - Time-based statistics
#### Example Queries
**Recent events**:
```sql
SELECT id, pubkey, created_at, kind
FROM events
ORDER BY created_at DESC
LIMIT 20
```
**Event distribution**:
```sql
SELECT * FROM event_kinds_view
ORDER BY count DESC
```
**Active subscriptions**:
```sql
SELECT * FROM active_subscriptions_log
ORDER BY created_at DESC
```
**Database statistics**:
```sql
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM subscription_events) as total_subscriptions,
(SELECT COUNT(DISTINCT pubkey) FROM events) as unique_pubkeys
```
**Events by specific pubkey**:
```sql
SELECT id, created_at, kind, content
FROM events
WHERE pubkey = 'abc123...'
ORDER BY created_at DESC
LIMIT 50
```
**Events in time range**:
```sql
SELECT COUNT(*) as count, kind
FROM events
WHERE created_at BETWEEN 1234567000 AND 1234567890
GROUP BY kind
ORDER BY count DESC
```
## Configuration Reference
### Basic Relay Settings
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `relay_name` | string | "C-Relay-PG" | Relay name (NIP-11) |
| `relay_description` | string | "C Nostr Relay" | Relay description |
| `relay_contact` | string | "" | Admin contact info |
| `relay_software` | string | "c-relay-pg" | Software identifier |
| `relay_version` | string | auto | Software version |
| `supported_nips` | string | "1,9,11,13,15,20,33,40,42,45,50,70" | Supported NIPs |
| `language_tags` | string | "*" | Supported languages |
| `relay_countries` | string | "*" | Supported countries |
| `posting_policy` | string | "" | Posting policy URL |
| `payments_url` | string | "" | Payment URL |
### Connection & Limits
| Key | Type | Default | Range | Restart Required |
|-----|------|---------|-------|------------------|
| `max_connections` | integer | 1000 | 1-10000 | Yes |
| `max_subscriptions_per_client` | integer | 25 | 1-100 | No |
| `max_total_subscriptions` | integer | 5000 | 100-50000 | No |
| `max_message_length` | integer | 65536 | 1024-1048576 | No |
| `max_event_tags` | integer | 2000 | 10-10000 | No |
| `max_content_length` | integer | 65536 | 1-1048576 | No |
### Authentication & Access Control
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `auth_enabled` | boolean | false | Enable whitelist/blacklist |
| `nip42_auth_required` | boolean | false | Require NIP-42 auth |
| `nip42_auth_required_kinds` | string | "" | Kinds requiring NIP-42 |
| `nip42_challenge_timeout` | integer | 300 | Challenge timeout (seconds) |
### Proof of Work (NIP-13)
| Key | Type | Default | Values | Description |
|-----|------|---------|--------|-------------|
| `pow_min_difficulty` | integer | 0 | 0-40 | Minimum PoW difficulty |
| `pow_mode` | string | "optional" | disabled/optional/required | PoW enforcement mode |
### Event Expiration (NIP-40)
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `nip40_expiration_enabled` | boolean | true | Enable expiration support |
| `nip40_expiration_strict` | boolean | false | Reject expired events |
| `nip40_expiration_filter` | boolean | true | Filter expired from results |
| `nip40_expiration_grace_period` | integer | 300 | Grace period (seconds) |
### Monitoring
| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `kind_24567_reporting_throttle_sec` | integer | 5 | Monitoring event throttle |
### Dynamic vs Restart-Required
**Dynamic (No Restart)**:
- All NIP-11 relay information
- Authentication settings
- Subscription limits
- Event validation limits
- Proof of Work settings
- Expiration settings
**Restart Required**:
- `max_connections`
- `relay_port`
- Database settings
## Real-time Monitoring
C-Relay-PG provides subscription-based real-time monitoring using ephemeral events (kind 24567).
### Activation
Subscribe to kind 24567 events to activate monitoring:
```json
["REQ", "monitoring-sub", {"kinds": [24567]}]
```
### Monitoring Event Types
Subscribe to specific monitoring types using d-tag filters:
```json
["REQ", "event-kinds", {"kinds": [24567], "#d": ["event_kinds"]}]
["REQ", "time-stats", {"kinds": [24567], "#d": ["time_stats"]}]
["REQ", "top-pubkeys", {"kinds": [24567], "#d": ["top_pubkeys"]}]
["REQ", "cpu-metrics", {"kinds": [24567], "#d": ["cpu_metrics"]}]
```
### Event Structure
```json
{
"kind": 24567,
"pubkey": "relay_pubkey",
"created_at": 1234567890,
"content": "{\"data_type\":\"event_kinds\",\"timestamp\":1234567890,...}",
"tags": [
["d", "event_kinds"]
]
}
```
### Monitoring Types
#### Event Distribution (`event_kinds`)
```json
{
"data_type": "event_kinds",
"timestamp": 1234567890,
"total_events": 15432,
"kinds": [
{"kind": 1, "count": 12000, "percentage": 77.8},
{"kind": 0, "count": 2500, "percentage": 16.2}
]
}
```
#### Time Statistics (`time_stats`)
```json
{
"data_type": "time_stats",
"timestamp": 1234567890,
"total_events": 15432,
"last_24h": 234,
"last_7d": 1456,
"last_30d": 5432
}
```
#### Top Publishers (`top_pubkeys`)
```json
{
"data_type": "top_pubkeys",
"timestamp": 1234567890,
"top_pubkeys": [
{"pubkey": "abc123...", "count": 1234, "percentage": 8.0},
{"pubkey": "def456...", "count": 987, "percentage": 6.4}
]
}
```
#### CPU Metrics (`cpu_metrics`)
```json
{
"data_type": "cpu_metrics",
"timestamp": 1234567890,
"cpu_percent": 12.5,
"memory_mb": 45.2,
"uptime_seconds": 86400
}
```
#### Active Subscriptions (`active_subscriptions`) - Admin Only
```json
{
"data_type": "active_subscriptions",
"timestamp": 1234567890,
"total_subscriptions": 156,
"subscriptions_by_client": [
{"client_id": "client1", "count": 12},
{"client_id": "client2", "count": 8}
]
}
```
### Configuration
Control monitoring frequency:
```json
["config_update", [{
"key": "kind_24567_reporting_throttle_sec",
"value": "10",
"data_type": "integer",
"category": "monitoring"
}]]
```
### Performance
- Events are ephemeral (not stored)
- Automatic activation/deactivation based on subscriptions
- Throttling prevents excessive event generation
- Minimal overhead when no clients monitoring
## Direct Message Admin
Control your relay by sending direct messages from any Nostr client.
### Setup
1. The relay has its own keypair (shown on startup)
2. The relay knows the admin public key
3. Send NIP-17 direct messages to the relay
### Available Commands
Send a DM containing any of these keywords:
| Command | Aliases | Response |
|---------|---------|----------|
| Statistics | stats, statistics | Database statistics |
| Configuration | config, configuration | Current configuration |
### Example
Using any Nostr client that supports NIP-17:
1. Find the relay's public key (shown on startup)
2. Send a DM: "stats"
3. Receive a DM with current relay statistics
### Response Format
The relay responds with a NIP-17 DM containing:
**Stats Response**:
```
Relay Statistics
================
Total Events: 15,432
Database Size: 10.5 MB
Active Connections: 42
Active Subscriptions: 156
Uptime: 1 day, 2 hours
```
**Config Response**:
```
Relay Configuration
===================
Name: My Awesome Relay
Description: Community relay
Max Subscriptions: 25
Auth Enabled: false
PoW Difficulty: 0
```
## Response Formats
### Success Response
```json
{
"query_type": "command_name",
"status": "success",
"message": "Operation completed successfully",
"timestamp": 1234567890,
"data": [...]
}
```
### Error Response
```json
{
"query_type": "command_name",
"status": "error",
"error": "Error description",
"timestamp": 1234567890
}
```
### Query Response
```json
{
"query_type": "query_name",
"total_results": 10,
"timestamp": 1234567890,
"data": [...]
}
```
## Error Handling
### Common Errors
| Error | Cause | Solution |
|-------|-------|----------|
| `invalid_signature` | Event signature invalid | Check admin private key |
| `unauthorized` | Wrong admin pubkey | Use correct admin key |
| `invalid_command` | Unknown command | Check command format |
| `validation_failed` | Invalid parameter value | Check parameter ranges |
| `database_error` | Database operation failed | Check database integrity |
| `timeout` | Query took too long | Simplify query or increase timeout |
### Error Response Example
```json
{
"query_type": "config_update",
"status": "error",
"error": "field validation failed: invalid port number '99999' (must be 1-65535)",
"timestamp": 1234567890
}
```
## Examples
### JavaScript/TypeScript Example
```javascript
import { SimplePool, nip44, getPublicKey, finalizeEvent } from 'nostr-tools';
const adminPrivkey = 'your_admin_privkey_hex';
const adminPubkey = getPublicKey(adminPrivkey);
const relayPubkey = 'relay_pubkey_hex';
const relayUrl = 'ws://localhost:8888';
// Create admin command
async function sendAdminCommand(command) {
const pool = new SimplePool();
// Encrypt command with NIP-44
const encryptedContent = await nip44.encrypt(
adminPrivkey,
relayPubkey,
JSON.stringify(command)
);
// Create event
const event = finalizeEvent({
kind: 23456,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', relayPubkey]],
content: encryptedContent
}, adminPrivkey);
// Publish event
await pool.publish([relayUrl], event);
// Subscribe to response
const sub = pool.sub([relayUrl], [{
kinds: [23457],
'#p': [adminPubkey],
since: Math.floor(Date.now() / 1000)
}]);
return new Promise((resolve) => {
sub.on('event', async (event) => {
// Decrypt response
const decrypted = await nip44.decrypt(
adminPrivkey,
relayPubkey,
event.content
);
resolve(JSON.parse(decrypted));
sub.unsub();
});
});
}
// Query configuration
const config = await sendAdminCommand(['config_query', 'all']);
console.log(config);
// Update configuration
const result = await sendAdminCommand(['config_update', [
{
key: 'relay_name',
value: 'My Relay',
data_type: 'string',
category: 'relay'
}
]]);
console.log(result);
// Add blacklist rule
const blacklist = await sendAdminCommand([
'blacklist',
'pubkey',
'abc123...'
]);
console.log(blacklist);
// Execute SQL query
const query = await sendAdminCommand([
'sql_query',
'SELECT * FROM events ORDER BY created_at DESC LIMIT 10'
]);
console.log(query);
```
### Python Example
```python
from nostr_sdk import Keys, Client, EventBuilder, Filter, nip44
admin_privkey = "your_admin_privkey_hex"
relay_pubkey = "relay_pubkey_hex"
relay_url = "ws://localhost:8888"
# Initialize
keys = Keys.parse(admin_privkey)
client = Client(keys)
client.add_relay(relay_url)
client.connect()
# Send admin command
async def send_admin_command(command):
# Encrypt command
encrypted = nip44.encrypt(
keys.secret_key(),
relay_pubkey,
json.dumps(command)
)
# Create event
event = EventBuilder.new(
kind=23456,
content=encrypted,
tags=[["p", relay_pubkey]]
).to_event(keys)
# Publish
await client.send_event(event)
# Wait for response
filter = Filter().kind(23457).pubkey(relay_pubkey).since(int(time.time()))
events = await client.get_events_of([filter], timeout=5)
if events:
# Decrypt response
decrypted = nip44.decrypt(
keys.secret_key(),
relay_pubkey,
events[0].content()
)
return json.loads(decrypted)
# Query configuration
config = await send_admin_command(["config_query", "all"])
print(config)
```
### Bash/curl Example
```bash
#!/bin/bash
# Note: This is a simplified example. Real implementation requires:
# - NIP-44 encryption
# - Event signing
# - WebSocket connection
RELAY_URL="ws://localhost:8888"
ADMIN_PRIVKEY="your_admin_privkey"
RELAY_PUBKEY="relay_pubkey"
# Use nostrtool or similar for proper event creation
nostrtool event \
--kind 23456 \
--content "$(echo '["config_query","all"]' | nip44-encrypt)" \
--tag p "$RELAY_PUBKEY" \
--private-key "$ADMIN_PRIVKEY" \
| nostrtool send "$RELAY_URL"
```
---
## Additional Resources
- **[Configuration Guide](docs/configuration_guide.md)** - Detailed configuration options
- **[Deployment Guide](docs/deployment_guide.md)** - Production deployment
- **[NIP-42 Authentication](docs/NIP-42_Authentication.md)** - Authentication setup
- **[User Guide](docs/user_guide.md)** - End-user documentation
## Support
For API questions or issues:
- Open an issue on GitHub
- Check existing documentation
- Join the Nostr community
---
**API Version**: 0.6.0
**Last Updated**: 2026-01-23
+55 -31
View File
@@ -1,8 +1,15 @@
# Alpine-based MUSL static binary builder for C-Relay
# Alpine-based MUSL static binary builder for C-Relay-PG
# Produces truly portable binaries with zero runtime dependencies
ARG DEBUG_BUILD=false
ARG DB_BACKEND=sqlite
FROM alpine:3.19 AS builder
# Re-declare build arguments in this stage
ARG DEBUG_BUILD=false
ARG DB_BACKEND=sqlite
# Install build dependencies
RUN apk add --no-cache \
build-base \
@@ -21,9 +28,11 @@ RUN apk add --no-cache \
curl-static \
sqlite-dev \
sqlite-static \
postgresql-dev \
linux-headers \
wget \
bash
bash \
openssh-client
# Set working directory
WORKDIR /build
@@ -63,15 +72,8 @@ RUN cd /tmp && \
make install && \
rm -rf /tmp/libwebsockets
# Copy only submodule configuration and git directory
COPY .gitmodules /build/.gitmodules
COPY .git /build/.git
# Clean up any stale submodule references (nips directory is not a submodule)
RUN git rm --cached nips 2>/dev/null || true
# Initialize submodules (cached unless .gitmodules changes)
RUN git submodule update --init --recursive
# Submodules are provided in the local build context and copied explicitly below.
# Avoid network/SSH dependency inside Docker image build.
# Copy nostr_core_lib source files (cached unless nostr_core_lib changes)
COPY nostr_core_lib /build/nostr_core_lib/
@@ -79,51 +81,73 @@ COPY nostr_core_lib /build/nostr_core_lib/
# Copy c_utils_lib source files (cached unless c_utils_lib changes)
COPY c_utils_lib /build/c_utils_lib/
# Build c_utils_lib with MUSL-compatible flags (cached unless c_utils_lib changes)
# Build c_utils_lib static library for relay logging utilities
# (build only debug.c to avoid broken/missing version header surface in submodule revision)
RUN cd c_utils_lib && \
sed -i 's/CFLAGS = -Wall -Wextra -std=c99 -O2 -g/CFLAGS = -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -O2 -g/' Makefile && \
make clean && \
make
mkdir -p build && \
gcc -U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -O2 -g \
-Isrc -Iinclude -c src/debug.c -o build/debug.o && \
ar rcs libc_utils.a build/debug.o
# Build nostr_core_lib with required NIPs (cached unless nostr_core_lib changes)
# Disable fortification in build.sh to prevent __*_chk symbol issues
# NIPs: 001(Basic), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
# NIPs: 001(Basic), 004(Encryption), 006(Keys), 013(PoW), 017(DMs), 019(Bech32), 044(Encryption), 059(Gift Wrap - required by NIP-17)
# NIP-04 is required by nostr_signer.c (NIP-46 bunker signer uses NIP-04 encryption)
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,6,13,17,19,44,59
./build.sh --nips=1,4,6,13,17,19,44,59
# Copy c-relay source files LAST (only this layer rebuilds on source changes)
# Copy c-relay-pg source files LAST (only this layer rebuilds on source changes)
COPY src/ /build/src/
COPY Makefile /build/Makefile
# Build c-relay with full static linking and debug symbols (only rebuilds when src/ changes)
# Build c-relay-pg with full static linking (only rebuilds when src/ changes)
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
# Use conditional compilation flags based on DEBUG_BUILD and DB_BACKEND build args
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
echo "Building with DEBUG symbols enabled (optimized with -O2)"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/c_relay_pg_static"; \
echo "Building optimized production binary (symbols stripped)"; \
fi && \
if [ "$DB_BACKEND" = "postgres" ]; then \
DB_FLAGS="-DDB_BACKEND_POSTGRES -DHAVE_LIBPQ"; \
DB_LIBS="-lpq -lpgcommon -lpgport"; \
echo "Compiling with PostgreSQL backend"; \
else \
DB_FLAGS=""; \
DB_LIBS=""; \
echo "Compiling with SQLite backend"; \
fi && \
gcc -static $CFLAGS $DB_FLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql \
src/main.c src/config.c src/dm_admin.c src/request_validator.c \
src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c \
-o /build/c_relay_static \
src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c \
src/caching_inbox_poller.c src/caching_service_launcher.c \
src/db_ops.c src/db_ops_sqlite.c src/db_ops_postgres.c src/thread_pool.c \
-o /build/c_relay_pg_static \
c_utils_lib/libc_utils.a \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl
# DO NOT strip - we need debug symbols for debugging
# RUN strip /build/c_relay_static
-lcurl -lz -lpthread -lm -ldl $DB_LIBS && \
eval "$STRIP_CMD"
# Verify it's truly static
RUN echo "=== Binary Information ===" && \
file /build/c_relay_static && \
ls -lh /build/c_relay_static && \
file /build/c_relay_pg_static && \
ls -lh /build/c_relay_pg_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/c_relay_static 2>&1 || echo "Binary is static") && \
(ldd /build/c_relay_pg_static 2>&1 || echo "Binary is static") && \
echo "=== Build complete ==="
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/c_relay_static /c_relay_static
COPY --from=builder /build/c_relay_pg_static /c_relay_pg_static
+38 -26
View File
@@ -1,28 +1,40 @@
# C-Relay Makefile
# C-Relay-PG Makefile
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -g -O2
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket
INCLUDES = -I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket -I/usr/include/postgresql
LIBS = -lsqlite3 -lwebsockets -lz -ldl -lpthread -lm -L/usr/local/lib -lsecp256k1 -lssl -lcrypto -L/usr/local/lib -lcurl -Lc_utils_lib -lc_utils
DB_BACKEND ?= sqlite
# Build directory
BUILD_DIR = build
# Source files
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c
MAIN_SRC = src/main.c src/config.c src/dm_admin.c src/request_validator.c src/nip009.c src/nip011.c src/nip013.c src/nip040.c src/nip042.c src/websockets.c src/subscriptions.c src/api.c src/embedded_web_content.c src/ip_ban.c src/thread_pool.c src/caching_inbox_poller.c src/caching_service_launcher.c
DB_OPS_SRC = src/db_ops.c
ifeq ($(DB_BACKEND),postgres)
CFLAGS += -DDB_BACKEND_POSTGRES -DHAVE_LIBPQ
DB_OPS_SRC += src/db_ops_postgres.c
LIBS += -lpq
else
DB_OPS_SRC += src/db_ops_sqlite.c
endif
NOSTR_CORE_LIB = nostr_core_lib/libnostr_core_x64.a
C_UTILS_LIB = c_utils_lib/libc_utils.a
# Architecture detection
ARCH = $(shell uname -m)
ifeq ($(ARCH),x86_64)
TARGET = $(BUILD_DIR)/c_relay_x86
TARGET = $(BUILD_DIR)/c_relay_pg_x86
else ifeq ($(ARCH),aarch64)
TARGET = $(BUILD_DIR)/c_relay_arm64
TARGET = $(BUILD_DIR)/c_relay_pg_arm64
else ifeq ($(ARCH),arm64)
TARGET = $(BUILD_DIR)/c_relay_arm64
TARGET = $(BUILD_DIR)/c_relay_pg_arm64
else
TARGET = $(BUILD_DIR)/c_relay_$(ARCH)
TARGET = $(BUILD_DIR)/c_relay_pg_$(ARCH)
endif
# Default target
@@ -81,19 +93,19 @@ force-version:
@$(MAKE) src/main.h
# Build the relay
$(TARGET): $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Compiling C-Relay for architecture: $(ARCH)"
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
$(TARGET): $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Compiling C-Relay-PG for architecture: $(ARCH) (backend: $(DB_BACKEND))"
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(TARGET) $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
@echo "Build complete: $(TARGET)"
# Build for specific architectures
x86: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Building C-Relay for x86_64..."
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_x86 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_x86"
x86: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Building C-Relay-PG for x86_64 (backend: $(DB_BACKEND))..."
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_x86 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_x86"
arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Cross-compiling C-Relay for ARM64..."
arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(DB_OPS_SRC) $(NOSTR_CORE_LIB) $(C_UTILS_LIB)
@echo "Cross-compiling C-Relay-PG for ARM64 (backend: $(DB_BACKEND))..."
@if ! command -v aarch64-linux-gnu-gcc >/dev/null 2>&1; then \
echo "ERROR: ARM64 cross-compiler not found."; \
echo "Install with: make install-cross-tools"; \
@@ -116,9 +128,9 @@ arm64: $(BUILD_DIR) src/main.h src/sql_schema.h $(MAIN_SRC) $(NOSTR_CORE_LIB) $(
fi
@echo "Using aarch64-linux-gnu-gcc with ARM64 libraries..."
PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) -o $(BUILD_DIR)/c_relay_arm64 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
aarch64-linux-gnu-gcc $(CFLAGS) $(INCLUDES) $(MAIN_SRC) $(DB_OPS_SRC) -o $(BUILD_DIR)/c_relay_pg_arm64 $(NOSTR_CORE_LIB) $(C_UTILS_LIB) \
-L/usr/lib/aarch64-linux-gnu $(LIBS)
@echo "Build complete: $(BUILD_DIR)/c_relay_arm64"
@echo "Build complete: $(BUILD_DIR)/c_relay_pg_arm64"
# Install ARM64 cross-compilation dependencies
install-arm64-deps:
@@ -160,7 +172,7 @@ test: $(TARGET)
init-db:
@echo "Database initialization is now handled automatically when the server starts."
@echo "The schema is embedded in the binary - no external files needed."
@echo "To manually recreate database: rm -f db/c_nostr_relay.db && ./build/c_relay_x86"
@echo "To manually recreate database: rm -f db/c_nostr_relay.db && ./build/c_relay_pg_x86"
# Clean build artifacts
clean:
@@ -180,7 +192,7 @@ install-deps:
# Help
help:
@echo "C-Relay Build System"
@echo "C-Relay-PG Build System"
@echo ""
@echo "Targets:"
@echo " all Build the relay for current architecture (default)"
@@ -209,15 +221,15 @@ help:
# Build fully static MUSL binaries using Docker
static-musl-x86_64:
@echo "Building fully static MUSL binary for x86_64..."
docker buildx build --platform linux/amd64 -f examples/deployment/static-builder.Dockerfile -t c-relay-static-builder-x86_64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-static-builder-x86_64 sh -c "cp /c_relay_static_musl_x86_64 /output/"
@echo "Static binary created: build/c_relay_static_musl_x86_64"
docker buildx build --platform linux/amd64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-x86_64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-x86_64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/"
@echo "Static binary created: build/c_relay_pg_static_musl_x86_64"
static-musl-arm64:
@echo "Building fully static MUSL binary for ARM64..."
docker buildx build --platform linux/arm64 -f examples/deployment/static-builder.Dockerfile -t c-relay-static-builder-arm64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-static-builder-arm64 sh -c "cp /c_relay_static_musl_x86_64 /output/c_relay_static_musl_arm64"
@echo "Static binary created: build/c_relay_static_musl_arm64"
docker buildx build --platform linux/arm64 -f examples/deployment/static-builder.Dockerfile -t c-relay-pg-static-builder-arm64 --load .
docker run --rm -v $(PWD)/build:/output c-relay-pg-static-builder-arm64 sh -c "cp /c_relay_pg_static_musl_x86_64 /output/c_relay_pg_static_musl_arm64"
@echo "Static binary created: build/c_relay_pg_static_musl_arm64"
static-musl: static-musl-x86_64 static-musl-arm64
@echo "Built static MUSL binaries for both architectures"
+65
View File
@@ -0,0 +1,65 @@
# Relay
I am releasing the code for the nostr relay that I wrote use myself. The code is free for anyone to use in any way that they wish.
Some of the features of this relay are conventional, and some are unconventional.
## The conventional
This relay is written in C99 with a sqlite database.
It implements the following NIPs.
- [x] NIP-01: Basic protocol flow implementation
- [x] NIP-09: Event deletion
- [x] NIP-11: Relay information document
- [x] NIP-13: Proof of Work
- [x] NIP-15: End of Stored Events Notice
- [x] NIP-20: Command Results
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [x] NIP-42: Authentication of clients to relays
- [x] NIP-45: Counting results
- [x] NIP-50: Keywords filter
- [x] NIP-70: Protected Events
## The unconventional
### The binaries are fully self contained.
It should just run in linux without having to worry about what you have on your system. I want to download and run. No docker. No dependency hell.
I'm not bothering with other operating systems.
### The relay is a full nostr citizen with it's own public and private keys.
For example, you can see my relay's profile (wss://relay.laantungir.net) running here:
[Primal link](https://primal.net/p/nprofile1qqswn2jsmm8lq8evas0v9vhqkdpn9nuujt90mtz60nqgsxndy66es4qjjnhr7)
What this means in practice is that when you start the relay, it generates keys for itself, and for it's administrator (You can specify these if you wish)
Now the program and the administrator can have verifed communication between the two, simply by using nostr events. For example, the administrator can send DMs to the relay, asking it's status, and changing it's configuration through any client that can handle nip17 DMs. The relay can also send notifications to the administrator about it's current status, or it can publish it's status on a regular schedule directly to NOSTR as kind-1 notes.
Also included is a more standard administrative web front end. This front end communicates to the relay using an extensive api, which again is nostr events signed by the administrator. You can completely control the relay through signed nostr events.
## Screenshots
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/main.png)
Main page with real time updates.
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/config.png)
Set your configuration preferences.
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/subscriptions.png)
View current subscriptions
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/white-blacklists.png)
Add npubs to white or black lists.
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/sqlQuery.png)
Run sql queries on the database.
![](https://git.laantungir.net/laantungir/c-relay-pg/raw/branch/master/screenshots/main-light.png)
Light mode.
+215 -277
View File
@@ -1,347 +1,285 @@
# C-Nostr Relay
# C-Relay-PG: High-Performance Nostr Relay
A high-performance Nostr relay implemented in C with SQLite backend, featuring nostr event-based management.
A blazingly fast, production-ready Nostr relay implemented in C with an innovative event-based configuration system. Built for performance, security, and ease of deployment.
## Supported NIPs
## 🚀 Why C-Relay-PG?
<!--
NOTE FOR ASSISTANTS: When updating the NIPs checklist below, ONLY change [ ] to [x] to mark as complete.
Do NOT modify the formatting, add emojis, or change the text. Keep the simple format consistent.
-->
### Event-Based Configuration
Unlike traditional relays that require config files, C-Relay-PG uses **cryptographically signed Nostr events** for all configuration. This means:
- **Zero config files** - Everything stored in the database
- **Real-time updates** - Changes applied instantly without restart
- **Cryptographic security** - All changes must be signed by admin
- **Complete audit trail** - Every configuration change is timestamped and signed
- **Version control built-in** - Configuration history is part of the event stream
- [x] NIP-01: Basic protocol flow implementation
- [x] NIP-09: Event deletion
- [x] NIP-11: Relay information document
- [x] NIP-13: Proof of Work
- [x] NIP-15: End of Stored Events Notice
- [x] NIP-20: Command Results
- [x] NIP-33: Parameterized Replaceable Events
- [x] NIP-40: Expiration Timestamp
- [x] NIP-42: Authentication of clients to relays
- [x] NIP-45: Counting results
- [x] NIP-50: Keywords filter
- [x] NIP-70: Protected Events
### Built-in Web Admin Interface
Access a full-featured web dashboard at `http://localhost:8888/api/` with:
- Real-time configuration management
- Database statistics and analytics
- Auth rules management (whitelist/blacklist)
- NIP-42 authentication for secure access
- No external dependencies - all files embedded in the binary
## Quick Start
### Direct Message Admin System
Control your relay by sending direct messages from any Nostr client:
- Send "stats" to get relay statistics
- Send "config" to view current configuration
- Full Nostr citizen with its own keypair
- Works with any NIP-17 compatible client
Get your C-Relay up and running in minutes with a static binary (no dependencies required):
### Performance & Efficiency
- **Written in C** - Maximum performance and minimal resource usage
- **SQLite backend** - Reliable, fast, and self-contained
- **Static binary available** - Single file deployment with zero dependencies
- **Efficient memory management** - Optimized for long-running operation
- **WebSocket native** - Built on libwebsockets for optimal protocol support
### 1. Download Static Binary
## 📋 Supported NIPs
Download the latest static release from the [releases page](https://git.laantungir.net/laantungir/c-relay/releases):
C-Relay-PG implements a comprehensive set of Nostr Improvement Proposals:
-**NIP-01**: Basic protocol flow implementation
-**NIP-09**: Event deletion
-**NIP-11**: Relay information document
-**NIP-13**: Proof of Work
-**NIP-15**: End of Stored Events Notice
-**NIP-20**: Command Results
-**NIP-33**: Parameterized Replaceable Events
-**NIP-40**: Expiration Timestamp
-**NIP-42**: Authentication of clients to relays
-**NIP-45**: Counting results
-**NIP-50**: Keywords filter
-**NIP-70**: Protected Events
## 🎯 Key Features
### Security
- **NIP-42 Authentication** - Cryptographic client authentication
- **Proof of Work** - Configurable PoW requirements (NIP-13)
- **Protected Events** - Support for encrypted/protected content (NIP-70)
- **Whitelist/Blacklist** - Flexible access control by pubkey
- **Admin key security** - Private key shown only once, never stored
### Flexibility
- **Dynamic configuration** - Most settings update without restart
- **Subscription management** - Configurable limits per client and globally
- **Event expiration** - Automatic cleanup of expired events (NIP-40)
- **Parameterized events** - Full support for replaceable events (NIP-33)
- **Keyword search** - Built-in full-text search (NIP-50)
### Monitoring
- **Real-time statistics** - Live event distribution and metrics
- **Subscription-based monitoring** - Ephemeral events (kind 24567) for dashboards
- **SQL query API** - Direct database queries for advanced analytics
- **Resource tracking** - CPU, memory, and database size monitoring
- **Event broadcast logging** - Complete audit trail of all operations
### Developer-Friendly
- **Comprehensive test suite** - Automated tests for all NIPs
- **Clear documentation** - Detailed guides for deployment and configuration
- **SystemD integration** - Production-ready service files included
- **Docker support** - Container deployment with Alpine Linux
- **Cross-platform** - Builds on Linux, macOS, and Windows (WSL)
## 🚀 Quick Start
### Option 1: Static Binary (Recommended)
Download and run - no dependencies required:
```bash
# Static binary - works on all Linux distributions (no dependencies)
wget https://git.laantungir.net/laantungir/c-relay/releases/download/v0.6.0/c-relay-v0.6.0-linux-x86_64-static
chmod +x c-relay-v0.6.0-linux-x86_64-static
mv c-relay-v0.6.0-linux-x86_64-static c-relay
# Download the latest static release
wget https://git.laantungir.net/laantungir/c-relay-pg/releases/download/v0.6.0/c-relay-pg-v0.6.0-linux-x86_64-static
chmod +x c-relay-pg-v0.6.0-linux-x86_64-static
mv c-relay-pg-v0.6.0-linux-x86_64-static c-relay-pg
# Run the relay
./c-relay-pg
```
### 2. Start the Relay
**Important**: On first startup, save the **Admin Private Key** displayed in the console. You'll need it for all administrative operations.
Simply run the binary - no configuration files needed:
### Option 2: Build from Source
```bash
./c-relay
# Install dependencies (Ubuntu/Debian)
sudo apt install -y build-essential git sqlite3 libsqlite3-dev \
libwebsockets-dev libssl-dev libsecp256k1-dev libcurl4-openssl-dev zlib1g-dev
# Clone and build
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
git submodule update --init --recursive
./make_and_restart_relay.sh
```
On first startup, you'll see:
- **Admin Private Key**: Save this securely! You'll need it for administration
- **Relay Public Key**: Your relay's identity on the Nostr network
- **Port Information**: Default is 8888, or the next available port
The relay will start on port 8888 (or the next available port).
### 3. Access the Web Interface
## 🌐 Access the Web Interface
Once running, open your browser to:
Open your browser and navigate to:
```
http://localhost:8888/api/
```
The web interface provides:
- Real-time configuration management
- Database statistics dashboard
- Auth rules management
- Secure admin authentication with your Nostr identity
- Configuration management with live updates
- Database statistics and event distribution charts
- Auth rules management (whitelist/blacklist)
- SQL query interface for advanced analytics
- Real-time monitoring dashboard
### 4. Test Your Relay
## 📦 Installation Options
### Production Deployment (SystemD)
Test basic connectivity:
```bash
# Test WebSocket connection
curl -H "Accept: application/nostr+json" http://localhost:8888
# Clone repository
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
git submodule update --init --recursive
# Test with a Nostr client
# Add ws://localhost:8888 to your client's relay list
# Build
make clean && make
# Install as system service
sudo systemd/install-service.sh
# Start and enable
sudo systemctl start c-relay-pg
sudo systemctl enable c-relay-pg
# Capture admin keys from logs
sudo journalctl -u c-relay-pg | grep "Admin Private Key"
```
### 5. Configure Your Relay (Optional)
### Docker Deployment
Use the web interface or send admin commands to customize:
- Relay name and description
- Authentication rules (whitelist/blacklist)
- Connection limits
- Proof-of-work requirements
```bash
# Build Docker image
docker build -f Dockerfile.alpine-musl -t c-relay-pg .
**That's it!** Your relay is now running with zero configuration required. The event-based configuration system means you can adjust all settings through the web interface or admin API without editing config files.
## Web Admin Interface
C-Relay includes a **built-in web-based administration interface** accessible at `http://localhost:8888/api/`. The interface provides:
- **Real-time Configuration Management**: View and edit all relay settings through a web UI
- **Database Statistics Dashboard**: Monitor event counts, storage usage, and performance metrics
- **Auth Rules Management**: Configure whitelist/blacklist rules for pubkeys
- **NIP-42 Authentication**: Secure access using your Nostr identity
- **Event-Based Updates**: All changes are applied as cryptographically signed Nostr events
The web interface serves embedded static files with no external dependencies and includes proper CORS headers for browser compatibility.
## Administrator API
C-Relay uses an innovative **event-based administration system** where all configuration and management commands are sent as signed Nostr events using the admin private key generated during first startup. All admin commands use **NIP-44 encrypted command arrays** for security and compatibility.
### Authentication
All admin commands require signing with the admin private key displayed during first-time startup. **Save this key securely** - it cannot be recovered and is needed for all administrative operations.
### Event Structure
All admin commands use the same unified event structure with NIP-44 encrypted content:
**Admin Command Event:**
```json
{
"id": "event_id",
"pubkey": "admin_public_key",
"created_at": 1234567890,
"kind": 23456,
"content": "AqHBUgcM7dXFYLQuDVzGwMST1G8jtWYyVvYxXhVGEu4nAb4LVw...",
"tags": [
["p", "relay_public_key"]
],
"sig": "event_signature"
}
# Run container
docker run -d \
--name c-relay-pg \
-p 8888:8888 \
-v /path/to/data:/data \
c-relay-pg
```
The `content` field contains a NIP-44 encrypted JSON array representing the command.
### Cloud Deployment
**Admin Response Event:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "BpKCVhfN8eYtRmPqSvWxZnMkL2gHjUiOp3rTyEwQaS5dFg...",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
Quick deployment scripts for popular cloud providers:
```bash
# AWS, GCP, DigitalOcean, etc.
sudo examples/deployment/simple-vps/deploy.sh
```
The `content` field contains a NIP-44 encrypted JSON response object.
See [`docs/deployment_guide.md`](docs/deployment_guide.md) for detailed deployment instructions.
### Admin Commands
## 🔧 Configuration
All commands are sent as NIP-44 encrypted JSON arrays in the event content. The following table lists all available commands:
C-Relay-PG uses an innovative event-based configuration system. All settings are managed through signed Nostr events.
| Command Type | Command Format | Description |
|--------------|----------------|-------------|
| **Configuration Management** |
| `config_update` | `["config_update", [{"key": "auth_enabled", "value": "true", "data_type": "boolean", "category": "auth"}, {"key": "relay_description", "value": "My Relay", "data_type": "string", "category": "relay"}, ...]]` | Update relay configuration parameters (supports multiple updates) |
| `config_query` | `["config_query", "all"]` | Query all configuration parameters |
| **Auth Rules Management** |
| `auth_add_blacklist` | `["blacklist", "pubkey", "abc123..."]` | Add pubkey to blacklist |
| `auth_add_whitelist` | `["whitelist", "pubkey", "def456..."]` | Add pubkey to whitelist |
| `auth_delete_rule` | `["delete_auth_rule", "blacklist", "pubkey", "abc123..."]` | Delete specific auth rule |
| `auth_query_all` | `["auth_query", "all"]` | Query all auth rules |
| `auth_query_type` | `["auth_query", "whitelist"]` | Query specific rule type |
| `auth_query_pattern` | `["auth_query", "pattern", "abc123..."]` | Query specific pattern |
| **System Commands** |
| `system_clear_auth` | `["system_command", "clear_all_auth_rules"]` | Clear all auth rules |
| `system_status` | `["system_command", "system_status"]` | Get system status |
| `stats_query` | `["stats_query"]` | Get comprehensive database statistics |
### Basic Configuration
### Available Configuration Keys
Use the web interface at `http://localhost:8888/api/` or send admin commands via the API.
**Basic Relay Settings:**
- `relay_name`: Relay name (displayed in NIP-11)
- `relay_description`: Relay description text
- `relay_contact`: Contact information
- `relay_software`: Software URL
- `relay_version`: Software version
- `supported_nips`: Comma-separated list of supported NIP numbers (e.g., "1,2,4,9,11,12,13,15,16,20,22,33,40,42")
- `language_tags`: Comma-separated list of supported language tags (e.g., "en,es,fr" or "*" for all)
- `relay_countries`: Comma-separated list of supported country codes (e.g., "US,CA,MX" or "*" for all)
- `posting_policy`: Posting policy URL or text
- `payments_url`: Payment URL for premium features
- `max_connections`: Maximum concurrent connections
- `max_subscriptions_per_client`: Max subscriptions per client
- `max_event_tags`: Maximum tags per event
- `max_content_length`: Maximum event content length
### Common Settings
**Authentication & Access Control:**
- `auth_enabled`: Enable whitelist/blacklist auth rules (`true`/`false`)
- `nip42_auth_required`: Enable NIP-42 cryptographic authentication (`true`/`false`)
- `nip42_auth_required_kinds`: Event kinds requiring NIP-42 auth (comma-separated)
- `nip42_challenge_timeout`: NIP-42 challenge expiration seconds
- **Relay Information**: Name, description, contact info
- **Connection Limits**: Max subscriptions per client, total subscriptions
- **Authentication**: Enable/disable NIP-42, whitelist/blacklist rules
- **Proof of Work**: Minimum difficulty, enforcement mode
- **Event Validation**: Max tags, content length, message size
- **Expiration**: Enable/disable NIP-40 event expiration
**Proof of Work & Validation:**
- `pow_min_difficulty`: Minimum proof-of-work difficulty
- `nip40_expiration_enabled`: Enable event expiration (`true`/`false`)
### Dynamic Updates
### Dynamic Configuration Updates
Most configuration changes take effect immediately without restart:
- Relay information (NIP-11)
- Authentication settings
- Subscription limits
- Event validation rules
- Proof of Work settings
C-Relay supports **dynamic configuration updates** without requiring a restart for most settings. Configuration parameters are categorized as either **dynamic** (can be updated immediately) or **restart-required** (require relay restart to take effect).
See [`docs/configuration_guide.md`](docs/configuration_guide.md) for complete configuration reference.
**Dynamic Configuration Parameters (No Restart Required):**
- All relay information (NIP-11) settings: `relay_name`, `relay_description`, `relay_contact`, `relay_software`, `relay_version`, `supported_nips`, `language_tags`, `relay_countries`, `posting_policy`, `payments_url`
- Authentication settings: `auth_enabled`, `nip42_auth_required`, `nip42_auth_required_kinds`, `nip42_challenge_timeout`
- Subscription limits: `max_subscriptions_per_client`, `max_total_subscriptions`
- Event validation limits: `max_event_tags`, `max_content_length`, `max_message_length`
- Proof of Work settings: `pow_min_difficulty`, `pow_mode`
- Event expiration settings: `nip40_expiration_enabled`, `nip40_expiration_strict`, `nip40_expiration_filter`, `nip40_expiration_grace_period`
## 📚 Documentation
**Restart-Required Configuration Parameters:**
- Connection settings: `max_connections`, `relay_port`
- Database and core system settings
- **[API Documentation](API.md)** - Complete API reference and advanced features
- **[Configuration Guide](docs/configuration_guide.md)** - Detailed configuration options
- **[Deployment Guide](docs/deployment_guide.md)** - Production deployment instructions
- **[User Guide](docs/user_guide.md)** - End-user documentation
- **[NIP-42 Authentication](docs/NIP-42_Authentication.md)** - Authentication setup guide
When updating configuration, the admin API response will indicate whether a restart is required for each parameter. Dynamic updates take effect immediately and are reflected in NIP-11 relay information documents without restart.
## 🧪 Testing
### Response Format
Run the comprehensive test suite:
All admin commands return **signed EVENT responses** via WebSocket following standard Nostr protocol. Responses use JSON content with structured data.
```bash
# Run all tests
./tests/run_all_tests.sh
#### Response Examples
# Run NIP-specific tests
./tests/run_nip_tests.sh
**Success Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"config_update\", \"status\": \"success\", \"message\": \"Operation completed successfully\", \"timestamp\": 1234567890}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
# Test specific NIPs
./tests/42_nip_test.sh # NIP-42 authentication
./tests/13_nip_test.sh # NIP-13 proof of work
```
**Error Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"config_update\", \"status\": \"error\", \"error\": \"invalid configuration value\", \"timestamp\": 1234567890}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
## 🔒 Security
### Admin Key Management
The admin private key is displayed **only once** during first startup. Store it securely:
```bash
# Save to secure location
echo "ADMIN_PRIVKEY=your_admin_private_key" > ~/.c-relay-pg-admin
chmod 600 ~/.c-relay-pg-admin
```
**Auth Rules Query Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"auth_rules_all\", \"total_results\": 2, \"timestamp\": 1234567890, \"data\": [{\"rule_type\": \"blacklist\", \"pattern_type\": \"pubkey\", \"pattern_value\": \"abc123...\", \"action\": \"allow\"}]}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
```
### Production Security
**Configuration Query Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"config_all\", \"total_results\": 27, \"timestamp\": 1234567890, \"data\": [{\"key\": \"auth_enabled\", \"value\": \"false\", \"data_type\": \"boolean\", \"category\": \"auth\", \"description\": \"Enable NIP-42 authentication\"}, {\"key\": \"relay_description\", \"value\": \"My Relay\", \"data_type\": \"string\", \"category\": \"relay\", \"description\": \"Relay description text\"}]}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
```
- Use HTTPS/WSS with reverse proxy (nginx/Apache)
- Enable NIP-42 authentication for client verification
- Configure whitelist/blacklist for access control
- Set up firewall rules to restrict access
- Enable Proof of Work to prevent spam
- Regular database backups
**Configuration Update Success Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"config_update\", \"total_results\": 2, \"timestamp\": 1234567890, \"status\": \"success\", \"data\": [{\"key\": \"auth_enabled\", \"value\": \"true\", \"status\": \"updated\"}, {\"key\": \"relay_description\", \"value\": \"My Updated Relay\", \"status\": \"updated\"}]}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
```
See [`docs/deployment_guide.md`](docs/deployment_guide.md#security-hardening) for security hardening guide.
**Configuration Update Error Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"config_update\", \"status\": \"error\", \"error\": \"field validation failed: invalid port number '99999' (must be 1-65535)\", \"timestamp\": 1234567890}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
```
## 🤝 Contributing
**Database Statistics Query Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"stats_query\", \"timestamp\": 1234567890, \"database_size_bytes\": 1048576, \"total_events\": 15432, \"database_created_at\": 1234567800, \"latest_event_at\": 1234567890, \"event_kinds\": [{\"kind\": 1, \"count\": 12000, \"percentage\": 77.8}, {\"kind\": 0, \"count\": 2500, \"percentage\": 16.2}], \"time_stats\": {\"total\": 15432, \"last_24h\": 234, \"last_7d\": 1456, \"last_30d\": 5432}, \"top_pubkeys\": [{\"pubkey\": \"abc123...\", \"event_count\": 1234, \"percentage\": 8.0}, {\"pubkey\": \"def456...\", \"event_count\": 987, \"percentage\": 6.4}]}",
"tags": [
["p", "admin_public_key"]
],
"sig": "response_event_signature"
}]
Contributions are welcome! Please:
1. Fork the repository
2. Create a feature branch
3. Make your changes with tests
4. Submit a pull request
```
## 📄 License
[Add your license here]
## 🔗 Links
- **Repository**: [https://github.com/your-org/c-relay-pg](https://github.com/your-org/c-relay-pg)
- **Releases**: [https://git.laantungir.net/laantungir/c-relay-pg/releases](https://git.laantungir.net/laantungir/c-relay-pg/releases)
- **Issues**: [https://github.com/your-org/c-relay-pg/issues](https://github.com/your-org/c-relay-pg/issues)
- **Nostr Protocol**: [https://github.com/nostr-protocol/nostr](https://github.com/nostr-protocol/nostr)
## 💬 Support
- Open an issue on GitHub
- Join the Nostr community
- Contact via Nostr DM (relay pubkey shown on startup)
## Direct Messaging Admin System
In addition to the above admin API, c-relay allows the administrator to direct message the relay to get information or control some settings. As long as the administrator is signed in with any nostr client that allows sending nip-17 direct messages (DMs), they can control the relay.
The is possible because the relay is a full nostr citizen with it's own private and public key, and it knows the administrator's public key.
**Available DM commands**
The intent is not to be strict in the formatting of the DM. So for example if the relay receives any DM from the administrator with the words "stats" or "statistics" in it, it will respond to the administrator with a reply DM with the current relay statistics.
- `stats`|`statistics`: Relay statistics
- `config`|`configuration`: Relay configuration
---
**Built with ❤️ for the Nostr protocol**
+174
View File
@@ -0,0 +1,174 @@
# Real-Time Traffic Monitoring Commands (Direct Server Use)
Copy and paste these commands directly on your server.
## Quick Status Checks
### See IPs visiting in the last few minutes:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
```
### See what status codes they're getting:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $1, $9}' | grep '216.73.216.38'
```
### Count status codes (200 vs 403):
```bash
sudo tail -500 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c
```
## Real-Time Monitoring
### Watch live traffic (updates every 2 seconds):
```bash
watch -n 2 'sudo tail -200 /var/log/nginx/access.log | awk "{print \$1}" | sort | uniq -c | sort -rn | head -15'
```
### See live log entries as they happen:
```bash
sudo tail -f /var/log/nginx/access.log
```
### Live GoAccess dashboard:
```bash
sudo tail -f /var/log/nginx/access.log | goaccess -
```
## Active Connections
### See who's connected RIGHT NOW:
```bash
sudo netstat -tn | grep ':443' | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
```
### Alternative (using ss command):
```bash
sudo ss -tn | grep ':443' | awk '{print $5}' | cut -d: -f1 | sort | uniq -c | sort -rn
```
## Detailed Analysis
### Last 100 requests with timestamps:
```bash
sudo tail -100 /var/log/nginx/access.log | awk '{print $4, $1}' | sed 's/\[//'
```
### See what blocked IPs are trying to access:
```bash
sudo tail -500 /var/log/nginx/access.log | grep '216.73.216.38' | awk '{print $7}' | head -10
```
### Show all 403 (blocked) requests:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '$9==403 {print $1}' | sort | uniq -c | sort -rn
```
### Show all successful (200) requests:
```bash
sudo tail -500 /var/log/nginx/access.log | awk '$9==200 {print $1}' | sort | uniq -c | sort -rn | head -10
```
## Comprehensive Monitoring Script
### Create a monitoring script:
```bash
cat > /tmp/monitor-traffic.sh << 'EOF'
#!/bin/bash
echo "=== Traffic in last 5 minutes ==="
echo "Time: $(date)"
echo ""
echo "Top IPs:"
sudo tail -1000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10
echo ""
echo "Blocked IPs (403 errors):"
sudo tail -1000 /var/log/nginx/access.log | awk '$9==403 {print $1}' | sort | uniq -c | sort -rn
echo ""
echo "Successful requests (200):"
sudo tail -1000 /var/log/nginx/access.log | awk '$9==200 {print $1}' | sort | uniq -c | sort -rn | head -5
echo ""
echo "Status Code Summary:"
sudo tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c | sort -rn
EOF
chmod +x /tmp/monitor-traffic.sh
```
### Run the monitoring script:
```bash
/tmp/monitor-traffic.sh
```
## Auto-Refreshing Dashboard
### Live dashboard (refreshes every 5 seconds):
```bash
watch -n 5 'echo "=== Last 5 minutes ==="
date
echo ""
echo "Top IPs:"
sudo tail -1000 /var/log/nginx/access.log | awk "{print \$1}" | sort | uniq -c | sort -rn | head -10
echo ""
echo "Status Codes:"
sudo tail -1000 /var/log/nginx/access.log | awk "{print \$9}" | sort | uniq -c | sort -rn'
```
Press `Ctrl+C` to exit.
## GoAccess HTML Report (Live Updating)
### Generate live HTML report:
```bash
sudo goaccess /var/log/nginx/access.log -o /var/www/html/live-stats.html --real-time-html --daemonize
```
Then visit: https://git.laantungir.net/live-stats.html
### Stop the live report:
```bash
sudo pkill -f "goaccess.*live-stats"
```
## Filter by Time
### Get timestamp from 5 minutes ago:
```bash
date -d '5 minutes ago' '+%d/%b/%Y:%H:%M'
```
### Analyze only recent logs (replace timestamp):
```bash
sudo awk '/01\/Feb\/2026:19:09/,0' /var/log/nginx/access.log | goaccess -
```
## Check Gitea CPU
### Current CPU usage:
```bash
ps aux | grep gitea | grep -v grep
```
### Watch CPU in real-time:
```bash
watch -n 2 'ps aux | grep gitea | grep -v grep'
```
## Most Useful Command for Quick Check
This one-liner shows everything you need:
```bash
echo "=== Quick Status ===" && \
echo "Time: $(date)" && \
echo "" && \
echo "Top 10 IPs (last 1000 requests):" && \
sudo tail -1000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10 && \
echo "" && \
echo "Status Codes:" && \
sudo tail -1000 /var/log/nginx/access.log | awk '{print $9}' | sort | uniq -c && \
echo "" && \
echo "Gitea CPU:" && \
ps aux | grep gitea | grep -v grep
```
Copy any of these commands and run them directly on your server!
+612
View File
@@ -0,0 +1,612 @@
# Static MUSL Build Guide for C Programs
## Overview
This guide explains how to build truly portable static binaries using Alpine Linux and MUSL libc. These binaries have **zero runtime dependencies** and work on any Linux distribution without modification.
This guide is specifically tailored for C programs that use:
- **nostr_core_lib** - Nostr protocol implementation
- **nostr_login_lite** - Nostr authentication library
- Common dependencies: libwebsockets, OpenSSL, SQLite, curl, secp256k1
## Why MUSL Static Binaries?
### Advantages Over glibc
| Feature | MUSL Static | glibc Static | glibc Dynamic |
|---------|-------------|--------------|---------------|
| **Portability** | ✓ Any Linux | ⚠ glibc only | ✗ Requires matching libs |
| **Binary Size** | ~7-10 MB | ~12-15 MB | ~2-3 MB |
| **Dependencies** | None | NSS libs | Many system libs |
| **Deployment** | Single file | Single file + NSS | Binary + libraries |
| **Compatibility** | Universal | glibc version issues | Library version hell |
### Key Benefits
1. **True Portability**: Works on Alpine, Ubuntu, Debian, CentOS, Arch, etc.
2. **No Library Hell**: No `GLIBC_2.XX not found` errors
3. **Simple Deployment**: Just copy one file
4. **Reproducible Builds**: Same Docker image = same binary
5. **Security**: No dependency on system libraries with vulnerabilities
## Quick Start
### Prerequisites
- Docker installed and running
- Your C project with source code
- Internet connection for downloading dependencies
### Basic Build Process
```bash
# 1. Copy the Dockerfile template (see below)
cp /path/to/c-relay-pg/Dockerfile.alpine-musl ./Dockerfile.static
# 2. Customize for your project (see Customization section)
vim Dockerfile.static
# 3. Build the static binary
docker build --platform linux/amd64 -f Dockerfile.static -t my-app-builder .
# 4. Extract the binary
docker create --name temp-container my-app-builder
docker cp temp-container:/build/my_app_static ./my_app_static
docker rm temp-container
# 5. Verify it's static
ldd ./my_app_static # Should show "not a dynamic executable"
```
## Dockerfile Template
Here's a complete Dockerfile template you can customize for your project:
```dockerfile
# Alpine-based MUSL static binary builder
# Produces truly portable binaries with zero runtime dependencies
FROM alpine:3.19 AS builder
# Install build dependencies
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
sqlite-dev \
sqlite-static \
linux-headers \
wget \
bash
WORKDIR /build
# Build libsecp256k1 static (required for Nostr)
RUN cd /tmp && \
git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && \
./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr \
CFLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/secp256k1
# Build libwebsockets static (if needed for WebSocket support)
RUN cd /tmp && \
git clone --depth 1 --branch v4.3.3 https://github.com/warmcat/libwebsockets.git && \
cd libwebsockets && \
mkdir build && cd build && \
cmake .. \
-DLWS_WITH_STATIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_WITH_SSL=ON \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITHOUT_TEST_SERVER=ON \
-DLWS_WITHOUT_TEST_CLIENT=ON \
-DLWS_WITHOUT_TEST_PING=ON \
-DLWS_WITH_HTTP2=OFF \
-DLWS_WITH_LIBUV=OFF \
-DLWS_WITH_LIBEVENT=OFF \
-DLWS_IPV6=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_C_FLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/libwebsockets
# Copy git configuration for submodules
COPY .gitmodules /build/.gitmodules
COPY .git /build/.git
# Initialize submodules
RUN git submodule update --init --recursive
# Copy and build nostr_core_lib
COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,6,13,17,19,44,59
# Copy and build nostr_login_lite (if used)
# COPY nostr_login_lite /build/nostr_login_lite/
# RUN cd nostr_login_lite && make static
# Copy your application source
COPY src/ /build/src/
COPY Makefile /build/Makefile
# Build your application with full static linking
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
src/*.c \
-o /build/my_app_static \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl && \
strip /build/my_app_static
# Verify it's truly static
RUN echo "=== Binary Information ===" && \
file /build/my_app_static && \
ls -lh /build/my_app_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/my_app_static 2>&1 || echo "Binary is static")
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/my_app_static /my_app_static
```
## Customization Guide
### 1. Adjust Dependencies
**Add dependencies** by modifying the `apk add` section:
```dockerfile
RUN apk add --no-cache \
build-base \
musl-dev \
# Add your dependencies here:
libpng-dev \
libpng-static \
libjpeg-turbo-dev \
libjpeg-turbo-static
```
**Remove unused dependencies** to speed up builds:
- Remove `libwebsockets` section if you don't need WebSocket support
- Remove `sqlite` if you don't use databases
- Remove `curl` if you don't make HTTP requests
### 2. Configure nostr_core_lib NIPs
Specify which NIPs your application needs:
```bash
./build.sh --nips=1,6,19 # Minimal: Basic protocol, keys, bech32
./build.sh --nips=1,6,13,17,19,44,59 # Full: All common NIPs
./build.sh --nips=all # Everything available
```
**Common NIP combinations:**
- **Basic client**: `1,6,19` (events, keys, bech32)
- **With encryption**: `1,6,19,44` (add modern encryption)
- **With DMs**: `1,6,17,19,44,59` (add private messages)
- **Relay/server**: `1,6,13,17,19,42,44,59` (add PoW, auth)
### 3. Modify Compilation Flags
**For your application:**
```dockerfile
RUN gcc -static -O2 -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \ # REQUIRED for MUSL
-I. -Inostr_core_lib \ # Include paths
src/*.c \ # Your source files
-o /build/my_app_static \ # Output binary
nostr_core_lib/libnostr_core_x64.a \ # Nostr library
-lwebsockets -lssl -lcrypto \ # Link libraries
-lsqlite3 -lsecp256k1 -lcurl \
-lz -lpthread -lm -ldl
```
**Debug build** (with symbols, no optimization):
```dockerfile
RUN gcc -static -g -O0 -DDEBUG \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
# ... rest of flags
```
### 4. Multi-Architecture Support
Build for different architectures:
```bash
# x86_64 (Intel/AMD)
docker build --platform linux/amd64 -f Dockerfile.static -t my-app-x86 .
# ARM64 (Apple Silicon, Raspberry Pi 4+)
docker build --platform linux/arm64 -f Dockerfile.static -t my-app-arm64 .
```
## Build Script Template
Create a `build_static.sh` script for convenience:
```bash
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.static"
# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="my_app_static_x86_64"
;;
aarch64|arm64)
PLATFORM="linux/arm64"
OUTPUT_NAME="my_app_static_arm64"
;;
*)
echo "Unknown architecture: $ARCH"
exit 1
;;
esac
echo "Building for platform: $PLATFORM"
mkdir -p "$BUILD_DIR"
# Build Docker image
docker build \
--platform "$PLATFORM" \
-f "$DOCKERFILE" \
-t my-app-builder:latest \
--progress=plain \
.
# Extract binary
CONTAINER_ID=$(docker create my-app-builder:latest)
docker cp "$CONTAINER_ID:/build/my_app_static" "$BUILD_DIR/$OUTPUT_NAME"
docker rm "$CONTAINER_ID"
chmod +x "$BUILD_DIR/$OUTPUT_NAME"
echo "✓ Build complete: $BUILD_DIR/$OUTPUT_NAME"
echo "✓ Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
# Verify
if ldd "$BUILD_DIR/$OUTPUT_NAME" 2>&1 | grep -q "not a dynamic executable"; then
echo "✓ Binary is fully static"
else
echo "⚠ Warning: Binary may have dynamic dependencies"
fi
```
Make it executable:
```bash
chmod +x build_static.sh
./build_static.sh
```
## Common Issues and Solutions
### Issue 1: Fortification Errors
**Error:**
```
undefined reference to '__snprintf_chk'
undefined reference to '__fprintf_chk'
```
**Cause**: GCC's `-O2` enables fortification by default, which uses glibc-specific functions.
**Solution**: Add these flags to **all** compilation commands:
```bash
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0
```
This must be applied to:
1. nostr_core_lib build.sh
2. Your application compilation
3. Any other libraries you build
### Issue 2: Missing Symbols from nostr_core_lib
**Error:**
```
undefined reference to 'nostr_create_event'
undefined reference to 'nostr_sign_event'
```
**Cause**: Required NIPs not included in nostr_core_lib build.
**Solution**: Add missing NIPs:
```bash
./build.sh --nips=1,6,19 # Add the NIPs you need
```
### Issue 3: Docker Permission Denied
**Error:**
```
permission denied while trying to connect to the Docker daemon socket
```
**Solution**:
```bash
sudo usermod -aG docker $USER
newgrp docker # Or logout and login
```
### Issue 4: Binary Won't Run on Target System
**Checks**:
```bash
# 1. Verify it's static
ldd my_app_static # Should show "not a dynamic executable"
# 2. Check architecture
file my_app_static # Should match target system
# 3. Test on different distributions
docker run --rm -v $(pwd):/app alpine:latest /app/my_app_static --version
docker run --rm -v $(pwd):/app ubuntu:latest /app/my_app_static --version
```
## Project Structure Example
Organize your project for easy static builds:
```
my-nostr-app/
├── src/
│ ├── main.c
│ ├── handlers.c
│ └── utils.c
├── nostr_core_lib/ # Git submodule
├── nostr_login_lite/ # Git submodule (if used)
├── Dockerfile.static # Static build Dockerfile
├── build_static.sh # Build script
├── Makefile # Regular build
└── README.md
```
### Makefile Integration
Add static build targets to your Makefile:
```makefile
# Regular dynamic build
all: my_app
my_app: src/*.c
gcc -O2 src/*.c -o my_app \
nostr_core_lib/libnostr_core_x64.a \
-lssl -lcrypto -lsecp256k1 -lz -lpthread -lm
# Static MUSL build via Docker
static:
./build_static.sh
# Clean
clean:
rm -f my_app build/my_app_static_*
.PHONY: all static clean
```
## Deployment
### Single Binary Deployment
```bash
# Copy to server
scp build/my_app_static_x86_64 user@server:/opt/my-app/
# Run (no dependencies needed!)
ssh user@server
/opt/my-app/my_app_static_x86_64
```
### SystemD Service
```ini
[Unit]
Description=My Nostr Application
After=network.target
[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/my-app
ExecStart=/opt/my-app/my_app_static_x86_64
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
### Docker Container (Minimal)
```dockerfile
FROM scratch
COPY my_app_static_x86_64 /app
ENTRYPOINT ["/app"]
```
Build and run:
```bash
docker build -t my-app:latest .
docker run --rm my-app:latest --help
```
## Reusing c-relay-pg Files
You can directly copy these files from c-relay-pg:
### 1. Dockerfile.alpine-musl
```bash
cp /path/to/c-relay-pg/Dockerfile.alpine-musl ./Dockerfile.static
```
Then customize:
- Change binary name (line 125)
- Adjust source files (line 122-124)
- Modify include paths (line 120-121)
### 2. build_static.sh
```bash
cp /path/to/c-relay-pg/build_static.sh ./
```
Then customize:
- Change `OUTPUT_NAME` variable (lines 66, 70)
- Update Docker image name (line 98)
- Modify verification commands (lines 180-184)
### 3. .dockerignore (Optional)
```bash
cp /path/to/c-relay-pg/.dockerignore ./
```
Helps speed up Docker builds by excluding unnecessary files.
## Best Practices
1. **Version Control**: Commit your Dockerfile and build script
2. **Tag Builds**: Include git commit hash in binary version
3. **Test Thoroughly**: Verify on multiple distributions
4. **Document Dependencies**: List required NIPs and libraries
5. **Automate**: Use CI/CD to build on every commit
6. **Archive Binaries**: Keep old versions for rollback
## Performance Comparison
| Metric | MUSL Static | glibc Dynamic |
|--------|-------------|---------------|
| Binary Size | 7-10 MB | 2-3 MB + libs |
| Startup Time | ~50ms | ~40ms |
| Memory Usage | Similar | Similar |
| Portability | ✓ Universal | ✗ System-dependent |
| Deployment | Single file | Binary + libraries |
## References
- [MUSL libc](https://musl.libc.org/)
- [Alpine Linux](https://alpinelinux.org/)
- [nostr_core_lib](https://github.com/chebizarro/nostr_core_lib)
- [Static Linking Best Practices](https://www.musl-libc.org/faq.html)
- [c-relay-pg Implementation](./docs/musl_static_build.md)
## Example: Minimal Nostr Client
Here's a complete example of building a minimal Nostr client:
```c
// minimal_client.c
#include "nostr_core/nostr_core.h"
#include <stdio.h>
int main() {
// Generate keypair
char nsec[64], npub[64];
nostr_generate_keypair(nsec, npub);
printf("Generated keypair:\n");
printf("Private key (nsec): %s\n", nsec);
printf("Public key (npub): %s\n", npub);
// Create event
cJSON *event = nostr_create_event(1, "Hello, Nostr!", NULL);
nostr_sign_event(event, nsec);
char *json = cJSON_Print(event);
printf("\nSigned event:\n%s\n", json);
free(json);
cJSON_Delete(event);
return 0;
}
```
**Dockerfile.static:**
```dockerfile
FROM alpine:3.19 AS builder
RUN apk add --no-cache build-base musl-dev git autoconf automake libtool \
openssl-dev openssl-libs-static zlib-dev zlib-static
WORKDIR /build
# Build secp256k1
RUN cd /tmp && git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && ./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr CFLAGS="-fPIC" && \
make -j$(nproc) && make install
# Copy and build nostr_core_lib
COPY nostr_core_lib /build/nostr_core_lib/
RUN cd nostr_core_lib && \
sed -i 's/CFLAGS="-Wall/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall/' build.sh && \
./build.sh --nips=1,6,19
# Build application
COPY minimal_client.c /build/
RUN gcc -static -O2 -Wall -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-Inostr_core_lib -Inostr_core_lib/nostr_core -Inostr_core_lib/cjson \
minimal_client.c -o /build/minimal_client_static \
nostr_core_lib/libnostr_core_x64.a \
-lssl -lcrypto -lsecp256k1 -lz -lpthread -lm -ldl && \
strip /build/minimal_client_static
FROM scratch
COPY --from=builder /build/minimal_client_static /minimal_client_static
```
**Build and run:**
```bash
docker build -f Dockerfile.static -t minimal-client .
docker create --name temp minimal-client
docker cp temp:/minimal_client_static ./
docker rm temp
./minimal_client_static
```
## Conclusion
Static MUSL binaries provide the best portability for C applications. While they're slightly larger than dynamic binaries, the benefits of zero dependencies and universal compatibility make them ideal for:
- Server deployments across different Linux distributions
- Embedded systems and IoT devices
- Docker containers (FROM scratch)
- Distribution to users without dependency management
- Long-term archival and reproducibility
Follow this guide to create portable, self-contained binaries for your Nostr applications!
+37
View File
@@ -0,0 +1,37 @@
<?php
/** admin2/api/auth.php — Auth rules + WoT status. */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
try {
if ($action === 'add') {
$pdo->prepare("INSERT INTO auth_rules (rule_type, pattern_type, pattern_value) VALUES (?, 'pubkey', ?)")->execute([$input['rule_type'], $input['pattern_value']]);
json_response(['message' => 'Rule added']);
} elseif ($action === 'remove') {
$pdo->prepare("DELETE FROM auth_rules WHERE id = ?")->execute([intval($input['id'])]);
json_response(['message' => 'Rule removed']);
} elseif ($action === 'set_wot_level') {
$pdo->prepare("UPDATE config SET value = ? WHERE key = 'wot_level'")->execute([strval($input['level'])]);
json_response(['message' => 'WoT level set']);
} elseif ($action === 'sync_wot') {
json_response(['message' => 'WoT sync not available via PHP (requires relay)']);
}
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
}
// GET: WoT status
if (isset($_GET['action']) && $_GET['action'] === 'wot') {
$level = $pdo->query("SELECT value FROM config WHERE key = 'wot_level'")->fetchColumn() ?: '0';
$wl_count = 0;
try { $wl_count = intval($pdo->query("SELECT count(*) FROM auth_rules WHERE rule_type = 'whitelist'")->fetchColumn()); } catch (PDOException $e) {}
$descriptions = ['0' => 'Level 0: Open relay — anyone can read and write', '1' => 'Level 1: Write only — WoT users can write', '2' => 'Level 2: Full — WoT users only'];
json_response(['kind3_status' => 'N/A (PHP admin)', 'whitelist_count' => $wl_count, 'level_description' => $descriptions[$level] ?? $descriptions['0']]);
}
// GET: auth rules
$rules = [];
try { $rules = $pdo->query("SELECT id, rule_type, pattern_type, pattern_value FROM auth_rules ORDER BY id")->fetchAll(); } catch (PDOException $e) {}
json_response(['rules' => $rules]);
+123
View File
@@ -0,0 +1,123 @@
<?php
/** admin2/api/caching.php — Caching service status + follows + reset actions. */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// --- POST actions: reset backfill (all or per-user) ---
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true) ?: [];
$action = $input['action'] ?? '';
$pubkey = $input['pubkey'] ?? '';
if ($action === 'reset_all') {
// Reset backfill progress for ALL followed authors, then bump config
// generation so the running caching service hot-reloads and re-drains.
try {
$pdo->beginTransaction();
$pdo->exec("UPDATE caching_followed_pubkeys
SET until_cursor = 0, backfill_complete = FALSE,
events_fetched = 0,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
// Also reset per-relay progress rows
$pdo->exec("UPDATE caching_backfill_relay_progress
SET complete = FALSE, events_fetched = 0,
until_cursor = 0, last_status = 'reset',
consecutive_errors = 0,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT");
// Bump config generation to trigger hot-reload
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
WHERE key = 'caching_config_generation'");
$pdo->commit();
json_response(['ok' => true, 'message' => 'All backfill progress reset. Caching service will re-drain.']);
} catch (PDOException $e) {
$pdo->rollBack();
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
} elseif ($action === 'reset_user' && $pubkey) {
// Reset backfill progress for a single followed author.
try {
$pdo->beginTransaction();
$stmt = $pdo->prepare("UPDATE caching_followed_pubkeys
SET until_cursor = 0, backfill_complete = FALSE,
events_fetched = 0,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE pubkey = ?");
$stmt->execute([$pubkey]);
// Also reset per-relay progress for this author
$stmt2 = $pdo->prepare("UPDATE caching_backfill_relay_progress
SET complete = FALSE, events_fetched = 0,
until_cursor = 0, last_status = 'reset',
consecutive_errors = 0,
updated_at = EXTRACT(EPOCH FROM NOW())::BIGINT
WHERE author_pubkey = ?");
$stmt2->execute([$pubkey]);
// Bump config generation to trigger hot-reload
$pdo->exec("UPDATE config SET value = (COALESCE(value::int, 0) + 1)::text
WHERE key = 'caching_config_generation'");
$pdo->commit();
json_response(['ok' => true, 'message' => "Backfill reset for pubkey. Caching service will re-fetch."]);
} catch (PDOException $e) {
$pdo->rollBack();
json_response(['ok' => false, 'error' => $e->getMessage()], 500);
}
} else {
json_response(['ok' => false, 'error' => 'Unknown action'], 400);
}
exit;
}
// Caching config values (for toggle controls)
$config = [];
try {
$cfg_rows = $pdo->query("SELECT key, value FROM config WHERE key IN ('caching_enabled','caching_inbox_enabled','caching_live_enabled','caching_backfill_enabled')")->fetchAll();
foreach ($cfg_rows as $r) { $config[$r['key']] = $r['value']; }
} catch (PDOException $e) {}
// Service state
$state = [];
try { $state = $pdo->query("SELECT * FROM caching_service_state WHERE id = 1")->fetch() ?: []; } catch (PDOException $e) {}
// Active target
$active = ['pubkey' => '', 'relay' => ''];
try { $active = $pdo->query("SELECT active_pubkey AS pubkey, active_relay AS relay FROM caching_backfill_active WHERE id = 1")->fetch() ?: $active; } catch (PDOException $e) {}
// Inbox
$inbox = ['pending' => 0, 'live' => 0, 'backfill' => 0];
try {
$inbox = $pdo->query("SELECT COUNT(*) AS pending, COUNT(*) FILTER (WHERE source_class='live') AS live, COUNT(*) FILTER (WHERE source_class='backfill') AS backfill FROM caching_event_inbox")->fetch() ?: $inbox;
} catch (PDOException $e) {}
// Follows (paginated, with names from profiles cache + per-relay progress)
$follows = [];
try {
$follows = $pdo->query("
SELECT fp.pubkey, fp.is_root, fp.backfill_complete, fp.events_fetched,
fp.last_event_at,
p.name, p.display_name,
(SELECT count(*) FROM events WHERE pubkey = fp.pubkey) AS total_events,
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey) AS relay_count,
(SELECT count(*) FROM caching_backfill_relay_progress WHERE author_pubkey = fp.pubkey AND complete = false) AS relay_incomplete
FROM caching_followed_pubkeys fp
LEFT JOIN profiles p ON p.pubkey = fp.pubkey
ORDER BY fp.is_root DESC, fp.events_fetched DESC LIMIT 1000
")->fetchAll();
// Fetch per-relay progress for all followed pubkeys in one query
$relayProgress = [];
try {
$rpRows = $pdo->query("
SELECT author_pubkey, relay_url, complete, events_fetched, until_cursor, last_status, updated_at
FROM caching_backfill_relay_progress
ORDER BY author_pubkey, events_fetched DESC
")->fetchAll();
foreach ($rpRows as $rp) {
$relayProgress[$rp['author_pubkey']][] = $rp;
}
} catch (PDOException $e) {}
foreach ($follows as &$f) {
$f['name'] = profile_display_name($f);
$f['npub'] = function_exists('hex_to_npub') ? hex_to_npub($f['pubkey']) : substr($f['pubkey'], 0, 20);
$f['relays'] = $relayProgress[$f['pubkey']] ?? [];
}
} catch (PDOException $e) {}
json_response(['state' => $state, 'active' => $active, 'inbox' => $inbox, 'follows' => $follows, 'config' => $config]);
+120
View File
@@ -0,0 +1,120 @@
<?php
/**
* admin2/api/chart.php — Standalone ASCII chart endpoint.
*
* Returns a plain-text ASCII X-bar chart of event counts over time.
* Works in both the browser (injected into a <div>) and the terminal:
*
* curl http://localhost:8088/api/chart.php?range=hour
* curl http://localhost:8088/api/chart.php?range=day
* curl http://localhost:8088/api/chart.php?range=month
* curl http://localhost:8088/api/chart.php?range=year
*
* Caching: the hour chart is never cached (live). Day/month/year are
* cached to file with TTLs to avoid expensive queries on every request.
*/
require_once __DIR__ . '/../lib/helpers.php';
require_once __DIR__ . '/../lib/ascii_chart.php';
// --- Range configuration ---
$ranges = [
'hour' => ['span' => 3600, 'bin' => 45, 'bins' => 80, 'ttl' => 0, 'title' => 'New Events — Last Hour'],
'day' => ['span' => 86400, 'bin' => 1080, 'bins' => 80, 'ttl' => 3600, 'title' => 'New Events — Last 24 Hours'],
'month' => ['span' => 2592000, 'bin' => 32400, 'bins' => 80, 'ttl' => 86400, 'title' => 'New Events — Last 30 Days'],
'year' => ['span' => 31536000, 'bin' => 394200, 'bins' => 80, 'ttl' => 2592000, 'title' => 'New Events — Last Year'],
];
$range = $_GET['range'] ?? 'hour';
if (!isset($ranges[$range])) {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo "Invalid range. Use: hour, day, month, or year.\n";
exit;
}
$cfg = $ranges[$range];
// --- Set content type ---
header('Content-Type: text/plain; charset=utf-8');
// --- Check cache ---
if ($cfg['ttl'] > 0) {
$cached = get_cached_chart($range, $cfg['ttl']);
if ($cached !== null) {
echo $cached;
exit;
}
}
// --- Run binning query ---
$pdo = db();
$now = time();
$epoch = $now - $cfg['span'];
$bin_size = $cfg['bin'];
$num_bins = $cfg['bins'];
try {
// Calculate the bin offset so the rightmost bin aligns with "now"
// bin index 0 = oldest, bin index (num_bins-1) = newest
$base_bin = (int)floor($epoch / $bin_size);
$sql = "SELECT FLOOR(created_at / {$bin_size})::BIGINT - {$base_bin} AS bin, COUNT(*) AS cnt
FROM events
WHERE created_at >= {$epoch}
GROUP BY bin
ORDER BY bin";
$rows = $pdo->query($sql)->fetchAll();
} catch (PDOException $e) {
echo "Chart query failed: " . $e->getMessage() . "\n";
exit;
}
// --- Build bin array (zero-filled, fixed length) ---
// Index 0 = oldest, index N-1 = newest (natural time order from SQL)
$bins = build_bin_array($rows, $num_bins);
// Reverse so newest bin is at index 0 (leftmost) and oldest at right.
// This matches the original text_graph.js display: new data enters
// from the left and proceeds rightward as it ages.
$bins = array_reverse($bins);
// --- Render ASCII chart ---
$ascii = render_ascii_chart($bins, [
'title' => $cfg['title'],
'max_height' => 11,
'bin_duration' => $bin_size,
'label_interval' => max(1, (int)floor($num_bins / 15)), // ~15 labels across
]);
// --- Write to cache (if TTL > 0) ---
if ($cfg['ttl'] > 0) {
set_cached_chart($range, $ascii);
}
echo $ascii;
// ================================
// CACHE HELPERS
// ================================
function get_cache_dir(): string {
return __DIR__ . '/../cache';
}
function get_cached_chart(string $range, int $ttl): ?string {
$file = get_cache_dir() . "/chart_{$range}.txt";
if (!file_exists($file)) return null;
if (filemtime($file) < (time() - $ttl)) return null;
$content = @file_get_contents($file);
return ($content !== false) ? $content : null;
}
function set_cached_chart(string $range, string $ascii): void {
$dir = get_cache_dir();
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
@file_put_contents($dir . "/chart_{$range}.txt", $ascii);
}
+20
View File
@@ -0,0 +1,20 @@
<?php
/** admin2/api/config.php — Config table read/edit (all keys). */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$key = $input['key'] ?? '';
$value = $input['value'] ?? '';
if (!$key) json_response(['error' => 'Missing key']);
try {
$pdo->prepare("UPDATE config SET value = ? WHERE key = ?")->execute([$value, $key]);
json_response(['message' => "Updated $key"]);
} catch (PDOException $e) {
json_response(['error' => $e->getMessage()]);
}
}
$rows = $pdo->query("SELECT key, value FROM config ORDER BY key")->fetchAll();
json_response(['config' => $rows]);
+16
View File
@@ -0,0 +1,16 @@
<?php
/** admin2/api/dm.php — Direct messages (kind 4/14/15). */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$limit = min(200, intval($_GET['limit'] ?? 50));
$rows = $pdo->prepare("SELECT id, pubkey, kind, created_at, content FROM events WHERE kind IN (4, 14, 15) ORDER BY created_at DESC LIMIT $limit");
$rows->execute();
$messages = $rows->fetchAll();
foreach ($messages as &$m) {
$m['created_at'] = date('Y-m-d H:i:s', intval($m['created_at']));
$m['content'] = substr($m['content'] ?? '', 0, 300);
}
json_response(['messages' => $messages]);
+32
View File
@@ -0,0 +1,32 @@
<?php
/** admin2/api/events.php — Recent relay events (paginated). */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$limit = min(200, intval($_GET['limit'] ?? 50));
$offset = intval($_GET['offset'] ?? 0);
$kind = $_GET['kind'] ?? '';
$where = [];
$params = [];
if ($kind !== '') { $where[] = 'kind = ?'; $params[] = intval($kind); }
$where_sql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$rows = $pdo->prepare("SELECT e.id, e.pubkey, e.kind, e.created_at, e.content,
p.name, p.display_name
FROM events e
LEFT JOIN profiles p ON p.pubkey = e.pubkey
$where_sql
ORDER BY e.created_at DESC LIMIT $limit OFFSET $offset");
$rows->execute($params);
$events = $rows->fetchAll();
foreach ($events as &$e) {
$e['created_at'] = date('Y-m-d H:i:s', intval($e['created_at']));
$e['content'] = substr($e['content'] ?? '', 0, 200);
// Resolve display name from profiles cache; fall back to truncated pubkey.
$best = profile_display_name($e);
$e['display_name'] = $best !== '' ? $best : substr($e['pubkey'] ?? '', 0, 16) . '…';
}
json_response(['events' => $events]);
+35
View File
@@ -0,0 +1,35 @@
<?php
/** admin2/api/ipbans.php — IP ban management. */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$input = json_decode(file_get_contents('php://input'), true);
$action = $input['action'] ?? '';
try {
if ($action === 'add') {
$until = time() + intval($input['duration'] ?? 86400);
$pdo->prepare("INSERT INTO ip_bans (ip, banned_until, ban_count) VALUES (?, ?, 1) ON CONFLICT (ip) DO UPDATE SET banned_until = ?, ban_count = ip_bans.ban_count + 1")->execute([$input['ip'], $until, $until]);
json_response(['message' => 'IP banned']);
} elseif ($action === 'remove') {
$pdo->prepare("DELETE FROM ip_bans WHERE ip = ?")->execute([$input['ip']]);
json_response(['message' => 'Ban removed']);
}
} catch (PDOException $e) { json_response(['error' => $e->getMessage()]); }
}
$now = time();
$total = 0; $active = 0; $issued = 0;
$bans = [];
try {
$total = intval($pdo->query("SELECT count(*) FROM ip_bans")->fetchColumn());
$active = intval($pdo->query("SELECT count(*) FROM ip_bans WHERE banned_until > $now")->fetchColumn());
$issued = intval($pdo->query("SELECT COALESCE(sum(ban_count),0) FROM ip_bans")->fetchColumn());
$bans = $pdo->query("SELECT ip, banned_until, ban_count, failures, authed_successfully, connection_attempts FROM ip_bans ORDER BY banned_until DESC LIMIT 100")->fetchAll();
foreach ($bans as &$b) {
$b['status'] = intval($b['banned_until']) > $now ? 'banned' : 'expired';
$b['banned_until'] = date('Y-m-d H:i:s', intval($b['banned_until']));
}
} catch (PDOException $e) {}
json_response(['total' => $total, 'active' => $active, 'issued' => $issued, 'bans' => $bans]);
+38
View File
@@ -0,0 +1,38 @@
<?php
/**
* admin/api/profile.php — Single-profile lookup from the profiles cache.
* Returns cached kind-0 metadata for a given pubkey.
*
* Usage: profile.php?pubkey=<64-char-hex>
* Returns: { "pubkey": "...", "name": "...", "display_name": "...",
* "best_name": "...", "picture": "...", "nip05": "...",
* "about": "...", "website": "...", "lud16": "..." }
* or { "error": "not found" } with HTTP 404 if no profile is cached.
*/
require_once __DIR__ . '/../lib/helpers.php';
$pubkey = trim($_GET['pubkey'] ?? '');
if (!preg_match('/^[0-9a-fA-F]{64}$/', $pubkey)) {
http_response_code(400);
json_response(['error' => 'Invalid pubkey']);
}
$pdo = db();
try {
$stmt = $pdo->prepare(
"SELECT pubkey, name, display_name, about, picture, banner,
nip05, website, lud16, lud06
FROM profiles WHERE pubkey = ?"
);
$stmt->execute([$pubkey]);
$row = $stmt->fetch();
if (!$row) {
http_response_code(404);
json_response(['error' => 'not found']);
}
$row['best_name'] = profile_display_name($row);
json_response($row);
} catch (PDOException $e) {
http_response_code(500);
json_response(['error' => 'Database error']);
}
+24
View File
@@ -0,0 +1,24 @@
<?php
/** admin2/api/query.php — Direct SQL query (SELECT only, admin only). */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
json_response(['error' => 'POST only']);
}
$input = json_decode(file_get_contents('php://input'), true);
$sql = trim($input['sql'] ?? '');
if (!$sql) json_response(['error' => 'No query provided']);
if (!preg_match('/^\s*SELECT/i', $sql)) json_response(['error' => 'Only SELECT queries are allowed']);
$start = microtime(true);
try {
$stmt = $pdo->query($sql);
$rows = $stmt->fetchAll();
$time_ms = round((microtime(true) - $start) * 1000, 1);
json_response(['rows' => $rows, 'row_count' => count($rows), 'time_ms' => $time_ms]);
} catch (PDOException $e) {
json_response(['error' => $e->getMessage()]);
}
+154
View File
@@ -0,0 +1,154 @@
<?php
/**
* admin2/api/stats.php — Statistics page data.
* Returns DB size, event counts, kind distribution, top pubkeys,
* time-based stats, and event-rate delta (for the chart).
*/
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
// Database size
$db_size = '-';
try {
$bytes = $pdo->query("SELECT pg_database_size(current_database())")->fetchColumn();
$db_size = format_bytes(intval($bytes));
} catch (PDOException $e) {}
// Total events
$total_events = intval($pdo->query("SELECT COUNT(*) FROM events")->fetchColumn());
// Event rate: events with first_seen in last 10 seconds
$events_delta = 0;
try {
$events_delta = intval($pdo->query("SELECT COUNT(*) FROM events WHERE first_seen >= EXTRACT(EPOCH FROM NOW())::BIGINT - 10")->fetchColumn());
} catch (PDOException $e) {}
// Process info (from pg_stat_activity)
$process_id = '-';
$ws_connections = '-';
try {
$pid = $pdo->query("SELECT pg_backend_pid()")->fetchColumn();
$process_id = strval($pid);
$ws_connections = intval($pdo->query("SELECT count(*) FROM pg_stat_activity WHERE state = 'active' AND pid != pg_backend_pid()")->fetchColumn());
} catch (PDOException $e) {}
// Active subscriptions
$active_subscriptions = 0;
try {
$active_subscriptions = intval($pdo->query("SELECT count(*) FROM subscriptions WHERE active = true")->fetchColumn());
} catch (PDOException $e) {}
// Memory/CPU (from /proc on Linux)
$memory_usage = '-';
$cpu_usage = '-';
$cpu_core = '-';
if (is_readable('/proc/meminfo')) {
$mem = parse_ini_file('/proc/meminfo');
$total = intval($mem['MemTotal'] ?? 0) * 1024;
$avail = intval($mem['MemAvailable'] ?? 0) * 1024;
if ($total > 0) $memory_usage = format_bytes($total - $avail) . ' / ' . format_bytes($total);
}
if (is_readable('/proc/cpuinfo')) {
$cores = intval(shell_exec('nproc 2>/dev/null') ?: 1);
$cpu_core = strval($cores) . ' cores';
}
$cpu_usage = @file_get_contents('/proc/loadavg');
if ($cpu_usage !== false) $cpu_usage = trim(explode(' ', $cpu_usage)[0] ?? '-');
// Oldest / newest event
$oldest_event = '-';
$newest_event = '-';
try {
$oldest = $pdo->query("SELECT to_timestamp(MIN(created_at)) FROM events")->fetchColumn();
$newest = $pdo->query("SELECT to_timestamp(MAX(created_at)) FROM events")->fetchColumn();
if ($oldest) $oldest_event = substr($oldest, 0, 19);
if ($newest) $newest_event = substr($newest, 0, 19);
} catch (PDOException $e) {}
// Time-based stats
$now = time();
$events_24h = 0; $events_7d = 0; $events_30d = 0;
try {
$events_24h = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 86400")->fetchColumn());
$events_7d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 604800")->fetchColumn());
$events_30d = intval($pdo->query("SELECT COUNT(*) FROM events WHERE created_at >= $now - 2592000")->fetchColumn());
} catch (PDOException $e) {}
// Kind distribution
$kinds = [];
try {
$rows = $pdo->query("SELECT kind, COUNT(*) AS cnt FROM events GROUP BY kind ORDER BY cnt DESC LIMIT 20")->fetchAll();
foreach ($rows as $r) {
$kinds[] = ['kind' => intval($r['kind']), 'count' => intval($r['cnt']), 'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0];
}
} catch (PDOException $e) {}
// Top pubkeys (with names from profiles cache)
$top_pubkeys = [];
try {
$rows = $pdo->query("
SELECT pubkey, COUNT(*) AS cnt
FROM events
GROUP BY pubkey
ORDER BY cnt DESC LIMIT 20
")->fetchAll();
// Batch-resolve profile names from the profiles cache table.
$pubkeys = array_column($rows, 'pubkey');
$pmap = profile_map($pubkeys);
foreach ($rows as $r) {
$pk = $r['pubkey'];
$prof = $pmap[$pk] ?? null;
$top_pubkeys[] = [
'pubkey' => $pk,
'name' => $prof ? $prof['best_name'] : '',
'count' => intval($r['cnt']),
'pct' => $total_events > 0 ? round(intval($r['cnt']) / $total_events * 100, 1) : 0,
];
}
} catch (PDOException $e) {}
// Name-field usage stats (from profiles cache)
$name_field_usage = ['both' => 0, 'name_only' => 0, 'display_only' => 0, 'neither' => 0, 'both_differ' => 0, 'total' => 0];
try {
$row = $pdo->query("
SELECT count(*) FILTER (WHERE name <> '' AND display_name <> '') AS both,
count(*) FILTER (WHERE name <> '' AND display_name = '') AS name_only,
count(*) FILTER (WHERE name = '' AND display_name <> '') AS display_only,
count(*) FILTER (WHERE name = '' AND display_name = '') AS neither,
count(*) FILTER (WHERE name <> '' AND display_name <> '' AND name <> display_name) AS both_differ,
count(*) AS total
FROM profiles
")->fetch();
if ($row) {
$name_field_usage = array_map('intval', $row);
}
} catch (PDOException $e) {}
json_response([
'db_size' => $db_size,
'total_events' => $total_events,
'events_delta' => $events_delta,
'process_id' => $process_id,
'ws_connections' => $ws_connections,
'active_subscriptions' => $active_subscriptions,
'memory_usage' => $memory_usage,
'cpu_usage' => $cpu_usage,
'cpu_core' => $cpu_core,
'oldest_event' => $oldest_event,
'newest_event' => $newest_event,
'events_24h' => $events_24h,
'events_7d' => $events_7d,
'events_30d' => $events_30d,
'kinds' => $kinds,
'top_pubkeys' => $top_pubkeys,
'name_field_usage' => $name_field_usage,
]);
/** Format bytes as human-readable. */
function format_bytes(int $bytes): string {
if ($bytes < 1024) return $bytes . ' B';
if ($bytes < 1048576) return round($bytes / 1024, 1) . ' KB';
if ($bytes < 1073741824) return round($bytes / 1048576, 1) . ' MB';
return round($bytes / 1073741824, 2) . ' GB';
}
+11
View File
@@ -0,0 +1,11 @@
<?php
/** admin2/api/subscriptions.php — Active subscription details. */
require_once __DIR__ . '/../lib/helpers.php';
$pdo = db();
$subs = [];
try {
$subs = $pdo->query("SELECT id, sub_id, pubkey, filters, created_at, events_sent FROM subscriptions ORDER BY created_at DESC LIMIT 100")->fetchAll();
} catch (PDOException $e) {}
json_response(['subscriptions' => $subs]);
+956
View File
@@ -0,0 +1,956 @@
/*
* admin2 — Full PHP admin for C-Relay-PG.
* Replaces the 7457-line api/index.js with a lightweight AJAX-based
* controller that fetches data from PHP API endpoints (api/*.php)
* instead of Nostr WebSocket admin commands.
*/
const REFRESH_MS = 10000;
let currentPage = 'statistics';
let statsInterval = null;
// HTML-escape helper for safe interpolation into innerHTML.
// Prevents stored XSS from user-controlled data (profile names, event
// content, config values, etc.) being parsed as HTML by the browser.
const esc = (s) => {
const str = String(s ?? '');
return str.replace(/[&<>"']/g, (ch) => {
return '&#' + ch.charCodeAt(0) + ';';
});
};
// Auth state
let nlLite = null;
let userPubkey = null;
let isLoggedIn = false;
let relayAnimationTimer = null;
// Server-rendered ASCII chart state
let currentChartRange = 'hour';
let chartLoadTimer = null;
// ================================
// NAVIGATION
// ================================
function toggleNav() {
document.getElementById('side-nav').classList.toggle('open');
document.getElementById('side-nav-overlay').classList.toggle('show');
}
document.getElementById('side-nav-overlay')?.addEventListener('click', toggleNav);
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => {
const page = btn.getAttribute('data-page');
switchPage(page);
document.getElementById('side-nav').classList.remove('open');
document.getElementById('side-nav-overlay').classList.remove('show');
});
});
function switchPage(pageName) {
currentPage = pageName;
document.querySelectorAll('.nav-item').forEach(item => {
item.classList.remove('active');
if (item.getAttribute('data-page') === pageName) item.classList.add('active');
});
const sections = [
'databaseStatisticsSection', 'subscriptionDetailsSection', 'div_config',
'authRulesSection', 'wotSection', 'ipBansSection', 'relayEventsSection',
'cachingSection', 'nip17DMSection', 'sqlQuerySection'
];
sections.forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; });
const pageMap = {
'statistics': 'databaseStatisticsSection',
'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config',
'ip-bans': 'ipBansSection',
'relay-events': 'relayEventsSection',
'caching': 'cachingSection',
'dm': 'nip17DMSection',
'database': 'sqlQuerySection'
};
if (pageName === 'authorization') {
document.getElementById('authRulesSection').style.display = 'block';
document.getElementById('wotSection').style.display = 'block';
loadAuthRules();
loadWotStatus();
} else {
const target = pageMap[pageName];
if (target) document.getElementById(target).style.display = 'block';
}
// Load data for the page
const loaders = {
'statistics': loadStats,
'subscriptions': loadSubscriptions,
'configuration': loadConfig,
'ip-bans': loadIpBans,
'relay-events': loadEvents,
'caching': loadCaching,
'dm': loadDMs,
};
if (loaders[pageName]) loaders[pageName]();
// Start/stop auto-refresh for statistics
if (pageName === 'statistics') {
if (!statsInterval) {
loadStats();
statsInterval = setInterval(loadStats, REFRESH_MS);
console.log('[admin2] auto-refresh started, interval:', REFRESH_MS, 'ms');
}
} else {
if (statsInterval) { clearInterval(statsInterval); statsInterval = null; }
}
}
// ================================
// STATISTICS
// ================================
async function loadStats() {
console.log('[admin2] loading stats at', new Date().toLocaleTimeString());
// Fire the RELAY letter animation on every refresh — visual indicator the page is updating
startRelayAnimation();
try {
const res = await fetch('api/stats.php');
if (!res.ok) { console.warn('[admin2] stats.php returned', res.status); return; }
const d = await res.json();
console.log('[admin2] stats loaded — events:', d.total_events, 'rate:', d.events_delta, '/10s');
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
set('db-size', d.db_size || '-');
set('total-events', (d.total_events ?? 0).toLocaleString());
set('process-id', d.process_id || '-');
set('websocket-connections', d.ws_connections ?? '-');
set('active-subscriptions', d.active_subscriptions ?? '-');
set('memory-usage', d.memory_usage || '-');
set('cpu-usage', d.cpu_usage || '-');
set('cpu-core', d.cpu_core || '-');
set('oldest-event', d.oldest_event || '-');
set('newest-event', d.newest_event || '-');
set('events-24h', (d.events_24h ?? 0).toLocaleString());
set('events-7d', (d.events_7d ?? 0).toLocaleString());
set('events-30d', (d.events_30d ?? 0).toLocaleString());
// Kind distribution
if (d.kinds) {
const tbody = document.getElementById('stats-kinds-table-body');
tbody.innerHTML = d.kinds.map(k =>
`<tr><td>${k.kind}</td><td>${k.count.toLocaleString()}</td><td>${k.pct}%</td></tr>`
).join('');
}
// Top pubkeys
if (d.top_pubkeys) {
const tbody = document.getElementById('stats-pubkeys-table-body');
tbody.innerHTML = d.top_pubkeys.map((p, i) =>
`<tr><td>${i+1}</td><td><bdi>${esc(p.name) || '<i>unknown</i>'}</bdi></td><td class="pubkey">${esc(p.pubkey.substring(0,16))}…</td><td>${p.count.toLocaleString()}</td><td>${p.pct}%</td></tr>`
).join('');
}
// Name-field usage stats (from profiles cache)
if (d.name_field_usage && d.name_field_usage.total > 0) {
const u = d.name_field_usage;
const el = document.getElementById('name-field-usage');
if (el) {
el.innerHTML = `
<div class="name-usage-stats">
<span class="name-usage-label">Profile name fields (${u.total} profiles):</span>
<span class="name-usage-item">Both: ${u.both}</span>
<span class="name-usage-item">name only: ${u.name_only}</span>
<span class="name-usage-item">display_name only: ${u.display_only}</span>
<span class="name-usage-item name-usage-differ">Differ: ${u.both_differ}</span>
</div>`;
}
}
// Refresh the chart on every stats poll (server handles caching for non-hour ranges)
loadChart(currentChartRange);
} catch (e) { console.error('[admin2] stats error:', e); }
}
// ================================
// EVENT RATE CHART (server-rendered ASCII via chart.php)
// ================================
// Fetch the ASCII chart from the server and inject it into the div.
// The chart is rendered server-side as plain text — works in both
// browser and terminal (curl http://localhost:8088/api/chart.php?range=hour)
async function loadChart(range) {
const el = document.getElementById('event-rate-chart');
if (!el) return;
try {
const res = await fetch('api/chart.php?range=' + encodeURIComponent(range));
if (!res.ok) { el.textContent = 'Chart load failed (HTTP ' + res.status + ')'; return; }
const text = await res.text();
el.textContent = text;
// Newest data is at the left (index 0) — scroll to start so it's visible
el.scrollLeft = 0;
} catch (e) {
console.error('[admin2] chart load error:', e);
el.textContent = 'Chart load error: ' + e.message;
}
}
// Chart range tab click handlers
document.querySelectorAll('.chart-tab').forEach(tab => {
tab.addEventListener('click', () => {
const range = tab.getAttribute('data-range');
if (!range || range === currentChartRange) return;
currentChartRange = range;
document.querySelectorAll('.chart-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
loadChart(range);
});
});
// ================================
// SUBSCRIPTIONS
// ================================
async function loadSubscriptions() {
try {
const res = await fetch('api/subscriptions.php');
const d = await res.json();
const tbody = document.getElementById('subscription-details-table-body');
if (!d.subscriptions || d.subscriptions.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No subscriptions active</td></tr>';
return;
}
tbody.innerHTML = d.subscriptions.map(s =>
`<tr><td>${esc(s.sub_id) || '-'}</td><td class="pubkey">${esc((s.pubkey||'-').substring(0,16))}</td><td>${esc(s.filters) || '-'}</td><td>${esc(s.created) || '-'}</td><td>${s.events_sent ?? 0}</td></tr>`
).join('');
} catch (e) { console.error('[admin2] subscriptions error:', e); }
}
// ================================
// CONFIGURATION
// ================================
async function loadConfig() {
try {
const res = await fetch('api/config.php');
const d = await res.json();
const tbody = document.getElementById('config-table-body');
if (!d.config || d.config.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;font-style:italic">No config entries</td></tr>';
return;
}
tbody.innerHTML = d.config.map(c =>
`<tr><td>${esc(c.key)}</td><td>${esc(c.value)}</td><td><button onclick="editConfig('${esc(c.key)}')">EDIT</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] config error:', e); }
}
function editConfig(key) {
const newVal = prompt('Enter new value for ' + key + ':');
if (newVal === null) return;
fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key, value: newVal})
}).then(r => r.json()).then(d => {
alert(d.message || 'Saved');
loadConfig();
}).catch(e => alert('Error: ' + e));
}
// ================================
// AUTH RULES
// ================================
async function loadAuthRules() {
try {
const res = await fetch('api/auth.php');
const d = await res.json();
const tbody = document.getElementById('authRulesTableBody');
if (!d.rules || d.rules.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;font-style:italic">No auth rules</td></tr>';
return;
}
tbody.innerHTML = d.rules.map(r =>
`<tr><td>${esc(r.rule_type)}</td><td>${esc(r.pattern_type)}</td><td>${esc(r.pattern_value)}</td><td>${esc(r.status) || 'active'}</td><td><button onclick="removeAuthRule(${r.id})">REMOVE</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] auth error:', e); }
}
async function addAuthRule(type) {
const pk = document.getElementById('authRulePubkey').value.trim();
if (!pk) { alert('Enter a pubkey first'); return; }
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'add', rule_type: type, pattern_value: pk})
});
const d = await res.json();
alert(d.message || 'Done');
loadAuthRules();
}
async function removeAuthRule(id) {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'remove', id})
});
const d = await res.json();
alert(d.message || 'Done');
loadAuthRules();
}
// ================================
// WEB OF TRUST
// ================================
async function loadWotStatus() {
try {
const res = await fetch('api/auth.php?action=wot');
const d = await res.json();
const ind = document.getElementById('wotKind3Indicator');
if (ind) ind.textContent = d.kind3_status || 'Unknown';
const cnt = document.getElementById('wotWhitelistCount');
if (cnt) cnt.textContent = d.whitelist_count ?? '—';
const desc = document.getElementById('wotLevelDescription');
if (desc) desc.textContent = d.level_description || '';
} catch (e) { console.error('[admin2] wot error:', e); }
}
async function setWotLevel(level) {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'set_wot_level', level})
});
const d = await res.json();
alert(d.message || 'Done');
loadWotStatus();
}
async function syncWot() {
const res = await fetch('api/auth.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'sync_wot'})
});
const d = await res.json();
alert(d.message || 'Done');
loadWotStatus();
}
// ================================
// IP BANS
// ================================
async function loadIpBans() {
try {
const res = await fetch('api/ipbans.php');
const d = await res.json();
document.getElementById('ip-bans-total').textContent = d.total ?? '-';
document.getElementById('ip-bans-active').textContent = d.active ?? '-';
document.getElementById('ip-bans-issued').textContent = d.issued ?? '-';
const tbody = document.getElementById('ip-bans-tbody');
if (!d.bans || d.bans.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No IP bans</td></tr>';
return;
}
tbody.innerHTML = d.bans.map(b =>
`<tr><td>${esc(b.ip)}</td><td>${esc(b.status)}</td><td>${esc(b.banned_until)}</td><td>${b.failures ?? 0}</td><td><button onclick="removeBan('${esc(b.ip)}')">REMOVE</button></td></tr>`
).join('');
} catch (e) { console.error('[admin2] ipbans error:', e); }
}
async function addBan() {
const ip = document.getElementById('ban-ip-input').value.trim();
const duration = document.getElementById('ban-duration-select').value;
if (!ip) { alert('Enter an IP address'); return; }
const res = await fetch('api/ipbans.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'add', ip, duration: parseInt(duration)})
});
const d = await res.json();
document.getElementById('add-ban-status').textContent = d.message || 'Done';
loadIpBans();
}
async function removeBan(ip) {
const res = await fetch('api/ipbans.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'remove', ip})
});
const d = await res.json();
alert(d.message || 'Done');
loadIpBans();
}
// ================================
// RELAY EVENTS
// ================================
async function loadEvents() {
try {
const res = await fetch('api/events.php?limit=50');
const d = await res.json();
const tbody = document.getElementById('live-relay-events-table-body');
if (!d.events || d.events.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center">No events</td></tr>';
return;
}
tbody.innerHTML = d.events.map(e =>
`<tr><td>${esc(e.created_at)}</td><td>${e.kind}</td><td><bdi>${esc(e.display_name || (e.pubkey||'').substring(0,16) + '…')}</bdi></td><td class="pubkey">${esc((e.id||'').substring(0,16))}…</td><td>${esc((e.content||'').substring(0,80))}</td></tr>`
).join('');
} catch (err) { console.error('[admin2] events error:', err); }
}
// ================================
// CACHING
// ================================
async function loadCaching() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
// Config toggle state
if (d.config) {
const enabled = d.config.caching_enabled === 'true';
const inboxEnabled = d.config.caching_inbox_enabled === 'true';
const enLabel = document.getElementById('caching-enabled-label');
const enBtn = document.getElementById('caching-toggle-btn');
const inLabel = document.getElementById('caching-inbox-enabled-label');
const inBtn = document.getElementById('caching-inbox-toggle-btn');
if (enLabel) enLabel.textContent = 'Caching: ' + (enabled ? 'ON' : 'OFF');
if (enBtn) enBtn.textContent = enabled ? 'Turn OFF' : 'Turn ON';
if (inLabel) inLabel.textContent = 'Inbox: ' + (inboxEnabled ? 'ON' : 'OFF');
if (inBtn) inBtn.textContent = inboxEnabled ? 'Turn OFF' : 'Turn ON';
}
// Service status
const ssEl = document.getElementById('caching-service-status');
if (ssEl && d.state) {
const s = d.state;
const hb = s.heartbeat_at ? new Date(s.heartbeat_at * 1000).toLocaleTimeString() : '—';
ssEl.innerHTML = `<p>State: <strong>${esc(s.service_state)}</strong> | Follows: ${s.followed_author_count ?? 0} | Connected relays: ${s.connected_relay_count ?? 0}/${s.selected_relay_count ?? 0} | Backfill: ${s.backfill_authors_complete ?? 0}/${s.backfill_authors_total ?? 0} | Events fetched: ${s.events_fetched ?? 0} | Inbox inserts: ${s.inbox_inserts ?? 0} | Heartbeat: ${hb}</p>`;
}
// Inbox status
const isEl = document.getElementById('caching-inbox-status');
if (isEl && d.inbox) {
isEl.innerHTML = `<p>Pending: ${d.inbox.pending} | Live: ${d.inbox.live} | Backfill: ${d.inbox.backfill}</p>`;
}
// Follows table
const tbody = document.getElementById('caching-follows-table-body');
if (tbody && d.follows) {
if (d.follows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;font-style:italic">No followed pubkeys</td></tr>';
} else {
const activePk = d.active && d.active.pubkey ? d.active.pubkey : '';
tbody.innerHTML = d.follows.map((f, i) => {
const npub = f.npub || f.pubkey.substring(0, 20);
const isActive = activePk && f.pubkey === activePk;
const relaySummary = f.relay_incomplete > 0
? `<span class="status-working">${f.relay_count - f.relay_incomplete}/${f.relay_count} done</span>`
: `${f.relay_count ?? 0} relays`;
// Per-relay detail row (hidden by default, toggle via click)
let relayDetail = '';
if (f.relays && f.relays.length > 0) {
relayDetail = '<div class="relay-progress-list">' +
f.relays.map(r => {
const statusIcon = r.complete ? '✓' : '⏳';
const statusBadge = r.complete
? `<span class="relay-status relay-done">${esc(r.last_status || 'eose')}</span>`
: `<span class="relay-status relay-pending">${esc(r.last_status || 'pending')}</span>`;
const relayHost = r.relay_url.replace(/^wss?:\/\//, '').replace(/\/relay$/, '');
return `<div class="relay-progress-row"><span class="relay-icon">${statusIcon}</span><span class="relay-url" title="${esc(r.relay_url)}">${esc(relayHost)}</span><span class="relay-events">${r.events_fetched} evts</span>${statusBadge}</div>`;
}).join('') +
'</div>';
}
const refreshBtn = `<button type="button" class="refresh-user-btn" onclick="event.stopPropagation(); refreshCachingUser('${esc(f.pubkey)}', '${esc(f.name || '')}')">↻ Refresh this user</button>`;
const rowClass = isActive ? 'follows-row follows-row-active' : 'follows-row';
const activeIcon = isActive ? '⚡ ' : '';
return `<tr class="${rowClass}" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display === 'none' ? '' : 'none'"><td><bdi>${activeIcon}${esc(f.name) || '<i>unknown</i>'}</bdi></td><td class="npub-link">${esc(npub)}…</td><td>${f.is_root ? '✓' : ''}</td><td>${f.total_events ?? 0}</td><td>${f.backfill_complete ? '✓' : '…'}</td><td>${relaySummary}</td></tr><tr class="follows-detail" style="display:none"><td colspan="6"><div class="follows-detail-content">${relayDetail || '<i>No relay progress data</i>'}<div class="follows-detail-actions">${refreshBtn}</div></div></td></tr>`;
}).join('');
}
}
// Active target
const fsEl = document.getElementById('caching-follows-status');
if (fsEl) {
if (d.active && d.active.pubkey && d.active.relay) {
fsEl.innerHTML = `<span class="status-working">⚡ Backfilling: ${d.active.pubkey.substring(0,16)}… @ ${esc(d.active.relay)}</span>`;
} else if (d.state && d.state.service_state === 'running') {
const incomplete = d.state.backfill_authors_complete < d.state.backfill_authors_total;
fsEl.innerHTML = incomplete
? `<span class="status-working">⚡ Backfill in progress…</span>`
: `<span class="status-complete">✓ Backfill complete — listening for live events</span>`;
} else {
fsEl.innerHTML = `<span class="status-complete">✓ Caching complete</span>`;
}
}
} catch (e) { console.error('[admin2] caching error:', e); }
}
// Toggle caching_enabled config via the config API.
async function toggleCachingEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_enabled === 'true';
const newVal = current ? 'false' : 'true';
await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_enabled', value: newVal})
});
loadCaching();
} catch (e) { console.error('[admin2] toggle caching error:', e); }
}
// Toggle caching_inbox_enabled config via the config API.
async function toggleCachingInboxEnabled() {
try {
const res = await fetch('api/caching.php');
const d = await res.json();
const current = d.config?.caching_inbox_enabled === 'true';
const newVal = current ? 'false' : 'true';
await fetch('api/config.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({key: 'caching_inbox_enabled', value: newVal})
});
loadCaching();
} catch (e) { console.error('[admin2] toggle inbox error:', e); }
}
// Re-run all caching: resets backfill progress for all followed authors and
// bumps caching_config_generation so the running service hot-reloads and
// re-drains from the beginning.
async function rerunAllCaching() {
if (!confirm('Reset backfill progress for ALL followed authors? The caching service will re-download everything from scratch.')) return;
const btn = document.getElementById('caching-rerun-all-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Resetting…'; }
try {
const res = await fetch('api/caching.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'reset_all'})
});
const d = await res.json();
if (d.ok) {
if (btn) { btn.textContent = '✓ Reset — re-draining'; }
setTimeout(() => { if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; } loadCaching(); }, 2000);
} else {
alert('Reset failed: ' + (d.error || 'unknown error'));
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
}
} catch (e) {
console.error('[admin2] rerunAllCaching error:', e);
alert('Reset failed: ' + e.message);
if (btn) { btn.disabled = false; btn.textContent = 'Re-run All Caching'; }
}
}
// Refresh a single user: resets backfill progress for one followed author
// and bumps caching_config_generation so the service re-fetches that author.
async function refreshCachingUser(pubkey, name) {
if (!confirm('Reset backfill progress for ' + (name || pubkey.substring(0, 16) + '…') + '? The caching service will re-download this user\'s events from scratch.')) return;
try {
const res = await fetch('api/caching.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'reset_user', pubkey: pubkey})
});
const d = await res.json();
if (d.ok) {
loadCaching();
} else {
alert('Refresh failed: ' + (d.error || 'unknown error'));
}
} catch (e) {
console.error('[admin2] refreshCachingUser error:', e);
alert('Refresh failed: ' + e.message);
}
}
// ================================
// DMs
// ================================
async function loadDMs() {
try {
const res = await fetch('api/dm.php?limit=50');
const d = await res.json();
const el = document.getElementById('dm-inbox');
if (!d.messages || d.messages.length === 0) {
el.innerHTML = '<div class="log-entry">No messages found.</div>';
return;
}
el.innerHTML = d.messages.map(m =>
`<div class="log-entry"><strong>kind ${m.kind}</strong> from <span class="pubkey">${esc((m.pubkey||'').substring(0,16))}…</span> at ${esc(m.created_at)}:<br>${esc((m.content||'').substring(0,200))}</div>`
).join('');
} catch (e) { console.error('[admin2] dm error:', e); }
}
// ================================
// SQL QUERY
// ================================
async function executeQuery() {
const sql = document.getElementById('sql-input').value.trim();
if (!sql) { alert('Enter a SQL query'); return; }
if (!sql.toUpperCase().startsWith('SELECT')) { alert('Only SELECT queries are allowed'); return; }
try {
const res = await fetch('api/query.php', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({sql})
});
const d = await res.json();
const info = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (d.error) {
info.textContent = 'Error: ' + d.error;
tableDiv.innerHTML = '';
return;
}
info.textContent = `${d.row_count} rows in ${d.time_ms}ms`;
if (d.rows && d.rows.length > 0) {
const cols = Object.keys(d.rows[0]);
let html = '<table class="config-table"><thead><tr>';
cols.forEach(c => html += `<th>${c}</th>`);
html += '</tr></thead><tbody>';
d.rows.forEach(r => {
html += '<tr>';
cols.forEach(c => html += `<td>${r[c] ?? ''}</td>`);
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p>No rows returned.</p>';
}
} catch (e) { console.error('[admin2] query error:', e); }
}
// ================================
// DARK MODE
// ================================
document.getElementById('nav-dark-mode-btn')?.addEventListener('click', () => {
document.body.classList.toggle('dark-mode');
localStorage.setItem('admin2-dark-mode', document.body.classList.contains('dark-mode'));
const btn = document.getElementById('nav-dark-mode-btn');
if (btn) btn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
});
if (localStorage.getItem('admin2-dark-mode') === 'true') {
document.body.classList.add('dark-mode');
const btn = document.getElementById('nav-dark-mode-btn');
if (btn) btn.textContent = 'LIGHT MODE';
}
// ================================
// RELAY LETTER ANIMATION
// ================================
// Animate the RELAY letters with an underline sweep. Fires on every stats
// refresh so the user gets a visual cue that the page is updating.
function startRelayAnimation() {
const letters = document.querySelectorAll('.relay-letter');
if (letters.length === 0) return;
// Cancel any in-flight animation so rapid refreshes don't overlap
if (relayAnimationTimer) { clearTimeout(relayAnimationTimer); relayAnimationTimer = null; }
let currentIndex = 0;
letters.forEach(l => l.classList.remove('underlined'));
function animateLetter() {
letters.forEach(letter => letter.classList.remove('underlined'));
if (letters[currentIndex]) {
letters[currentIndex].classList.add('underlined');
}
currentIndex++;
if (currentIndex > letters.length) {
// Sweep complete — clear underlines and pause before next refresh
letters.forEach(letter => letter.classList.remove('underlined'));
relayAnimationTimer = null;
return;
}
relayAnimationTimer = setTimeout(animateLetter, 100);
}
animateLetter();
}
// ================================
// RELAY PUBKEY COPY-TO-CLIPBOARD
// ================================
document.getElementById('relay-pubkey-container')?.addEventListener('click', async () => {
const el = document.getElementById('relay-pubkey');
if (!el || !el.textContent.trim()) return;
try {
await navigator.clipboard.writeText(el.textContent.replace(/\s+/g, ''));
const container = document.getElementById('relay-pubkey-container');
container.classList.add('copied');
setTimeout(() => container.classList.remove('copied'), 500);
} catch (e) { console.warn('[admin2] clipboard copy failed:', e); }
});
// ================================
// AUTH — nostr_login_lite modal
// ================================
const loginModal = document.getElementById('login-modal');
const loginModalContainer = document.getElementById('login-modal-container');
const profileArea = document.getElementById('profile-area');
const headerUserName = document.getElementById('header-user-name');
const headerUserImage = document.getElementById('header-user-image');
const logoutDropdown = document.getElementById('logout-dropdown');
function showLoginModal() {
if (loginModal && loginModalContainer) {
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
// Clear previous embed before re-embedding
loginModalContainer.innerHTML = '';
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', { seamless: true });
}
loginModal.style.display = 'flex';
}
}
function hideLoginModal() {
if (loginModal) loginModal.style.display = 'none';
}
function showProfileInHeader() {
if (profileArea) profileArea.style.display = 'flex';
}
function hideProfileFromHeader() {
if (profileArea) profileArea.style.display = 'none';
}
// Toggle logout dropdown when clicking the profile area
profileArea?.addEventListener('click', (e) => {
// Only toggle if the click wasn't on the logout button itself
if (e.target.closest('.logout-btn')) return;
if (logoutDropdown) {
logoutDropdown.style.display = (logoutDropdown.style.display === 'none' || !logoutDropdown.style.display) ? 'block' : 'none';
}
});
// Hide logout dropdown when clicking elsewhere
document.addEventListener('click', (e) => {
if (logoutDropdown && logoutDropdown.style.display === 'block' && !e.target.closest('#profile-area')) {
logoutDropdown.style.display = 'none';
}
});
// Update header profile display from logged-in user's pubkey.
// Sets a placeholder from the npub immediately, then fetches the kind 0
// profile event from public relays to populate the name + picture.
function updateProfileDisplay(pubkey) {
if (!pubkey) return;
let npub = '';
try {
if (pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey)) {
npub = window.NostrTools.nip19.npubEncode(pubkey);
}
} catch (err) { console.warn('[admin2] npub encode failed:', err); }
// Placeholder until the profile fetch resolves
if (headerUserName) {
headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : pubkey.substring(0, 16) + '…';
}
if (headerUserImage) headerUserImage.style.display = 'none';
// Fetch kind 0 profile from public relays (same approach as original api page)
loadUserProfile(pubkey, npub);
}
// Apply profile data to the header name + profile picture.
// Uses best_name from the server (resolved per profile_name_preference).
function applyProfileToHeader(name, picture) {
if (headerUserName) headerUserName.textContent = name || 'Anonymous User';
if (headerUserImage && picture && typeof picture === 'string' &&
(picture.startsWith('http://') || picture.startsWith('https://'))) {
headerUserImage.src = picture;
headerUserImage.style.display = 'block';
headerUserImage.onerror = function() { this.style.display = 'none'; };
} else if (headerUserImage) {
headerUserImage.style.display = 'none';
}
}
// Fetch the user's profile. Tries the local profiles cache first
// (admin/api/profile.php), then falls back to public relays if the
// relay has no cached kind-0 for this pubkey.
async function loadUserProfile(pubkey, npub) {
if (!pubkey) return;
// Try local profiles cache first.
try {
const res = await fetch('api/profile.php?pubkey=' + encodeURIComponent(pubkey));
if (res.ok) {
const profile = await res.json();
if (profile && profile.best_name !== undefined) {
applyProfileToHeader(profile.best_name, profile.picture);
console.log('[admin2] profile loaded from local cache for', profile.best_name);
return;
}
}
} catch (e) {
// Local endpoint not available — fall through to public relays.
}
// Fall back to public relays.
if (!window.NostrTools || !window.NostrTools.SimplePool) {
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Anonymous User';
return;
}
const relays = [
'wss://relay.damus.io',
'wss://relay.nostr.band',
'wss://nos.lol',
'wss://relay.primal.net',
'wss://relay.snort.social'
];
try {
const pool = new window.NostrTools.SimplePool();
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
);
const queryPromise = pool.querySync(relays, {
kinds: [0],
authors: [pubkey],
limit: 1
});
const events = await Promise.race([queryPromise, timeoutPromise]);
try { await pool.close(relays); } catch (e) {}
if (events && events.length > 0) {
const profile = JSON.parse(events[0].content);
// Use best_name resolution: display_name first, then name.
const name = profile.display_name || profile.name || profile.displayName || 'Anonymous User';
const picture = profile.picture || profile.image || null;
applyProfileToHeader(name, picture);
console.log('[admin2] profile loaded from public relays for', name);
} else {
if (headerUserName) headerUserName.textContent = 'Anonymous User';
console.log('[admin2] no profile event found for', pubkey);
}
} catch (err) {
console.warn('[admin2] profile load failed:', err.message);
if (headerUserName) headerUserName.textContent = npub ? npub.substring(0, 16) + '…' : 'Error loading profile';
}
}
// Initialize nostr_login_lite and show modal if not already authenticated
async function initializeAuth() {
if (!window.NOSTR_LOGIN_LITE) {
console.warn('[admin2] NOSTR_LOGIN_LITE not loaded — skipping auth modal');
return;
}
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
otp: false
},
floatingTab: { enabled: false }
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('[admin2] nostr_login_lite initialized');
// Check for existing auth state
let alreadyLoggedIn = false;
try {
const stored = localStorage.getItem('nostr_login_lite_auth');
if (stored) {
const parsed = JSON.parse(stored);
if (parsed && parsed.pubkey) {
userPubkey = parsed.pubkey;
isLoggedIn = true;
alreadyLoggedIn = true;
showProfileInHeader();
updateProfileDisplay(userPubkey);
hideLoginModal();
console.log('[admin2] existing auth restored for', userPubkey);
}
}
} catch (e) { /* no stored auth */ }
if (!alreadyLoggedIn) {
console.log('[admin2] no existing auth — showing login modal');
showLoginModal();
}
// Listen for auth events
window.addEventListener('nlMethodSelected', (event) => {
const { pubkey, method, error } = event.detail || {};
if (method && pubkey) {
userPubkey = pubkey;
isLoggedIn = true;
console.log('[admin2] login success via', method, pubkey);
hideLoginModal();
showProfileInHeader();
updateProfileDisplay(pubkey);
} else if (error) {
console.warn('[admin2] auth error:', error);
}
});
window.addEventListener('nlLogout', () => {
console.log('[admin2] logout event received');
userPubkey = null;
isLoggedIn = false;
hideProfileFromHeader();
if (logoutDropdown) logoutDropdown.style.display = 'none';
showLoginModal();
});
} catch (err) {
console.error('[admin2] nostr_login_lite init failed:', err);
}
}
// Logout function — clears auth state and re-shows login modal
async function logout() {
console.log('[admin2] logging out...');
try {
if (nlLite && typeof nlLite.logout === 'function') {
await nlLite.logout();
}
} catch (e) { console.warn('[admin2] nlLite.logout error:', e); }
userPubkey = null;
isLoggedIn = false;
hideProfileFromHeader();
if (logoutDropdown) logoutDropdown.style.display = 'none';
showLoginModal();
console.log('[admin2] logged out');
}
// ================================
// INIT
// ================================
switchPage('statistics');
// Start the RELAY animation immediately on page load
startRelayAnimation();
// Initialize auth + load initial chart on DOM ready
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeAuth, 100);
// Load the default (hour) chart immediately
loadChart(currentChartRange);
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
New Events — Last 24 Hours
11 | X
10 | XX
9 | XX
8 | XX
7 | XX X X
6 | XX X X
5 | XX X X
4 | X XX X XX
3 | X XXX X XX
2 | X X X XXX X XX
1 | X XX X X X X X X X X X X X X XX X X XXXX X X X X X XX
+--------------------------------------------------------------------------------
0s 1h 3h 4h 6h 7h 9h 10h 12h 13h 15h 16h 18h 19h 21h 22h
+15
View File
@@ -0,0 +1,15 @@
New Events — Last 30 Days
81 | X
73 | XX X
65 | XX X
57 | XX X X X
49 | X X XX XX X X X
41 | X X X XX X X X XXX XXX XXX X X X X
33 | X X X XXXX XX X X X XX XXX XXXXXXXX XX X X X X X XX
25 |X XX XX XX XXXX XX XXX X XX XXXXXXXXXXXXXXXXXXXX XX XXX X XX XX XXXXX
17 |X XXXXX XX XXXXXXXXXXXXXXX X XX XXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXX XX XXXXXXXX
9 |XXX XXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+--------------------------------------------------------------------------------
0s 1d 3d 5d 7d 9d 11d 13d 15d 16d 18d 20d 22d 24d 26d 28d
+15
View File
@@ -0,0 +1,15 @@
New Events — Last Year
531 | X XX
478 | X X X XXX X
425 | X X XX X XX XXX X
372 | XXX XX X XX XXX X
319 |XXXXXXXXXXX X X X XXXXXXX X
266 |XXXXXXXXXXXX X XX XXX XXXXXXXXX X
213 |XXXXXXXXXXXXXX XXX XXXXXXXXXXXXX XX X
160 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX X X XXXX X
107 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XX XX XX X X X XX XXX X XXXXXXX X
54 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXX
1 |XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
+--------------------------------------------------------------------------------
0s 22d 45d 68d 91d 114d 136d 159d 182d 205d 228d 250d 273d 296d 319d 342d
+429
View File
@@ -0,0 +1,429 @@
<?php
/**
* admin2 — Full PHP admin for C-Relay-PG.
* Recreates the original api/ admin interface with a PHP/PDO backend
* instead of Nostr WebSocket admin commands.
*/
require_once __DIR__ . '/lib/helpers.php';
$pdo = db();
// Fetch relay info for the header (from config table)
$relay_name = '';
$relay_desc = '';
$relay_pubkey = '';
$relay_version = '';
try {
$relay_name = $pdo->query("SELECT value FROM config WHERE key = 'relay_name'")->fetchColumn() ?: 'C-Relay-PG';
$relay_desc = $pdo->query("SELECT value FROM config WHERE key = 'relay_description'")->fetchColumn() ?: '';
$relay_pubkey = $pdo->query("SELECT value FROM config WHERE key = 'relay_pubkey'")->fetchColumn() ?: '';
$relay_version = $pdo->query("SELECT value FROM config WHERE key = 'relay_version'")->fetchColumn() ?: '';
} catch (PDOException $e) {}
// Convert relay pubkey (hex) to npub and format into 3 lines of 3 groups of 7 chars
$relay_npub = $relay_pubkey;
if ($relay_pubkey && preg_match('/^[0-9a-fA-F]{64}$/', $relay_pubkey)) {
$relay_npub = hex_to_npub($relay_pubkey);
}
// Format npub (63 chars) into 3 lines of "xxxxxxx xxxxxxx xxxxxxx"
$formatted_npub = $relay_npub;
if (strlen($relay_npub) === 63) {
$line1 = substr($relay_npub, 0, 7) . ' ' . substr($relay_npub, 7, 7) . ' ' . substr($relay_npub, 14, 7);
$line2 = substr($relay_npub, 21, 7) . ' ' . substr($relay_npub, 28, 7) . ' ' . substr($relay_npub, 35, 7);
$line3 = substr($relay_npub, 42, 7) . ' ' . substr($relay_npub, 49, 7) . ' ' . substr($relay_npub, 56, 7);
$formatted_npub = $line1 . "\n" . $line2 . "\n" . $line3;
}
$display_name = $relay_version ? ($relay_name . ' ' . $relay_version) : $relay_name;
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C-Relay-PG Admin</title>
<link rel="stylesheet" href="assets/index.css">
</head>
<body>
<!-- Side Navigation Menu -->
<nav class="side-nav" id="side-nav">
<ul class="nav-menu">
<li><button class="nav-item" data-page="statistics">Statistics</button></li>
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="ip-bans">IP BAN</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="caching">Caching</button></li>
<li><button class="nav-item" data-page="dm">DM</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
</ul>
<div class="nav-footer">
<button class="nav-footer-btn" id="nav-dark-mode-btn">DARK MODE</button>
</div>
</nav>
<!-- Side Navigation Overlay -->
<div class="side-nav-overlay" id="side-nav-overlay"></div>
<!-- Header with title and profile display -->
<div class="section">
<div class="header-content">
<div class="header-title clickable" id="header-title" onclick="toggleNav()">
<span class="relay-letter" data-letter="R">R</span>
<span class="relay-letter" data-letter="E">E</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="A">A</span>
<span class="relay-letter" data-letter="Y">Y</span>
</div>
<div class="relay-info">
<div id="relay-name" class="relay-name"><?= e($display_name) ?></div>
<div id="relay-description" class="relay-description"><?= e($relay_desc) ?></div>
<div id="relay-pubkey-container" class="relay-pubkey-container" title="Click to copy npub">
<div id="relay-pubkey" class="relay-pubkey"><?= e($formatted_npub) ?></div>
</div>
</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="admin-label">admin</div>
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
</div>
<!-- Logout dropdown (under admin picture) -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" class="logout-btn" onclick="logout()">LOGOUT</button>
</div>
</div>
</div>
</div>
<!-- Login Modal Overlay (nostr_login_lite) -->
<div id="login-modal" class="login-modal-overlay" style="display: none;">
<div class="login-modal-content">
<div id="login-modal-container"></div>
</div>
</div>
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
<div class="access-denied-content">
<div class="access-denied-icon">⛔</div>
<h2>ACCESS DENIED</h2>
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
</div>
</div>
<!-- DATABASE STATISTICS Section -->
<div class="section flex-section" id="databaseStatisticsSection">
<div class="section-header">DATABASE STATISTICS</div>
<!-- Event Rate Graph — range selector tabs + ASCII chart (server-rendered) -->
<div class="chart-range-tabs">
<button class="chart-tab active" data-range="hour">1H</button>
<button class="chart-tab" data-range="day">1D</button>
<button class="chart-tab" data-range="month">1M</button>
<button class="chart-tab" data-range="year">1Y</button>
</div>
<div id="event-rate-chart" class="event-rate-chart-container">Loading chart...</div>
<!-- Database Overview Table -->
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="stats-overview-table">
<thead><tr><th>Metric</th><th>Value</th></tr></thead>
<tbody id="stats-overview-table-body">
<tr><td>Database Size</td><td id="db-size">-</td></tr>
<tr><td>Total Events</td><td id="total-events">-</td></tr>
<tr><td>Process ID</td><td id="process-id">-</td></tr>
<tr><td>WebSocket Connections</td><td id="websocket-connections">-</td></tr>
<tr><td>Active Subscriptions</td><td id="active-subscriptions">-</td></tr>
<tr><td>Memory Usage</td><td id="memory-usage">-</td></tr>
<tr><td>CPU Usage</td><td id="cpu-usage">-</td></tr>
<tr><td>CPU Core</td><td id="cpu-core">-</td></tr>
<tr><td>Oldest Event</td><td id="oldest-event">-</td></tr>
<tr><td>Newest Event</td><td id="newest-event">-</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Event Kind Distribution Table -->
<div class="input-group">
<label>Event Kind Distribution:</label>
<div class="config-table-container">
<table class="config-table" id="stats-kinds-table">
<thead><tr><th>Event Kind</th><th>Count</th><th>Percentage</th></tr></thead>
<tbody id="stats-kinds-table-body">
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Time-based Statistics Table -->
<div class="input-group">
<label>Time-based Statistics:</label>
<div class="config-table-container">
<table class="config-table" id="stats-time-table">
<thead><tr><th>Period</th><th>Events</th></tr></thead>
<tbody id="stats-time-table-body">
<tr><td>Last 24 Hours</td><td id="events-24h">-</td></tr>
<tr><td>Last 7 Days</td><td id="events-7d">-</td></tr>
<tr><td>Last 30 Days</td><td id="events-30d">-</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Top Pubkeys Table -->
<div class="input-group">
<label>Top Pubkeys by Event Count:</label>
<div class="config-table-container">
<table class="config-table" id="stats-pubkeys-table">
<thead><tr><th>Rank</th><th>Name</th><th>Pubkey</th><th>Event Count</th><th>Percentage</th></tr></thead>
<tbody id="stats-pubkeys-table-body">
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- Name-field usage stats (from profiles cache) -->
<div class="input-group" id="name-field-usage" style="display:none;"></div>
</div>
<!-- SUBSCRIPTION DETAILS Section -->
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
<div class="section-header">ACTIVE SUBSCRIPTION DETAILS</div>
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="subscription-details-table">
<thead><tr><th>Sub ID</th><th>Pubkey</th><th>Filters</th><th>Created</th><th>Events Sent</th></tr></thead>
<tbody id="subscription-details-table-body">
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- CONFIGURATION Section -->
<div id="div_config" class="section flex-section" style="display: none;">
<div class="section-header">RELAY CONFIGURATION</div>
<div id="config-display">
<div class="config-table-container">
<table class="config-table" id="config-table">
<thead><tr><th>Parameter</th><th>Value</th><th>Actions</th></tr></thead>
<tbody id="config-table-body">
<tr><td colspan="3" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
<div class="inline-buttons">
<button type="button" id="fetch-config-btn" onclick="loadConfig()">REFRESH</button>
</div>
</div>
</div>
<!-- AUTH RULES Section -->
<div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header">AUTH RULES MANAGEMENT</div>
<div id="authRulesTableContainer">
<table class="config-table" id="authRulesTable">
<thead><tr><th>Rule Type</th><th>Pattern Type</th><th>Pattern Value</th><th>Status</th><th>Actions</th></tr></thead>
<tbody id="authRulesTableBody">
<tr><td colspan="5" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
<div id="authRuleInputSections" style="display: block;">
<div class="input-group">
<label for="authRulePubkey">Public Key (npub or hex):</label>
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
</div>
<div class="inline-buttons">
<button type="button" onclick="addAuthRule('whitelist')">ADD TO WHITELIST</button>
<button type="button" onclick="addAuthRule('blacklist')">ADD TO BLACKLIST</button>
<button type="button" onclick="loadAuthRules()">REFRESH</button>
</div>
</div>
</div>
<!-- WEB OF TRUST Section -->
<div class="section flex-section" id="wotSection" style="display: none;">
<div class="section-header">WEB OF TRUST</div>
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Loading...</span>
</div>
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" class="wot-level-btn" onclick="setWotLevel(0)">OFF</button>
<button type="button" class="wot-level-btn" onclick="setWotLevel(1)">WRITE ONLY</button>
<button type="button" class="wot-level-btn" onclick="setWotLevel(2)">FULL</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">Loading...</div>
</div>
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount">—</span>
</div>
<div class="inline-buttons">
<button type="button" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
<!-- NIP-17 DIRECT MESSAGES Section -->
<div class="section" id="nip17DMSection" style="display: none;">
<div class="section-header"><h2>NIP-17 DIRECT MESSAGES</h2></div>
<div class="input-group">
<label>Received Messages (kind 4/14/15):</label>
<div id="dm-inbox" class="log-panel" style="height: 300px; overflow-y: auto;">
<div class="log-entry">Loading...</div>
</div>
</div>
</div>
<!-- IP BANS Section -->
<div class="section" id="ipBansSection" style="display: none;">
<div class="section-header">IP BAN MANAGEMENT</div>
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="ip-bans-stats-table">
<thead><tr><th>Total IPs Tracked</th><th>Currently Banned</th><th>Total Bans Issued</th></tr></thead>
<tbody>
<tr><td id="ip-bans-total">-</td><td id="ip-bans-active">-</td><td id="ip-bans-issued">-</td></tr>
</tbody>
</table>
</div>
</div>
<div class="input-group">
<h3>Manually Ban IP Address</h3>
<div class="form-group">
<label for="ban-ip-input">IP Address:</label>
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label for="ban-duration-select">Ban Duration:</label>
<select id="ban-duration-select">
<option value="3600">1 Hour</option>
<option value="86400" selected>24 Hours</option>
<option value="604800">7 Days</option>
<option value="2592000">30 Days</option>
<option value="31536000">1 Year</option>
<option value="999999999">Permanent</option>
</select>
</div>
<div class="inline-buttons">
<button type="button" onclick="addBan()">BAN IP</button>
<button type="button" onclick="loadIpBans()">REFRESH</button>
</div>
<div id="add-ban-status" class="status-message"></div>
</div>
<div class="input-group">
<label>Banned IP Addresses:</label>
<div class="config-table-container">
<table class="config-table" id="ip-bans-table">
<thead><tr><th>IP Address</th><th>Status</th><th>Banned Until</th><th>Failures</th><th>Actions</th></tr></thead>
<tbody id="ip-bans-tbody">
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- RELAY EVENTS Section -->
<div class="section" id="relayEventsSection" style="display: none;">
<div class="section-header">RELAY EVENTS MANAGEMENT</div>
<div class="input-group">
<h3>Recent Events</h3>
<div class="inline-buttons">
<button type="button" onclick="loadEvents()">REFRESH</button>
</div>
<div class="config-table-container">
<table class="config-table" id="live-relay-events-table">
<thead><tr><th>Time</th><th>Kind</th><th>Name</th><th>ID</th><th>Content Preview</th></tr></thead>
<tbody id="live-relay-events-table-body">
<tr><td colspan="5" style="text-align: center;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- CACHING Section -->
<div class="section" id="cachingSection" style="display: none;">
<div class="section-header">CACHING</div>
<div class="input-group">
<h3>Caching Controls</h3>
<div id="caching-controls" class="inline-buttons">
<span id="caching-enabled-label">Caching: ?</span>
<button type="button" id="caching-toggle-btn" onclick="toggleCachingEnabled()">Toggle Caching</button>
<span id="caching-inbox-enabled-label">Inbox: ?</span>
<button type="button" id="caching-inbox-toggle-btn" onclick="toggleCachingInboxEnabled()">Toggle Inbox</button>
</div>
</div>
<div class="input-group">
<h3>Caching Service Status</h3>
<div id="caching-service-status" class="status-display">
<p>Loading...</p>
</div>
<div class="inline-buttons" style="margin-top:8px">
<button type="button" id="caching-rerun-all-btn" onclick="rerunAllCaching()">Re-run All Caching</button>
</div>
</div>
<div class="input-group">
<h3>Relay Inbox Status</h3>
<div id="caching-inbox-status" class="status-display">
<p>Loading...</p>
</div>
</div>
<div class="input-group">
<h3>Followed Pubkeys <span style="font-size:11px;font-weight:normal;color:var(--text-muted,#888)">(click a row for per-relay details)</span></h3>
<div id="caching-follows-status" class="status-message"></div>
<div class="config-table-container">
<table class="config-table" id="caching-follows-table">
<thead><tr><th>Name</th><th>npub</th><th>Root?</th><th>Events in DB</th><th>Backfill</th><th>Relays</th></tr></thead>
<tbody id="caching-follows-table-body">
<tr><td colspan="6" style="text-align: center; font-style: italic;">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="inline-buttons">
<button type="button" onclick="loadCaching()">REFRESH</button>
</div>
</div>
<!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header"><h2>SQL QUERY CONSOLE</h2></div>
<div class="input-group">
<label for="sql-input">SQL Query (SELECT only):</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
</div>
<div class="input-group">
<div class="inline-buttons">
<button type="button" onclick="executeQuery()">EXECUTE QUERY</button>
<button type="button" onclick="document.getElementById('sql-input').value=''">CLEAR</button>
</div>
</div>
<div class="input-group">
<label>Query Results:</label>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="config-table-container"></div>
</div>
</div>
<!-- Nostr libraries for nostr_login_lite auth modal -->
<script src="assets/nostr.bundle.js"></script>
<script src="assets/nostr-lite.js"></script>
<script src="assets/app.js"></script>
</body>
</html>
+147
View File
@@ -0,0 +1,147 @@
<?php
/**
* admin2/lib/ascii_chart.php — Server-side ASCII X-bar chart renderer.
*
* Produces a monospaced ASCII bar chart (using 'X' characters) from an
* array of bin counts. Mirrors the layout of the original text_graph.js
* ASCIIBarChart: Y-axis count labels, X-bar columns, X-axis with
* elapsed-time labels.
*
* The output is a plain text string suitable for both browser <div>
* injection (white-space: pre) and terminal display via curl.
*/
/**
* Render an ASCII bar chart from an array of bin counts.
*
* @param array $bins Array of integer counts (one per time bin, left=oldest)
* @param array $options {
* @var string $title Chart title (centered at top)
* @var int $max_height Chart height in rows (default 11)
* @var string $x_axis_label Label below the X-axis (default '')
* @var int $bin_duration Seconds per bin — for X-axis elapsed labels
* @var int $label_interval Label every N bins (default 5)
* }
* @return string The ASCII chart as a multi-line string.
*/
function render_ascii_chart(array $bins, array $options = []): string {
$title = $options['title'] ?? 'New Events';
$max_height = $options['max_height'] ?? 11;
$x_axis_label = $options['x_axis_label'] ?? '';
$bin_duration = $options['bin_duration'] ?? 10;
$label_interval = $options['label_interval'] ?? 5;
$num_bins = count($bins);
if ($num_bins === 0) {
return "No data available.\n";
}
$max_count = max($bins);
if ($max_count < 1) $max_count = 1; // Avoid division by zero for all-empty bins
// Scaling: each X represents scale_factor counts
$scale_factor = max(1, (int)ceil($max_count / $max_height));
$scaled_max = (int)ceil($max_count / $scale_factor) * $scale_factor;
$output = '';
// --- Title (centered) ---
$chart_width = 4 + $num_bins; // 4 = Y-axis number width (3) + separator (1)
if ($title !== '') {
$title_padding = (int)floor(($chart_width - strlen($title)) / 2);
if ($title_padding < 0) $title_padding = 0;
$output .= str_repeat(' ', $title_padding) . $title . "\n\n";
}
// --- Bar rows (top to bottom) ---
for ($row = $max_height; $row > 0; $row--) {
$row_count = ($row - 1) * $scale_factor + 1;
$line = str_pad((string)$row_count, 3, ' ', STR_PAD_LEFT) . ' |';
for ($i = 0; $i < $num_bins; $i++) {
$count = $bins[$i];
$scaled_height = ($count > 0) ? (int)ceil($count / $scale_factor) : 0;
$line .= ($scaled_height >= $row) ? 'X' : ' ';
}
$output .= $line . "\n";
}
// --- X-axis line ---
$output .= ' +' . str_repeat('-', $num_bins) . "\n";
// --- X-axis labels (elapsed time every label_interval bins) ---
$label_line = ' ';
$labels = [];
for ($i = 0; $i < $num_bins; $i++) {
if ($i % $label_interval === 0) {
$elapsed_sec = $i * $bin_duration;
$labels[] = format_elapsed_time($elapsed_sec);
}
}
// Build label line with spacing
for ($i = 0; $i < count($labels); $i++) {
$label_line .= $labels[$i];
if ($i < count($labels) - 1) {
$spacing = $label_interval - strlen($labels[$i]);
if ($spacing < 1) $spacing = 1;
$label_line .= str_repeat(' ', $spacing);
}
}
// Pad to match X-axis dash line length
$min_label_len = 4 + $num_bins;
if (strlen($label_line) < $min_label_len) {
$label_line .= str_repeat(' ', $min_label_len - strlen($label_line));
}
$output .= $label_line . "\n";
// --- X-axis label (if provided) ---
if ($x_axis_label !== '') {
$label_pad = (int)floor(($num_bins - strlen($x_axis_label)) / 2);
if ($label_pad < 0) $label_pad = 0;
$output .= "\n" . ' ' . str_repeat(' ', $label_pad) . $x_axis_label . "\n";
}
return $output;
}
/**
* Format an elapsed time in seconds as a compact label.
* < 60s → "Ns"
* < 1h → "Nm"
* < 1d → "Nh"
* else → "Nd"
*/
function format_elapsed_time(int $seconds): string {
if ($seconds < 60) {
return $seconds . 's';
} elseif ($seconds < 3600) {
return (int)floor($seconds / 60) . 'm';
} elseif ($seconds < 86400) {
return (int)floor($seconds / 3600) . 'h';
} else {
return (int)floor($seconds / 86400) . 'd';
}
}
/**
* Build a fixed-length bin array from SQL query rows.
*
* The query returns only non-empty bins. This function creates a
* zero-filled array of $num_bins length and overlays the counts
* at the correct positions, so empty time slots show as blank
* columns — the chart always advances in time.
*
* @param array $rows Query rows with 'bin' (int) and 'cnt' (int)
* @param int $num_bins Total number of bins (fixed length)
* @return array Zero-filled array of counts
*/
function build_bin_array(array $rows, int $num_bins): array {
$bins = array_fill(0, $num_bins, 0);
foreach ($rows as $r) {
$idx = (int)$r['bin'];
if ($idx >= 0 && $idx < $num_bins) {
$bins[$idx] = (int)$r['cnt'];
}
}
return $bins;
}
+27
View File
@@ -0,0 +1,27 @@
<?php
/**
* C-Relay-PG Admin — Database connection configuration.
*
* This file is NOT web-accessible (denied by nginx location block).
* It contains the PostgreSQL connection string used by all admin pages.
*
* Edit these values to match your relay's PostgreSQL credentials.
*/
// PostgreSQL connection parameters.
// These should match the --db-* flags passed to the c-relay-pg binary
// or the connection string passed to caching_relay -p "...".
return [
'db_host' => 'localhost',
'db_port' => '5432',
'db_name' => 'crelay',
'db_user' => 'crelay',
'db_password' => 'crelay',
// Page sizes for paginated tables.
'follows_per_page' => 50,
'relays_per_page' => 50,
// Auto-refresh interval for dashboard polling (milliseconds).
'refresh_ms' => 10000,
];
+48
View File
@@ -0,0 +1,48 @@
<?php
/**
* C-Relay-PG Admin — PDO database connection helper.
*
* Provides a singleton PDO connection to the relay's PostgreSQL database.
* All admin pages use db() to get the connection.
*/
require_once __DIR__ . '/config.php';
function db(): PDO {
static $pdo = null;
if ($pdo === null) {
$cfg = require __DIR__ . '/config.php';
// Build DSN — supports both TCP (host=localhost) and Unix socket
// (host=/var/run/postgresql). Omit password if empty (peer auth).
$dsn = sprintf(
'pgsql:host=%s;port=%d;dbname=%s;user=%s',
$cfg['db_host'],
$cfg['db_port'],
$cfg['db_name'],
$cfg['db_user']
);
if (!empty($cfg['db_password'])) {
$dsn .= ';password=' . $cfg['db_password'];
}
try {
$pdo = new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
http_response_code(500);
die('Database connection failed: ' . htmlspecialchars($e->getMessage()));
}
}
return $pdo;
}
/** Get a config value from the admin config file. */
function cfg(string $key, $default = null) {
static $cfg = null;
if ($cfg === null) {
$cfg = require __DIR__ . '/config.php';
}
return $cfg[$key] ?? $default;
}
+247
View File
@@ -0,0 +1,247 @@
<?php
/**
* C-Relay-PG Admin — Shared helper functions.
*/
require_once __DIR__ . '/db.php';
/** HTML-escape a string. */
function e(?string $s): string {
return htmlspecialchars($s ?? '', ENT_QUOTES, 'UTF-8');
}
/** Format a Unix timestamp as a human-readable relative time. */
function time_ago(int $ts): string {
if ($ts <= 0) return 'never';
$diff = time() - $ts;
if ($diff < 60) return $diff . 's ago';
if ($diff < 3600) return floor($diff / 60) . 'm ago';
if ($diff < 86400) return floor($diff / 3600) . 'h ago';
return floor($diff / 86400) . 'd ago';
}
/** Format a Unix timestamp as a date/time string. */
function fmt_date(int $ts): string {
if ($ts <= 0) return '-';
return date('Y-m-d H:i:s', $ts);
}
/**
* Convert a 64-char hex pubkey to npub (bech32).
* Uses the standard bech32 encoding (NIP-19).
*/
function hex_to_npub(string $hex): string {
if (!preg_match('/^[0-9a-fA-F]{64}$/', $hex)) return $hex;
$hex = strtolower($hex);
// Bech32 constants
$charset = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
$data = [];
for ($i = 0; $i < strlen($hex); $i += 2) {
$data[] = intval(substr($hex, $i, 2), 16);
}
// Convert 8-bit groups to 5-bit groups
$conv = [];
$buffer = 0;
$bits = 0;
foreach ($data as $byte) {
$buffer = ($buffer << 8) | $byte;
$bits += 8;
while ($bits >= 5) {
$conv[] = ($buffer >> ($bits - 5)) & 31;
$bits -= 5;
}
}
if ($bits > 0) {
$conv[] = ($buffer << (5 - $bits)) & 31;
}
// Compute checksum — HRP values are positions of each char in the charset.
$values = array_merge(
array_map(function($c) {
return strpos('qpzry9x8gf2tvdw0s3jn54khce6mua7l', $c);
}, str_split('npub')),
$conv
);
$gen = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
$chk = 1;
foreach ($values as $v) {
$top = $chk >> 25;
$chk = (($chk & 0x1ffffff) << 5) ^ $v;
for ($i = 0; $i < 5; $i++) {
if (($top >> $i) & 1) $chk ^= $gen[$i];
}
}
$chk ^= 1;
$checksum = [];
for ($i = 0; $i < 6; $i++) {
$checksum[] = ($chk >> (5 * (5 - $i))) & 31;
}
$result = 'npub1';
foreach (array_merge($conv, $checksum) as $v) {
$result .= $charset[$v];
}
return $result;
}
/** Truncate a string to $len chars with ellipsis. */
function trunc(string $s, int $len = 16): string {
if (strlen($s) <= $len) return $s;
return substr($s, 0, $len) . '…';
}
/** Build pagination links HTML. */
function pagination(int $page, int $per_page, int $total, string $base_url): string {
$total_pages = max(1, ceil($total / $per_page));
if ($total_pages <= 1) return '';
$html = '<div class="pagination">';
if ($page > 1) {
$html .= '<a href="' . e($base_url) . '?page=' . ($page - 1) . '">← Prev</a>';
}
$html .= '<span>Page ' . $page . ' of ' . $total_pages . ' (' . $total . ' total)</span>';
if ($page < $total_pages) {
$html .= '<a href="' . e($base_url) . '?page=' . ($page + 1) . '">Next →</a>';
}
$html .= '</div>';
return $html;
}
/** Get the current page number from the query string. */
function current_page(): int {
$p = intval($_GET['page'] ?? 1);
return max(1, $p);
}
/** JSON response helper for AJAX endpoints. */
function json_response($data): void {
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/** Get a query parameter with a default. */
function query_param(string $key, $default = null) {
return $_GET[$key] ?? $default;
}
/**
* Batch-resolve profiles from the profiles cache table.
* Returns [pubkey_hex => ['name'=>..., 'display_name'=>...,
* 'best_name'=>..., 'picture'=>..., 'nip05'=>...]].
* Pubkeys with no cached profile are absent from the result.
*/
function profile_map(array $pubkeys): array {
if (empty($pubkeys)) return [];
$pdo = db();
$map = [];
try {
// Build parameterized IN clause: WHERE pubkey = ANY(?)
// PDO PostgreSQL supports passing an array as a string literal.
$placeholders = implode(',', array_fill(0, count($pubkeys), '?'));
$stmt = $pdo->prepare(
"SELECT pubkey, name, display_name, picture, nip05 FROM profiles "
. "WHERE pubkey IN ($placeholders)"
);
$stmt->execute(array_values($pubkeys));
$rows = $stmt->fetchAll();
foreach ($rows as $r) {
$map[$r['pubkey']] = [
'name' => $r['name'] ?? '',
'display_name' => $r['display_name'] ?? '',
'best_name' => profile_display_name($r),
'picture' => $r['picture'] ?? '',
'nip05' => $r['nip05'] ?? '',
];
}
} catch (PDOException $e) {}
return $map;
}
/**
* Apply the profile_name_preference config key to resolve the display name.
* Never returns null. Returns '' if neither field is set.
*/
function profile_display_name(array $profile): string {
$name = $profile['name'] ?? '';
$display_name = $profile['display_name'] ?? '';
static $pref = null;
if ($pref === null) {
try {
$pdo = db();
$pref = $pdo->query("SELECT value FROM config WHERE key = 'profile_name_preference'")->fetchColumn() ?: 'display_name';
} catch (PDOException $e) {
$pref = 'display_name';
}
}
if ($pref === 'name') {
return $name !== '' ? $name : $display_name;
}
// Default: prefer display_name, fall back to name.
return $display_name !== '' ? $display_name : $name;
}
/**
* Render the shared HTML header with side navigation.
* Pass the active page name to highlight the current nav item.
*/
function admin_header(string $active = '', string $title = 'C-Relay-PG Admin'): void {
$pages = [
'dashboard' => ['index.php', 'Dashboard'],
'follows' => ['follows.php', 'Follows'],
'relays' => ['relays.php', 'Relays'],
'config' => ['config-edit.php', 'Config'],
'inbox' => ['inbox.php', 'Inbox'],
];
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= e($title) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<!-- Side Navigation -->
<nav class="side-nav" id="side-nav">
<ul class="nav-menu">
<?php foreach ($pages as $key => [$url, $label]): ?>
<li><a class="nav-item<?= $active === $key ? ' active' : '' ?>" href="<?= e($url) ?>"><?= e($label) ?></a></li>
<?php endforeach; ?>
</ul>
</nav>
<div class="side-nav-overlay" id="side-nav-overlay" onclick="closeNav()"></div>
<!-- Header -->
<div class="main-header">
<div class="header-content">
<button class="menu-btn" onclick="openNav()">☰</button>
<div class="header-title" onclick="location.href='index.php'">
<span class="relay-letter">R</span><span class="relay-letter">E</span><span class="relay-letter">L</span><span class="relay-letter">A</span><span class="relay-letter">Y</span>
</div>
<div style="width:40px"></div>
</div>
</div>
<script>
function openNav() {
document.getElementById('side-nav').classList.add('open');
document.getElementById('side-nav-overlay').classList.add('show');
}
function closeNav() {
document.getElementById('side-nav').classList.remove('open');
document.getElementById('side-nav-overlay').classList.remove('show');
}
</script>
<?php
}
/** Render the shared HTML footer. */
function admin_footer(): void {
?>
</body>
</html>
<?php
}
+8754
View File
File diff suppressed because it is too large Load Diff
Executable
+27
View File
@@ -0,0 +1,27 @@
#!/bin/bash
# Launch the admin2 PHP built-in server, fully detached from the calling shell.
# Usage: ./admin2/serve.sh [port] (default port 8088)
PORT="${1:-8088}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Kill any existing instance on this port.
pkill -f "php -S 127.0.0.1:$PORT" 2>/dev/null
sleep 1
# Start fully detached in its own session so SIGTERM to the launcher
# does not propagate to the PHP server.
setsid php -S 127.0.0.1:$PORT -t "$SCRIPT_DIR" > "$SCRIPT_DIR/php_server.log" 2>&1 < /dev/null &
PHP_PID=$!
disown $PHP_PID 2>/dev/null
# Give it a moment to bind, then report.
sleep 1
if kill -0 "$PHP_PID" 2>/dev/null; then
echo "admin PHP server started on http://127.0.0.1:$PORT (PID $PHP_PID)"
echo "Serving from: $SCRIPT_DIR"
echo "Log: $SCRIPT_DIR/php_server.log"
else
echo "ERROR: PHP server failed to start. Log:"
cat "$SCRIPT_DIR/php_server.log" 2>/dev/null
exit 1
fi
+829 -57
View File
File diff suppressed because it is too large Load Diff
+570 -96
View File
@@ -4,27 +4,63 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>C-Relay Admin</title>
<title>C-Relay-PG Admin</title>
<link rel="stylesheet" href="/api/index.css">
</head>
<body>
<!-- Side Navigation Menu -->
<nav class="side-nav" id="side-nav">
<ul class="nav-menu">
<li><button class="nav-item" data-page="statistics">Statistics</button></li>
<li><button class="nav-item" data-page="subscriptions">Subscriptions</button></li>
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="ip-bans">IP BANS</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="caching">Caching</button></li>
<li><button class="nav-item" data-page="dm">DM</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
</ul>
<div class="nav-footer">
<button class="nav-footer-btn" id="nav-dark-mode-btn">DARK MODE</button>
<button class="nav-footer-btn" id="nav-logout-btn">LOGOUT</button>
</div>
</nav>
<!-- Side Navigation Overlay -->
<div class="side-nav-overlay" id="side-nav-overlay"></div>
<!-- Header with title and profile display -->
<header class="main-header">
<div class="section">
<div class="header-content">
<div class="header-title">RELAY</div>
<div class="header-title clickable" id="header-title">
<span class="relay-letter" data-letter="R">R</span>
<span class="relay-letter" data-letter="E">E</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="A">A</span>
<span class="relay-letter" data-letter="Y">Y</span>
</div>
<div class="relay-info">
<div id="relay-name" class="relay-name">C-Relay-PG</div>
<div id="relay-description" class="relay-description">Loading...</div>
<div id="relay-pubkey-container" class="relay-pubkey-container">
<div id="relay-pubkey" class="relay-pubkey">Loading...</div>
</div>
</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="admin-label">admin</div>
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
</div>
<!-- Logout dropdown -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
</div>
<!-- Dropdown menu removed - buttons moved to sidebar -->
</div>
</div>
</header>
</div>
<!-- Login Modal Overlay -->
<div id="login-modal" class="login-modal-overlay" style="display: none;">
@@ -33,98 +69,77 @@
</div>
</div>
<!-- Main Sections Wrapper -->
<div class="main-sections-wrapper">
<!-- Relay Connection Section -->
<div id="relay-connection-section" class="flex-section">
<div class="section">
<h2>RELAY CONNECTION</h2>
<div class="input-group">
<label for="relay-connection-url">Relay URL:</label>
<input type="text" id="relay-connection-url" value=""
placeholder="ws://localhost:8888 or wss://relay.example.com">
</div>
<div class="input-group">
<label for="relay-pubkey-manual">Relay Pubkey (if not available via NIP-11):</label>
<input type="text" id="relay-pubkey-manual" placeholder="64-character hex pubkey"
pattern="[0-9a-fA-F]{64}" title="64-character hexadecimal public key">
</div>
<div class="inline-buttons">
<button type="button" id="connect-relay-btn">CONNECT TO RELAY</button>
<button type="button" id="disconnect-relay-btn" disabled>DISCONNECT</button>
<button type="button" id="restart-relay-btn" disabled>RESTART RELAY</button>
</div>
<div class="status disconnected" id="relay-connection-status">NOT CONNECTED</div>
<!-- Relay Information Display -->
<div id="relay-info-display" class="hidden">
<h3>Relay Information (NIP-11)</h3>
<table class="config-table" id="relay-info-table">
<thead>
<tr>
<th>Property</th>
<th>Value</th>
</tr>
</thead>
<tbody id="relay-info-table-body">
</tbody>
</table>
</div>
</div>
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
<div class="access-denied-content">
<div class="access-denied-icon"></div>
<h2>ACCESS DENIED</h2>
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
</div>
</div> <!-- End Main Sections Wrapper -->
</div>
<!-- DATABASE STATISTICS Section -->
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
<div class="section-header">
<h2>DATABASE STATISTICS</h2>
<button type="button" id="refresh-stats-btn" class="countdown-btn"></button>
DATABASE STATISTICS
</div>
<!-- Event Rate Graph Container -->
<div id="event-rate-chart"></div>
<!-- Database Overview Table -->
<div class="input-group">
<label>Database Overview:</label>
<div class="config-table-container">
<table class="config-table" id="stats-overview-table">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-overview-table-body">
<tr>
<td>Database Size</td>
<td id="db-size">-</td>
<td>Current database file size</td>
</tr>
<tr>
<td>Total Events</td>
<td id="total-events">-</td>
<td>Total number of events stored</td>
</tr>
<tr>
<td>Process ID</td>
<td id="process-id">-</td>
</tr>
<tr>
<td>WebSocket Connections</td>
<td id="websocket-connections">-</td>
</tr>
<tr>
<td>Active Subscriptions</td>
<td id="active-subscriptions">-</td>
</tr>
<tr>
<td>Memory Usage</td>
<td id="memory-usage">-</td>
</tr>
<tr>
<td>CPU Usage</td>
<td id="cpu-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</td>
<td>Timestamp of oldest event</td>
</tr>
<tr>
<td>Newest Event</td>
<td id="newest-event">-</td>
<td>Timestamp of newest event</td>
</tr>
</tbody>
</table>
@@ -161,24 +176,20 @@
<tr>
<th>Period</th>
<th>Events</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-time-table-body">
<tr>
<td>Last 24 Hours</td>
<td id="events-24h">-</td>
<td>Events in the last day</td>
</tr>
<tr>
<td>Last 7 Days</td>
<td id="events-7d">-</td>
<td>Events in the last week</td>
</tr>
<tr>
<td>Last 30 Days</td>
<td id="events-30d">-</td>
<td>Events in the last month</td>
</tr>
</tbody>
</table>
@@ -193,6 +204,7 @@
<thead>
<tr>
<th>Rank</th>
<th>Name</th>
<th>Pubkey</th>
<th>Event Count</th>
<th>Percentage</th>
@@ -200,7 +212,7 @@
</thead>
<tbody id="stats-pubkeys-table-body">
<tr>
<td colspan="4" style="text-align: center; font-style: italic;">No data loaded</td>
<td colspan="5" style="text-align: center; font-style: italic;">No data loaded</td>
</tr>
</tbody>
</table>
@@ -209,9 +221,30 @@
</div>
<!-- SUBSCRIPTION DETAILS Section (Admin Only) -->
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
<div class="section-header">
ACTIVE SUBSCRIPTION DETAILS
</div>
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="subscription-details-table">
<tbody id="subscription-details-table-body">
<tr>
<td colspan="4" style="text-align: center; font-style: italic;">No subscriptions active</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Testing Section -->
<div id="div_config" class="section flex-section" style="display: none;">
<h2>RELAY CONFIGURATION</h2>
<div class="section-header">
RELAY CONFIGURATION
</div>
<div id="config-display" class="hidden">
<div class="config-table-container">
<table class="config-table" id="config-table">
@@ -236,9 +269,10 @@
</div>
<!-- Auth Rules Management - Moved after configuration -->
<!-- AUTH RULES MANAGEMENT SECTION -->
<div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header">
<h2>AUTH RULES MANAGEMENT</h2>
AUTH RULES MANAGEMENT
</div>
<!-- Auth Rules Table -->
@@ -258,33 +292,69 @@
</table>
</div>
<!-- Simplified Auth Rule Input Section -->
<!-- Auth Rule Input Section -->
<div id="authRuleInputSections" style="display: block;">
<div class="input-group">
<label for="authRulePubkey">Public Key (npub or hex):</label>
<input type="text" id="authRulePubkey" placeholder="npub1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
mode.
Only whitelisted users will be able to interact with the relay.
</div>
<div class="inline-buttons">
<button type="button" id="addWhitelistBtn" onclick="addWhitelistRule()">ADD TO
WHITELIST</button>
<button type="button" id="addBlacklistBtn" onclick="addBlacklistRule()">ADD TO
BLACKLIST</button>
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
</div>
</div>
</div>
<!-- Combined Pubkey Auth Rule Section -->
<div class="input-group">
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
mode.
Only whitelisted users will be able to interact with the relay.
</div>
<div class="inline-buttons">
<button type="button" id="addWhitelistBtn" onclick="addWhitelistRule()">ADD TO
WHITELIST</button>
<button type="button" id="addBlacklistBtn" onclick="addBlacklistRule()">ADD TO
BLACKLIST</button>
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
</div>
<!-- WEB OF TRUST SECTION -->
<div class="section flex-section" id="wotSection" style="display: none;">
<div class="section-header">
WEB OF TRUST
</div>
<!-- Kind 3 Status Indicator -->
<div id="wotKind3Status" class="wot-status-row">
<span>Admin Contact List (kind 3):</span>
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
</div>
<!-- WoT Level Selector -->
<div class="wot-level-selector">
<label>WoT Level:</label>
<div class="inline-buttons">
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
OFF
</button>
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
WRITE ONLY
</button>
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
FULL
</button>
</div>
<div class="wot-level-description" id="wotLevelDescription">
Level 0: Open relay — anyone can read and write
</div>
</div>
<!-- WoT Stats -->
<div class="wot-stats-row">
<span>Whitelisted Pubkeys:</span>
<span id="wotWhitelistCount"></span>
</div>
<!-- Sync Button -->
<div class="inline-buttons">
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
</div>
</div>
@@ -300,7 +370,7 @@
</div>
<!-- Outbox -->
<div class="input-group">
<div>
<label for="dm-outbox">Send Message to Relay:</label>
<textarea id="dm-outbox" rows="4" placeholder="Enter your message to send to the relay..."></textarea>
</div>
@@ -319,6 +389,408 @@
</div>
</div>
<!-- IP BANS Section -->
<div class="section" id="ipBansSection" style="display: none;">
<div class="section-header">
IP BAN MANAGEMENT
</div>
<!-- Statistics Cards -->
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="ip-bans-stats-table">
<thead>
<tr>
<th>Total IPs Tracked</th>
<th>Currently Banned</th>
<th>Total Bans Issued</th>
</tr>
</thead>
<tbody>
<tr>
<td id="ip-bans-total">-</td>
<td id="ip-bans-active">-</td>
<td id="ip-bans-issued">-</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add Ban Form -->
<div class="input-group">
<h3>Manually Ban IP Address</h3>
<div class="form-group">
<label for="ban-ip-input">IP Address:</label>
<input type="text" id="ban-ip-input" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label for="ban-duration-select">Ban Duration:</label>
<select id="ban-duration-select">
<option value="3600">1 Hour</option>
<option value="86400" selected>24 Hours</option>
<option value="604800">7 Days</option>
<option value="2592000">30 Days</option>
<option value="31536000">1 Year</option>
<option value="999999999">Permanent</option>
</select>
</div>
<div class="inline-buttons">
<button type="button" id="add-ban-btn">BAN IP</button>
</div>
<div id="add-ban-status" class="status-message"></div>
</div>
<!-- Whitelist Management -->
<div class="input-group">
<h3>IP Whitelist (Never Banned)</h3>
<p style="font-size:13px;opacity:0.8;">IPs in this list are never idle-banned. Comma-separated.</p>
<div class="form-group">
<label for="whitelist-ip-input">Add IP to Whitelist:</label>
<input type="text" id="whitelist-ip-input" placeholder="103.81.231.220">
</div>
<div class="inline-buttons">
<button type="button" id="add-whitelist-btn">ADD TO WHITELIST</button>
</div>
<div id="whitelist-status" class="status-message"></div>
<div id="whitelist-current" style="margin-top:8px;font-size:13px;"></div>
</div>
<!-- Filter Controls -->
<div class="input-group">
<div class="inline-buttons">
<button type="button" id="ip-ban-filter-all" class="active">All IPs</button>
<button type="button" id="ip-ban-filter-banned">Currently Banned</button>
<button type="button" id="ip-ban-filter-expired">Expired</button>
<button type="button" id="refresh-ip-bans-btn">REFRESH</button>
</div>
</div>
<!-- IP Bans List -->
<div class="input-group">
<label>Banned IP Addresses:</label>
<div class="config-table-container">
<table class="config-table" id="ip-bans-table">
<thead>
<tr>
<th>IP Address</th>
<th>Status</th>
<th>Banned Until</th>
<th>Failures</th>
<th>Authed Successfully</th>
<th>Connection Attempts</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="ip-bans-tbody">
<tr>
<td colspan="7" style="text-align: center;">Click REFRESH to load IP bans</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- RELAY EVENTS Section -->
<div class="section" id="relayEventsSection" style="display: none;">
<div class="section-header">
RELAY EVENTS MANAGEMENT
</div>
<!-- Live Relay Event Feed -->
<div class="input-group">
<h3>Live Relay Event Feed (Normal Nostr Subscription)</h3>
<div class="config-table-container">
<table class="config-table" id="live-relay-events-table">
<thead>
<tr>
<th>Time</th>
<th>Kind</th>
<th>Pubkey</th>
<th>ID</th>
<th>Content Preview</th>
</tr>
</thead>
<tbody id="live-relay-events-table-body">
<tr>
<td colspan="5" style="text-align: center;">Waiting for live events...</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Kind 0: User Metadata -->
<div class="input-group">
<h3>Kind 0: User Metadata</h3>
<div class="form-group">
<label for="kind0-name">Name:</label>
<input type="text" id="kind0-name" placeholder="Relay Name">
</div>
<div class="form-group">
<label for="kind0-about">About:</label>
<textarea id="kind0-about" rows="3" placeholder="Relay Description"></textarea>
</div>
<div class="form-group">
<label for="kind0-picture">Picture URL:</label>
<input type="url" id="kind0-picture" placeholder="https://example.com/logo.png">
</div>
<div class="form-group">
<label for="kind0-banner">Banner URL:</label>
<input type="url" id="kind0-banner" placeholder="https://example.com/banner.png">
</div>
<div class="form-group">
<label for="kind0-nip05">NIP-05:</label>
<input type="text" id="kind0-nip05" placeholder="relay@example.com">
</div>
<div class="form-group">
<label for="kind0-website">Website:</label>
<input type="url" id="kind0-website" placeholder="https://example.com">
</div>
<div class="inline-buttons">
<button type="button" id="submit-kind0-btn">UPDATE METADATA</button>
</div>
<div id="kind0-status" class="status-message"></div>
</div>
<!-- Kind 10050: DM Relay List -->
<div class="input-group">
<h3>Kind 10050: DM Relay List</h3>
<div class="form-group">
<label for="kind10050-relays">Relay URLs (one per line):</label>
<textarea id="kind10050-relays" rows="4" placeholder="wss://relay1.com&#10;wss://relay2.com"></textarea>
</div>
<div class="inline-buttons">
<button type="button" id="submit-kind10050-btn">UPDATE DM RELAYS</button>
</div>
<div id="kind10050-status" class="status-message"></div>
</div>
<!-- Kind 10002: Relay List -->
<div class="input-group">
<h3>Kind 10002: Relay List</h3>
<div id="kind10002-relay-entries">
<!-- Dynamic relay entries will be added here -->
</div>
<div class="inline-buttons">
<button type="button" id="add-relay-entry-btn">ADD RELAY</button>
<button type="button" id="submit-kind10002-btn">UPDATE RELAYS</button>
</div>
<div id="kind10002-status" class="status-message"></div>
</div>
</div>
<!-- CACHING Section -->
<div class="section" id="cachingSection" style="display: none;">
<div class="section-header">CACHING</div>
<!-- Caching Service Status (read-only) -->
<div class="input-group">
<h3>Caching Service Status</h3>
<div id="caching-service-status" class="status-display">
<p>Service status will appear here after connecting.</p>
</div>
</div>
<!-- Relay Inbox Status (read-only) -->
<div class="input-group">
<h3>Relay Inbox Status</h3>
<div id="caching-inbox-status" class="status-display">
<p>Inbox status will appear here after connecting.</p>
</div>
</div>
<!-- Caching Configuration (editable) -->
<div class="input-group">
<h3>Caching Configuration</h3>
<div class="form-group">
<label for="caching-enabled">Enable Caching:</label>
<select id="caching-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-inbox-enabled">Enable Inbox Consumer:</label>
<select id="caching-inbox-enabled">
<option value="false">Disabled</option>
<option value="true">Enabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-root-npubs">Root Npubs (one per line):</label>
<textarea id="caching-root-npubs" rows="4" placeholder="npub1...">npub13lm5wf8dvsdnc2894pkhch9uf8phvw9varrv8zf4sc885hhdmc8q6lx7ks</textarea>
</div>
<div class="form-group">
<label for="caching-bootstrap-relays">Bootstrap Relays (one per line):</label>
<textarea id="caching-bootstrap-relays" rows="4" placeholder="wss://relay.damus.io">wss://relay.damus.io
wss://nos.lol
wss://relay.primal.net
wss://laantungir.net/relay</textarea>
</div>
<div class="form-group">
<label for="caching-kinds">Kinds (comma-separated):</label>
<input type="text" id="caching-kinds" placeholder="1,3,6,10000,30023" value="0,1,3,6,10000,10002,30023">
</div>
<div class="form-group">
<label for="caching-admin-kinds">Admin Kinds (comma-separated, * for all):</label>
<input type="text" id="caching-admin-kinds" placeholder="*">
</div>
<div class="form-group">
<label for="caching-live-enabled">Live Subscriptions:</label>
<select id="caching-live-enabled">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-backfill-enabled">Backfill:</label>
<select id="caching-backfill-enabled">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="form-group">
<label for="caching-backfill-windows">Backfill Windows (seconds, comma-separated):</label>
<input type="text" id="caching-backfill-windows" placeholder="86400,604800,2592000,7776000,31536000">
</div>
<div class="form-group">
<label for="caching-backfill-page-size">Backfill Page Size:</label>
<input type="number" id="caching-backfill-page-size" placeholder="50">
</div>
<div class="form-group">
<label for="caching-backfill-tick-interval">Backfill Tick Interval (ms):</label>
<input type="number" id="caching-backfill-tick-interval" placeholder="5000">
</div>
<div class="form-group">
<label for="caching-inbox-batch-size">Inbox Batch Size:</label>
<input type="number" id="caching-inbox-batch-size" placeholder="150">
</div>
<div class="form-group">
<label for="caching-inbox-active-poll">Inbox Active Poll (ms):</label>
<input type="number" id="caching-inbox-active-poll" placeholder="200">
</div>
<div class="form-group">
<label for="caching-inbox-idle-poll">Inbox Idle Poll (ms):</label>
<input type="number" id="caching-inbox-idle-poll" placeholder="5000">
</div>
<div class="form-group">
<label for="caching-service-binary-path">Caching Service Binary Path:</label>
<input type="text" id="caching-service-binary-path" placeholder="./caching_relay" value="./caching_relay">
</div>
<div class="form-group">
<label for="caching-service-pg-conn">Caching Service PG Connection:</label>
<input type="text" id="caching-service-pg-conn" placeholder="host=localhost port=5432 dbname=crelay user=crelay password=crelay" value="host=localhost port=5432 dbname=crelay user=crelay password=crelay">
</div>
<div class="inline-buttons">
<button type="button" id="caching-apply-btn">APPLY CONFIGURATION</button>
<button type="button" id="caching-reset-progress-btn">RESET BACKFILL PROGRESS</button>
</div>
<div id="caching-config-status" class="status-message"></div>
</div>
<div class="input-group">
<h3>Caching Service Control</h3>
<p>Start or stop the external caching service process. Set <code>caching_service_binary_path</code> and <code>caching_service_pg_conn</code> above and click APPLY CONFIGURATION before starting.</p>
<div class="inline-buttons">
<button type="button" id="caching-start-service-btn">START CACHING SERVICE</button>
<button type="button" id="caching-stop-service-btn">STOP CACHING SERVICE</button>
</div>
<div id="caching-service-control-status" class="status-message"></div>
</div>
<div class="input-group">
<h3>Followed Pubkeys</h3>
<p>Pubkeys being cached with their profile names, event counts, outbox relays, and backfill status.</p>
<div id="caching-follows-status" class="status-message"></div>
<table id="caching-follows-table" style="width: 100%; border-collapse: collapse; font-size: 13px;">
<thead>
<tr style="border-bottom: 2px solid var(--border-color, #444); text-align: left;">
<th style="padding: 6px 8px;">Name</th>
<th style="padding: 6px 8px;">npub</th>
<th style="padding: 6px 8px;">Root?</th>
<th style="padding: 6px 8px;">Events in DB</th>
<th style="padding: 6px 8px;">Backfill</th>
<th style="padding: 6px 8px; width: 40%;">Relays (status, events fetched)</th>
</tr>
</thead>
<tbody id="caching-follows-table-body">
<tr><td colspan="6" style="text-align: center; font-style: italic; padding: 12px;">Loading follows status...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header">
<h2>SQL QUERY CONSOLE</h2>
</div>
<!-- Query Selector -->
<div class="input-group">
<label for="query-dropdown">Quick Queries & History:</label>
<select id="query-dropdown" onchange="loadSelectedQuery()">
<option value="">-- Select a query --</option>
<optgroup label="Common Queries">
<option value="recent_events">Recent Events</option>
<option value="event_stats">Event Statistics</option>
<option value="subscriptions">Active Subscriptions</option>
<option value="top_pubkeys">Top Pubkeys</option>
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="IP Ban Queries">
<option value="banned_ips_summary">Banned IPs Summary</option>
<option value="banned_ips_list">All Banned IP Addresses</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
</select>
</div>
<!-- Query Editor -->
<div class="input-group">
<label for="sql-input">SQL Query:</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
</div>
<!-- Query Actions -->
<div class="input-group">
<div class="inline-buttons">
<button type="button" id="execute-sql-btn">EXECUTE QUERY</button>
<button type="button" id="clear-sql-btn">CLEAR</button>
<button type="button" id="clear-history-btn">CLEAR HISTORY</button>
</div>
</div>
<!-- Query Results -->
<div class="input-group">
<label>Query Results:</label>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="config-table-container"></div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
<script src="/api/nostr.bundle.js"></script>
@@ -326,6 +798,8 @@
<!-- Load NOSTR_LOGIN_LITE main library -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script> -->
<script src="/api/nostr-lite.js"></script>
<!-- Load text_graph library -->
<script src="/api/text_graph.js"></script>
+4026 -452
View File
File diff suppressed because it is too large Load Diff
+95 -1
View File
@@ -3297,8 +3297,18 @@ var NostrTools = (() => {
this.enablePing = opts.enablePing;
}
async ensureRelay(url, params) {
const rawUrl = url;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
url = normalizeURL(url);
let relay = this.relays.get(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay START", {
rawUrl,
normalizedUrl: url,
hadRelay: !!relay,
relayMapKeysBefore: Array.from(this.relays.keys())
});
}
if (!relay) {
relay = new AbstractRelay(url, {
verifyEvent: this.trustedRelayURLs.has(url) ? alwaysTrue : this.verifyEvent,
@@ -3306,13 +3316,38 @@ var NostrTools = (() => {
enablePing: this.enablePing
});
relay.onclose = () => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose", {
normalizedUrl: url,
relayMapKeysBeforeDelete: Array.from(this.relays.keys())
});
}
this.relays.delete(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay onclose complete", {
normalizedUrl: url,
relayMapKeysAfterDelete: Array.from(this.relays.keys())
});
}
};
if (params?.connectionTimeout)
relay.connectionTimeout = params.connectionTimeout;
this.relays.set(url, relay);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay created relay", {
normalizedUrl: url,
relayMapKeysAfterCreate: Array.from(this.relays.keys())
});
}
}
await relay.connect();
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL ensureRelay connected", {
normalizedUrl: url,
relayConnected: relay.connected,
relayMapKeysAfterConnect: Array.from(this.relays.keys())
});
}
return relay;
}
close(relays) {
@@ -3334,16 +3369,26 @@ var NostrTools = (() => {
}
subscribeMany(relays, filters, params) {
params.onauth = params.onauth || params.doauth;
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const request = [];
const uniqUrls = [];
for (let i2 = 0; i2 < relays.length; i2++) {
const url = normalizeURL(relays[i2]);
if (uniqUrls.indexOf(url) === -1) {
uniqUrls.push(url);
for (let f2 = 0; f2 < filters.length; f2++) {
request.push({ url, filter: filters[f2] });
}
}
}
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMany", {
relaysInput: relays,
uniqUrls,
filtersCount: filters.length,
requestsCount: request.length
});
}
return this.subscribeMap(request, params);
}
subscribeMap(requests, params) {
@@ -3391,14 +3436,31 @@ var NostrTools = (() => {
_knownIds.add(id);
return have;
};
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const allOpened = Promise.all(
requests.map(async ({ url, filter }, i2) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap request", {
index: i2,
url,
filterKinds: filter?.kinds,
hasAuthorFilter: !!filter?.authors,
hasPTagFilter: !!filter?.["#p"]
});
}
let relay;
try {
relay = await this.ensureRelay(url, {
connectionTimeout: params.maxWait ? Math.max(params.maxWait * 0.8, params.maxWait - 1e3) : void 0
});
} catch (err) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL subscribeMap ensureRelay FAILED", {
index: i2,
url,
error: err?.message || String(err)
});
}
handleClose(i2, err?.message || String(err));
return;
}
@@ -3480,18 +3542,50 @@ var NostrTools = (() => {
return events[0] || null;
}
publish(relays, event, options) {
return relays.map(normalizeURL).map(async (url, i2, arr) => {
const debugEnabled = typeof window !== "undefined" && !!window.__SIMPLE_POOL_DEBUG__;
const normalizedRelays = relays.map(normalizeURL);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish START", {
relaysInput: relays,
normalizedRelays,
eventKind: event?.kind,
eventId: event?.id
});
}
return normalizedRelays.map(async (url, i2, arr) => {
if (arr.indexOf(url) !== i2) {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish duplicate URL", { url, index: i2 });
}
return Promise.reject("duplicate url");
}
let r = await this.ensureRelay(url);
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ensured relay", {
url,
relayConnected: r?.connected,
relayMapKeys: Array.from(this.relays.keys())
});
}
return r.publish(event).catch(async (err) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish ERROR", {
url,
error: err?.message || String(err)
});
}
if (err instanceof Error && err.message.startsWith("auth-required: ") && options?.onauth) {
await r.auth(options.onauth);
return r.publish(event);
}
throw err;
}).then((reason) => {
if (debugEnabled) {
console.log("🔎 SIMPLE_POOL publish SUCCESS", {
url,
reason
});
}
if (this.trackRelays) {
let set = this.seenOn.get(event.id);
if (!set) {
+813
View File
@@ -0,0 +1,813 @@
<!DOCTYPE html>
<?xml version="1.0" encoding="UTF-8"?>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<title>TEMPLATE</title>
<link rel="stylesheet" href="./css/client.css" />
<!-- Initialize theme BEFORE any components load -->
<script>
(function () {
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.documentElement.classList.add('dark-mode');
if (document.body) {
document.body.classList.add('dark-mode');
}
}
})();
</script>
<link rel="shortcut icon" type="image/x-icon" href="./favicon/favicon-dots2.ico" />
<!-- SVG.js library (required by HamburgerMorphing) -->
<script src="https://cdn.jsdelivr.net/npm/@svgdotjs/svg.js@3.0/dist/svg.min.js"></script>
</head>
<body>
<!-- ================================================================
HAMBURGER BUTTON (Fixed, separate from header)
================================================================
The hamburger button is a fixed element outside the header
to ensure it stays visible above the sidenav (z-index: 10 > 3).
================================================================ -->
<div id="divSvgHam" class="divHeaderButtons">
<!-- HamburgerMorphing will be injected here -->
</div>
<!-- ================================================================
HEADER
================================================================
Standard header with title (center).
================================================================ -->
<div id="divHeader">
<div id="divHeaderFlexLeft">
<!-- Hamburger is now separate fixed element -->
</div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText"></div>
</div>
<div id="divHeaderFlexRight">
<!-- No button in header right - logout is in sidenav footer -->
</div>
</div>
<!-- ================================================================
BODY
================================================================
Main content area. Add your page-specific content here.
================================================================ -->
<div id="divBody">
</div>
<!-- ================================================================
FOOTER
================================================================
Three-section footer layout:
- Left: Relay status animations (HamburgerMorphing instances)
- Center: General status information
- Right: Additional information
================================================================ -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
<div id="divFooterBalance" class="divFooterBox">0 sats</div>
</div>
<!-- ================================================================
SIDENAV
================================================================
Slide-out navigation panel. Opens from left when hamburger clicked.
Uses flexbox layout to pin version bar to bottom.
Includes a version bar footer with theme toggle and logout buttons.
================================================================ -->
<div id="divSideNav">
<div id="divSideNavHeader">
<!-- No close button - use main hamburger to close -->
</div>
<div id="divSideNavBody">
<div id="divFiles"></div>
</div>
<div id="divAiSection" class="sidenavSection">
<div id="divAiSectionTitle" class="sidenavSectionTitle">AI</div>
<div id="divAiList" class="sidenavSectionList">
<div id="divAiProvidersList">No saved providers yet.</div>
</div>
</div>
<div id="divRelaySection">
<div id="divRelaySectionTitle">
リレー
</div>
<div id="divRelayList">
Loading relays...
</div>
</div>
<div id="divBlossomSection">
<div id="divBlossomSectionTitle">ブロッサム</div>
<div id="divBlossomList">Loading blossom servers...</div>
</div>
<div id="divVersionBar">
<span id="versionDisplay">v0.0.1</span>
<div id="divVersionBarButtons">
<button id="themeToggleButton" title="Toggle Dark/Light Mode">
<div id="themeToggleHamburgerContainer"></div>
</button>
<button id="logoutButton" title="Logout">
<div id="logoutHamburgerContainer"></div>
</button>
</div>
</div>
</div>
<!-- ================================================================
REQUIRED SCRIPTS
================================================================
These scripts must be loaded in this order:
1. nostr.bundle.js - Nostr tools library
2. nostr-lite.js - Authentication modal (nostr-login-lite)
================================================================ -->
<script src="./nostr.bundle.js"></script>
<script src="./nostr-lite.js"></script>
<script type="module">
/* ================================================================
IMPORTS
================================================================
Import shared NDK functionality from init-ndk.mjs:
- initNDKPage() - Initialize authentication and worker
- getPubkey() - Get current user's pubkey
- subscribe() - Create NDK subscriptions
- publishEvent() - Publish events via NDK
- disconnect() - Disconnect from worker
- getRelayData() - Get relay connection data
- getRelayStats() - Get relay activity statistics
Import HamburgerMorphing for animated icons
================================================================ */
import {
initNDKPage,
getPubkey, injectHeaderAvatar,
subscribe,
publishEvent,
disconnect,
getVersion,
updateVersionDisplay,
getUserSettings,
patchUserSettings,
onUserSettings
} from './js/init-ndk.mjs';
import { HamburgerMorphing } from "./hamburger_morphing/hamburger.mjs";
import { initFooterRelayStatus, updateFooterRelayStatus, initSidenavRelaySection, updateSidenavRelaySection, setRelayActivityState } from './js/relay-ui.mjs';
import { initBlossomSection, updateBlossomSection } from './js/blossom-ui.mjs';
import { initAiSectionWithLocalConfig } from './js/ai-ui.mjs';
// Version will be loaded asynchronously
const versionInfo = await getVersion();
const VERSION = versionInfo.VERSION;
console.log(`[template.html ${VERSION}] Loading...`);
/* ================================================================
GLOBAL VARIABLES
================================================================
Track state for hamburger menu, relay status, and theme.
================================================================ */
let updateIntervalId = null;
let currentPubkey = null;
/*
AUTH STATE MODEL (Template reference)
------------------------------------------------------------------
This template now demonstrates three auth modes for standalone pages:
- required (default):
Behaves like existing pages: login is required immediately.
- optional:
Page can render public/read-only data without login, but can still
prompt login later for user actions (publish, settings, etc).
- none:
Never auto-login on load (pure public page).
URL behavior in this template:
- If ?auth=required|optional|none is present, it wins.
- Otherwise, if URL includes ?npub=... or ?pubkey=..., mode defaults
to optional because pages with explicit profile targets are commonly
public-readable.
- Otherwise, mode defaults to required.
*/
let isAuthenticated = false;
let authMode = 'required';
let authedPageInitialized = false;
let relayActivityListenersBound = false;
// Hamburger menu
let hamburgerInstance = null;
let isNavOpen = false;
// Version bar buttons
let logoutHamburger = null;
let themeToggleHamburger = null;
let isDarkMode = false;
// App-wide user settings (NIP-78 kind 30078, d:user-settings)
let pageSettings = {};
let unsubscribeUserSettings = null;
/* ================================================================
DOM VARIABLES
================================================================
Cache DOM element references for better performance.
================================================================ */
const divBody = document.getElementById("divBody");
const divSideNav = document.getElementById("divSideNav");
const divSideNavBody = document.getElementById("divSideNavBody");
const divFooterCenter = document.getElementById("divFooterCenter");
const divFooterRight = document.getElementById("divFooterRight");
/* ================================================================
HAMBURGER MENU
================================================================
Initialize and control the animated hamburger menu.
================================================================ */
function initHamburgerMenu() {
hamburgerInstance = new HamburgerMorphing('#divSvgHam', {
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
hamburgerInstance.animateTo('burger');
}
/* ================================================================
SIDENAV FUNCTIONS
================================================================
Open/close sidenav with hamburger morphing animation.
================================================================ */
function openNav() {
divSideNav.style.zIndex = 3;
divSideNav.style.width = "clamp(400px, 50vw, 600px)";
isNavOpen = true;
if (hamburgerInstance) {
hamburgerInstance.animateTo('arrow_left');
}
// Initialize version bar buttons when sidenav opens (lazy load)
if (!logoutHamburger) {
logoutHamburger = new HamburgerMorphing('#logoutHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
logoutHamburger.animateTo('x');
}
if (!themeToggleHamburger) {
themeToggleHamburger = new HamburgerMorphing('#themeToggleHamburgerContainer', {
size: 24,
foreground: 'var(--primary-color)',
background: 'var(--secondary-color)',
hover: 'var(--accent-color)'
});
// Determine current theme
const savedTheme = localStorage.getItem('theme');
isDarkMode = savedTheme === 'dark' || document.body.classList.contains('dark-mode');
const initialShape = isDarkMode ? 'moon' : 'circle';
themeToggleHamburger.animateTo(initialShape);
}
}
function closeNav() {
divSideNav.style.width = "0vw";
divSideNav.style.zIndex = -1;
isNavOpen = false;
if (hamburgerInstance) {
hamburgerInstance.animateTo('burger');
}
}
function toggleNav() {
if (isNavOpen) {
closeNav();
} else {
openNav();
}
}
/* ================================================================
AUTH MODE HELPERS
================================================================
These functions are meant as reusable guidance for future pages.
================================================================ */
function hasTargetPubkeyInUrl() {
const params = new URLSearchParams(window.location.search || '');
const npub = String(params.get('npub') || '').trim();
const pubkey = String(params.get('pubkey') || '').trim();
return Boolean(npub || pubkey);
}
function resolveAuthModeFromUrl() {
const params = new URLSearchParams(window.location.search || '');
const explicitAuth = String(params.get('auth') || '').trim().toLowerCase();
if (explicitAuth === 'required' || explicitAuth === 'optional' || explicitAuth === 'none') {
return explicitAuth;
}
// Convention: explicit target profiles are public-readable by default.
if (hasTargetPubkeyInUrl()) {
return 'optional';
}
return 'required';
}
function isAuthRequiredError(error) {
const message = String(error?.message || error || '').toLowerCase();
return message.includes('authentication required');
}
async function initializeAuthentication(mode) {
// required: existing behavior, throw if auth fails.
if (mode === 'required') {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
return;
}
// none: public page, no login attempt on load.
if (mode === 'none') {
isAuthenticated = false;
currentPubkey = null;
return;
}
// optional: try silent/normal init; if auth required, continue public.
try {
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
} catch (error) {
if (isAuthRequiredError(error)) {
console.log('[template.html] Optional auth mode: continuing unauthenticated');
isAuthenticated = false;
currentPubkey = null;
return;
}
throw error;
}
}
async function initializeAuthenticatedPageFeatures() {
if (!isAuthenticated || authedPageInitialized) return;
await injectHeaderAvatar(currentPubkey);
console.log('[template.html] Authenticated as:', currentPubkey);
// Hydrate app-wide user settings for this page
try {
pageSettings = await getUserSettings();
} catch (error) {
console.warn('[template.html] getUserSettings failed:', error);
pageSettings = {};
}
// Subscribe to live user settings updates (cross-tab + publish echoes)
if (!unsubscribeUserSettings) {
unsubscribeUserSettings = onUserSettings((settings) => {
pageSettings = settings || {};
// TODO: Re-render page-specific UI from pageSettings here.
});
}
// Initialize relay-dependent UI only once authenticated.
initFooterRelayStatus();
initSidenavRelaySection();
await initBlossomSection();
initAiSectionWithLocalConfig();
await UpdateFooter();
if (!updateIntervalId) {
updateIntervalId = setInterval(UpdateFooter, 1000);
}
// Relay activity listeners only matter after worker init/auth.
if (!relayActivityListenersBound) {
window.addEventListener('ndkRelayActivity', (event) => {
const { relayUrl, activity, stats } = event.detail;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`, stats);
setRelayActivityState(relayUrl, activity);
});
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'relayActivity') {
const { relayUrl, activity } = event.data;
console.log(`[template.html] Relay activity: ${relayUrl} - ${activity}`);
setRelayActivityState(relayUrl, activity);
}
});
relayActivityListenersBound = true;
}
authedPageInitialized = true;
}
async function promptLoginIfNeeded() {
if (isAuthenticated) return true;
await initNDKPage();
currentPubkey = await getPubkey();
isAuthenticated = true;
await initializeAuthenticatedPageFeatures();
return true;
}
/* ================================================================
UPDATE FOOTER
================================================================
Update footer sections with relay status, pubkey, and other info.
Called periodically by update loop.
================================================================ */
const UpdateFooter = async () => {
try {
// Update relay status visuals in footer and sidenav
await updateFooterRelayStatus();
await updateSidenavRelaySection();
await updateBlossomSection();
// Clear center and right sections
divFooterCenter.innerHTML = '';
divFooterRight.innerHTML = '';
} catch (error) {
console.error('[template.html] Error updating footer:', error);
}
};
/* ================================================================
LOGOUT
================================================================
Complete logout process:
1. Stop update loop
2. Disconnect from NDK worker
3. Logout from nostr-login-lite
4. Clear all storage (localStorage, sessionStorage, IndexedDB)
5. Reload page
================================================================ */
const Logout = async () => {
console.log("[template.html] Starting logout process...");
// Stop the update loop
if (updateIntervalId) {
clearInterval(updateIntervalId);
updateIntervalId = null;
}
// Disconnect from worker
disconnect();
// Logout from nostr-login-lite
if (window.NOSTR_LOGIN_LITE && window.NOSTR_LOGIN_LITE.logout) {
await window.NOSTR_LOGIN_LITE.logout();
}
// Clear all storage
localStorage.clear();
sessionStorage.clear();
// Clear IndexedDB
if (window.indexedDB) {
const databases = await window.indexedDB.databases();
for (const db of databases) {
if (db.name) {
window.indexedDB.deleteDatabase(db.name);
}
}
}
console.log("[template.html] Logged out, reloading page");
location.reload(true);
};
/* ================================================================
EVENT LISTENERS
================================================================
Wire up UI interactions.
Main hamburger button click handler is set up in main() after initialization.
================================================================ */
/* ================================================================
SUBSCRIPTION EXAMPLE
================================================================
Example of how to subscribe to Nostr events:
const sub = subscribe(
{ kinds: [1], authors: [pubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
Cache usage options:
- 'CACHE_FIRST' - Check cache first, then relays
- 'ONLY_RELAY' - Only query relays
- 'ONLY_CACHE' - Only query cache
- 'PARALLEL' - Query cache and relays simultaneously
Listen for events via window events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log('Received event:', evt);
});
================================================================ */
/* ================================================================
PUBLISH EXAMPLE
================================================================
Example of how to publish a Nostr event:
const event = {
created_at: Math.floor(Date.now() / 1000),
kind: 1,
tags: [],
content: "Hello, Nostr!"
};
try {
const result = await publishEvent(event);
console.log("✅ Published to:", result.relayResults.successful);
console.log("❌ Failed:", result.relayResults.failed);
console.log("Total relays:", result.totalRelays);
} catch (error) {
console.error("Publish error:", error);
}
Note: Events are automatically signed by the NDK worker using
the message-based signer (which calls window.nostr.signEvent).
================================================================ */
/* ================================================================
USER SETTINGS EXAMPLE (NIP-78)
================================================================
Read/subscribe/write helper pattern for all pages:
// Read latest merged settings (cache + relay hydrated by worker)
const settings = await getUserSettings();
// Subscribe to cross-tab updates
const unsubscribe = onUserSettings((nextSettings) => {
// Re-render page from nextSettings
});
// Patch only your feature namespace
await patchUserSettings({
myFeature: {
someFlag: true
}
});
// On page teardown (if applicable)
// unsubscribe();
================================================================ */
/* ================================================================
INITIALIZATION
================================================================
Main initialization sequence:
1. Initialize hamburger menu
2. Set up hamburger click handler
3. Resolve auth mode from URL/query policy
4. Initialize authentication based on mode
5. Initialize authenticated-only features (if signed in)
6. Set up version bar button listeners
7. Restore sidenav state
8. Update version display
Notes:
- required mode = existing behavior (prompt login on load)
- optional mode = allow public load, login later on demand
- none mode = no auto-login on load
================================================================ */
(async function main() {
console.log("[template.html] Starting initialization...");
try {
// Initialize hamburger menu first
initHamburgerMenu();
// Add click handler to hamburger
const divSvgHam = document.getElementById('divSvgHam');
if (divSvgHam) {
divSvgHam.addEventListener('click', toggleNav);
}
// Initialize version bar buttons
const themeToggleButton = document.getElementById('themeToggleButton');
const logoutButton = document.getElementById('logoutButton');
if (themeToggleButton) {
themeToggleButton.addEventListener('click', () => {
isDarkMode = !isDarkMode;
if (isDarkMode) {
localStorage.setItem('theme', 'dark');
} else {
localStorage.setItem('theme', 'light');
}
// Save sidenav state before reload
localStorage.setItem('sidenavWasOpen', isNavOpen ? 'true' : 'false');
window.location.reload();
});
}
if (logoutButton) {
logoutButton.addEventListener('click', async () => {
try {
// In optional/none modes this doubles as a "Sign in" entry point.
if (!isAuthenticated) {
await promptLoginIfNeeded();
return;
}
await Logout();
} catch (error) {
console.error('Logout/login action failed:', error);
}
});
}
// Resolve and initialize page auth policy.
authMode = resolveAuthModeFromUrl();
console.log('[template.html] Resolved auth mode:', authMode);
await initializeAuthentication(authMode);
// Initialize authenticated features only when signed in.
await initializeAuthenticatedPageFeatures();
/* ============================================================
EXAMPLE: Subscribe to events
============================================================
Uncomment to subscribe to user's notes:
const notesSub = subscribe(
{ kinds: [1], authors: [currentPubkey], limit: 10 },
{ closeOnEose: false, cacheUsage: 'CACHE_FIRST' }
);
console.log("[template.html] Subscribed to kind 1");
============================================================ */
/* ============================================================
EXAMPLE: Listen for events from worker
============================================================
Uncomment to handle incoming events:
window.addEventListener('ndkEvent', (event) => {
const evt = event.detail;
console.log("[template.html] Received event:", evt.kind, evt.pubkey);
if (evt.pubkey === currentPubkey) {
if (evt.kind === 1) {
// Handle note event
console.log("Note:", evt.content);
}
}
});
============================================================ */
/* ============================================================
EXAMPLE: Listen for cached profile
============================================================
Uncomment to handle cached profile data:
window.addEventListener('ndkProfile', (event) => {
console.log("[template.html] Cached profile:", event.detail);
// event.detail contains profile object (name, about, etc.)
});
============================================================ */
// Restore sidenav state if it was open before theme toggle
const sidenavWasOpen = localStorage.getItem('sidenavWasOpen');
if (sidenavWasOpen === 'true') {
localStorage.removeItem('sidenavWasOpen');
openNav();
}
// Optional UX note for public mode pages.
if (!isAuthenticated && (authMode === 'optional' || authMode === 'none')) {
divFooterCenter.textContent = 'Public mode';
divFooterRight.textContent = 'Sign in from side menu for private features';
}
// Update version display
await updateVersionDisplay();
console.log('[template.html] Initialization complete');
} catch (error) {
console.error('[template.html] Initialization failed:', error);
divBody.innerHTML = `<div style="text-align: center; padding: 50px;">
<div style="font-size: 24px; margin-bottom: 20px; color: red;">❌ Authentication Error</div>
<div style="font-size: 16px; color: #666;">${error.message}</div>
<div style="margin-top: 20px;">
<button onclick="location.reload()" style="padding: 10px 20px; font-size: 16px;">Retry</button>
</div>
</div>`;
}
})();
/* ================================================================
WORKER MESSAGE TYPES
================================================================
The NDK worker can send these message types:
1. 'response' - Response to init/subscribe/publish requests
- data.profile - User profile (from init)
- data.relays - User relays (from init)
- data.success - Publish success status
- data.relayResults - Relay publish results
2. 'event' - Nostr event from subscription
- Dispatched as 'ndkEvent' window event
- event.detail contains the Nostr event
3. 'eose' - End of stored events for subscription
- Dispatched as 'ndkEose' window event
- event.detail.subId contains subscription ID
4. 'signRequest' - Request to sign event/encrypt/decrypt
- Handled automatically by init-ndk.mjs
- Calls window.nostr methods and sends response
5. 'error' - Error from worker
- Logged to console automatically
6. 'relayActivity' - Relay read/write activity notification
- Dispatched as 'ndkRelayActivity' window event
- Used to animate relay status icons in footer
================================================================ */
/* ================================================================
DISTRIBUTED ARCHITECTURE NOTES
================================================================
Each page is independently accessible and self-contained:
1. Authentication persists via nostr-login-lite localStorage
- Login once on any page
- All other pages automatically authenticated
2. NDK SharedWorker is shared across all tabs/pages
- Single NDK instance manages all connections
- Subscriptions from all pages handled by one worker
- Events broadcast to all connected pages
3. Dexie cache is shared across all pages
- IndexedDB persists across sessions
- Cache-first queries are fast
- Reduces relay load
4. User settings are centralized and shared
- Worker hydrates kind 30078 (`d:user-settings`) on init
- Pages read via getUserSettings()
- Pages patch via patchUserSettings({ featureNamespace: ... })
- Pages subscribe via onUserSettings() for live updates
5. Each page can be distributed independently
- Copy template.html and customize
- No dependencies on other pages
- Works standalone or as part of suite
6. Message-based signer bridges worker and page
- Worker's NDK uses MessageBasedSigner
- Signer sends sign requests to page
- Page calls window.nostr.signEvent()
- Response sent back to worker
- NDK completes signing and publishing
7. Relay status visualization
- Footer left section shows connected relays
- Each relay has animated icon (HamburgerMorphing)
- Icons morph based on activity (read/write)
- Temporary animations show real-time activity
================================================================ */
</script>
</body>
</html>
+463
View File
@@ -0,0 +1,463 @@
/**
* ASCIIBarChart - A dynamic ASCII-based vertical bar chart renderer
*
* Creates real-time animated bar charts using monospaced characters (X)
* with automatic scaling, labels, and responsive font sizing.
*/
class ASCIIBarChart {
/**
* Create a new ASCII bar chart
* @param {string} containerId - The ID of the HTML element to render the chart in
* @param {Object} options - Configuration options
* @param {number} [options.maxHeight=20] - Maximum height of the chart in rows
* @param {number} [options.maxDataPoints=30] - Maximum number of data columns before scrolling
* @param {string} [options.title=''] - Chart title (displayed centered at top)
* @param {string} [options.xAxisLabel=''] - X-axis label (displayed centered at bottom)
* @param {string} [options.yAxisLabel=''] - Y-axis label (displayed vertically on left)
* @param {boolean} [options.autoFitWidth=true] - Automatically adjust font size to fit container width
* @param {boolean} [options.useBinMode=false] - Enable time bin mode for data aggregation
* @param {number} [options.binDuration=10000] - Duration of each time bin in milliseconds (10 seconds default)
* @param {string} [options.xAxisLabelFormat='elapsed'] - X-axis label format: 'elapsed', 'bins', 'timestamps', 'ranges'
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.data = [];
this.maxHeight = options.maxHeight || 20;
this.maxDataPoints = options.maxDataPoints || 30;
this.totalDataPoints = 0; // Track total number of data points added
this.title = options.title || '';
this.xAxisLabel = options.xAxisLabel || '';
this.yAxisLabel = options.yAxisLabel || '';
this.autoFitWidth = options.autoFitWidth !== false; // Default to true
this.debug = options.debug || false; // Debug logging option
// Time bin configuration
this.useBinMode = options.useBinMode !== false; // Default to true
this.binDuration = options.binDuration || 4000; // 4 seconds default
this.xAxisLabelFormat = options.xAxisLabelFormat || 'elapsed';
// Time bin data structures
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.binCheckInterval = null;
this.chartStartTime = Date.now();
// Set up resize observer if auto-fit is enabled
if (this.autoFitWidth) {
this.resizeObserver = new ResizeObserver(() => {
this.adjustFontSize();
});
this.resizeObserver.observe(this.container);
}
// Initialize first bin if bin mode is enabled
if (this.useBinMode) {
this.initializeBins();
}
}
/**
* Add a new data point to the chart
* @param {number} value - The numeric value to add
*/
addValue(value) {
// Time bin mode: add value to current active bin count
this.checkBinRotation(); // Ensure we have an active bin
this.bins[this.currentBinIndex].count += value; // Changed from ++ to += value
this.totalDataPoints++;
this.render();
this.updateInfo();
}
/**
* Clear all data from the chart
*/
clear() {
this.data = [];
this.totalDataPoints = 0;
if (this.useBinMode) {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.initializeBins();
}
this.render();
this.updateInfo();
}
/**
* Calculate the width of the chart in characters
* @returns {number} The chart width in characters
* @private
*/
getChartWidth() {
let dataLength = this.maxDataPoints; // Always use maxDataPoints for consistent width
if (dataLength === 0) return 50; // Default width for empty chart
const yAxisPadding = this.yAxisLabel ? 2 : 0;
const yAxisNumbers = 3; // Width of Y-axis numbers
const separator = 1; // The '|' character
// const dataWidth = dataLength * 2; // Each column is 2 characters wide // TEMP: commented for no-space test
const dataWidth = dataLength; // Each column is 1 character wide // TEMP: adjusted for no-space columns
const padding = 1; // Extra padding
const totalWidth = yAxisPadding + yAxisNumbers + separator + dataWidth + padding;
// Only log when width changes
if (this.debug && this.lastChartWidth !== totalWidth) {
console.log('getChartWidth changed:', { dataLength, totalWidth, previous: this.lastChartWidth });
this.lastChartWidth = totalWidth;
}
return totalWidth;
}
/**
* Adjust font size to fit container width
* @private
*/
adjustFontSize() {
if (!this.autoFitWidth) return;
const containerWidth = this.container.clientWidth;
const chartWidth = this.getChartWidth();
if (chartWidth === 0) return;
// Calculate optimal font size
// For monospace fonts, character width is approximately 0.6 * font size
// Use a slightly smaller ratio to fit more content
const charWidthRatio = 0.7;
const padding = 30; // Reduce padding to fit more content
const availableWidth = containerWidth - padding;
const optimalFontSize = Math.floor((availableWidth / chartWidth) / charWidthRatio);
// Set reasonable bounds (min 4px, max 20px)
const fontSize = Math.max(4, Math.min(20, optimalFontSize));
// Only log when font size changes
if (this.debug && this.lastFontSize !== fontSize) {
console.log('fontSize changed:', { containerWidth, chartWidth, fontSize, previous: this.lastFontSize });
this.lastFontSize = fontSize;
}
this.container.style.fontSize = fontSize + 'px';
this.container.style.lineHeight = '1.0';
}
/**
* Render the chart to the container
* @private
*/
render() {
let dataToRender = [];
let maxValue = 0;
let minValue = 0;
let valueRange = 0;
if (this.useBinMode) {
// Bin mode: render bin counts
if (this.bins.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
// Always create a fixed-length array filled with 0s, then overlay actual bin data
dataToRender = new Array(this.maxDataPoints).fill(0);
// Overlay actual bin data (most recent bins, reversed for left-to-right display)
const startIndex = Math.max(0, this.bins.length - this.maxDataPoints);
const recentBins = this.bins.slice(startIndex);
// Reverse the bins so most recent is on the left, and overlay onto the fixed array
recentBins.reverse().forEach((bin, index) => {
if (index < this.maxDataPoints) {
dataToRender[index] = bin.count;
}
});
if (this.debug) {
console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length);
}
maxValue = Math.max(...dataToRender);
minValue = Math.min(...dataToRender);
valueRange = maxValue - minValue;
} else {
// Legacy mode: render individual values
if (this.data.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
dataToRender = this.data;
maxValue = Math.max(...this.data);
minValue = Math.min(...this.data);
valueRange = maxValue - minValue;
}
let output = '';
const scale = this.maxHeight;
// Calculate scaling factor: each X represents at least 1 count
const maxCount = Math.max(...dataToRender);
const scaleFactor = Math.max(1, Math.ceil(maxCount / scale)); // 1 X = scaleFactor counts
const scaledMax = Math.ceil(maxCount / scaleFactor) * scaleFactor;
// Calculate Y-axis label width (for vertical text)
const yLabelWidth = this.yAxisLabel ? 2 : 0;
const yAxisPadding = this.yAxisLabel ? ' ' : '';
// Add title if provided (centered)
if (this.title) {
// const chartWidth = 4 + this.maxDataPoints * 2; // Y-axis numbers + data columns // TEMP: commented for no-space test
const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space columns
const titlePadding = Math.floor((chartWidth - this.title.length) / 2);
output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n';
}
// Draw from top to bottom
for (let row = scale; row > 0; row--) {
let line = '';
// Add vertical Y-axis label character
if (this.yAxisLabel) {
const L = this.yAxisLabel.length;
const startRow = Math.floor((scale - L) / 2) + 1;
const relativeRow = scale - row + 1; // 1 at top, scale at bottom
if (relativeRow >= startRow && relativeRow < startRow + L) {
const labelIndex = relativeRow - startRow;
line += this.yAxisLabel[labelIndex] + ' ';
} else {
line += ' ';
}
}
// Calculate the actual count value this row represents (1 at bottom, increasing upward)
const rowCount = (row - 1) * scaleFactor + 1;
// Add Y-axis label (show actual count values)
line += String(rowCount).padStart(3, ' ') + ' |';
// Draw each column
for (let i = 0; i < dataToRender.length; i++) {
const count = dataToRender[i];
const scaledHeight = Math.ceil(count / scaleFactor);
if (scaledHeight >= row) {
// line += ' X'; // TEMP: commented out space between columns
line += 'X'; // TEMP: no space between columns
} else {
// line += ' '; // TEMP: commented out space between columns
line += ' '; // TEMP: single space for empty columns
}
}
output += line + '\n';
}
// Draw X-axis
// output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test
output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length
// Draw X-axis labels based on mode and format
let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis
// Determine label interval (every 5 columns)
const labelInterval = 5;
// Generate all labels first and store in array
let labels = [];
for (let i = 0; i < this.maxDataPoints; i++) {
if (i % labelInterval === 0) {
let label = '';
if (this.useBinMode) {
// For bin mode, show labels for all possible positions
// i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest)
const elapsedSec = (i * this.binDuration) / 1000;
// Format with appropriate precision for sub-second bins
if (this.binDuration < 1000) {
// Show decimal seconds for sub-second bins
label = elapsedSec.toFixed(1) + 's';
} else {
// Show whole seconds for 1+ second bins
label = String(Math.round(elapsedSec)) + 's';
}
} else {
// For legacy mode, show data point numbers
const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1);
label = String(startIndex + i);
}
labels.push(label);
}
}
// Build the label string with calculated spacing
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
xAxisLabels += label;
// Add spacing: labelInterval - label.length (except for last label)
if (i < labels.length - 1) {
const spacing = labelInterval - label.length;
xAxisLabels += ' '.repeat(spacing);
}
}
// Ensure the label line extends to match the X-axis dash line length
// The dash line is this.maxDataPoints characters long, starting after " +"
const dashLineLength = this.maxDataPoints;
const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " "
if (xAxisLabels.length < minLabelLineLength) {
xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length);
}
output += xAxisLabels + '\n';
// Add X-axis label if provided
if (this.xAxisLabel) {
// const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test
const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns
output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n';
}
this.container.textContent = output;
// Adjust font size to fit width (only once at initialization)
if (this.autoFitWidth) {
this.adjustFontSize();
}
// Update the external info display
if (this.useBinMode) {
const binCounts = this.bins.map(bin => bin.count);
const scaleFactor = Math.max(1, Math.ceil(maxValue / scale));
document.getElementById('values').textContent = `[${dataToRender.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, 1X=${scaleFactor} counts`;
} else {
document.getElementById('values').textContent = `[${this.data.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, Height: ${scale}`;
}
}
/**
* Update the info display
* @private
*/
updateInfo() {
if (this.useBinMode) {
const totalCount = this.bins.reduce((sum, bin) => sum + bin.count, 0);
document.getElementById('count').textContent = totalCount;
} else {
document.getElementById('count').textContent = this.data.length;
}
}
/**
* Initialize the bin system
* @private
*/
initializeBins() {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.chartStartTime = Date.now();
// Create first bin
this.rotateBin();
// Set up automatic bin rotation check
this.binCheckInterval = setInterval(() => {
this.checkBinRotation();
}, 100); // Check every 100ms for responsiveness
}
/**
* Check if current bin should rotate and create new bin if needed
* @private
*/
checkBinRotation() {
if (!this.useBinMode || !this.binStartTime) return;
const now = Date.now();
if ((now - this.binStartTime) >= this.binDuration) {
this.rotateBin();
}
}
/**
* Rotate to a new bin, finalizing the current one
*/
rotateBin() {
// Finalize current bin if it exists
if (this.currentBinIndex >= 0) {
this.bins[this.currentBinIndex].isActive = false;
}
// Create new bin
const newBin = {
startTime: Date.now(),
count: 0,
isActive: true
};
this.bins.push(newBin);
this.currentBinIndex = this.bins.length - 1;
this.binStartTime = newBin.startTime;
// Keep only the most recent bins
if (this.bins.length > this.maxDataPoints) {
this.bins.shift();
this.currentBinIndex--;
}
// Ensure currentBinIndex points to the last bin (the active one)
this.currentBinIndex = this.bins.length - 1;
// Force a render to update the display immediately
this.render();
this.updateInfo();
}
/**
* Format X-axis label for a bin based on the configured format
* @param {number} binIndex - Index of the bin
* @returns {string} Formatted label
* @private
*/
formatBinLabel(binIndex) {
const bin = this.bins[binIndex];
if (!bin) return ' ';
switch (this.xAxisLabelFormat) {
case 'bins':
return String(binIndex + 1).padStart(2, ' ');
case 'timestamps':
const time = new Date(bin.startTime);
return time.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/:/g, '');
case 'ranges':
const startSec = Math.floor((bin.startTime - this.chartStartTime) / 1000);
const endSec = startSec + Math.floor(this.binDuration / 1000);
return `${startSec}-${endSec}`;
case 'elapsed':
default:
// For elapsed time, always show time relative to the first bin (index 0)
// This keeps the leftmost label as 0s and increases to the right
const firstBinTime = this.bins[0] ? this.bins[0].startTime : this.chartStartTime;
const elapsedSec = Math.floor((bin.startTime - firstBinTime) / 1000);
return String(elapsedSec).padStart(2, ' ') + 's';
}
}
}
-616
View File
@@ -1,616 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1"; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Global variables
COMMIT_MESSAGE=""
RELEASE_MODE=false
show_usage() {
echo "C-Relay Build and Push Script"
echo ""
echo "Usage:"
echo " $0 \"commit message\" - Default: compile, increment patch, commit & push"
echo " $0 -r \"commit message\" - Release: compile x86+arm64, increment minor, create release"
echo ""
echo "Examples:"
echo " $0 \"Fixed event validation bug\""
echo " $0 --release \"Major release with new features\""
echo ""
echo "Default Mode (patch increment):"
echo " - Compile C-Relay"
echo " - Increment patch version (v1.2.3 → v1.2.4)"
echo " - Git add, commit with message, and push"
echo ""
echo "Release Mode (-r flag):"
echo " - Compile C-Relay for x86_64 and arm64 (dynamic and static versions)"
echo " - Increment minor version, zero patch (v1.2.3 → v1.3.0)"
echo " - Git add, commit, push, and create Gitea release"
echo ""
echo "Requirements for Release Mode:"
echo " - For ARM64 builds: make install-arm64-deps (optional - will build x86_64 only if missing)"
echo " - For static builds: sudo apt-get install musl-dev libcap-dev libuv1-dev libev-dev"
echo " - Gitea token in ~/.gitea_token for release uploads"
}
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-r|--release)
RELEASE_MODE=true
shift
;;
-h|--help)
show_usage
exit 0
;;
*)
# First non-flag argument is the commit message
if [[ -z "$COMMIT_MESSAGE" ]]; then
COMMIT_MESSAGE="$1"
fi
shift
;;
esac
done
# Validate inputs
if [[ -z "$COMMIT_MESSAGE" ]]; then
print_error "Commit message is required"
echo ""
show_usage
exit 1
fi
# Check if we're in a git repository
check_git_repo() {
if ! git rev-parse --git-dir > /dev/null 2>&1; then
print_error "Not in a git repository"
exit 1
fi
}
# Function to get current version and increment appropriately
increment_version() {
local increment_type="$1" # "patch" or "minor"
print_status "Getting current version..."
# Get the highest version tag (not chronologically latest)
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "")
if [[ -z "$LATEST_TAG" ]]; then
LATEST_TAG="v0.0.0"
print_warning "No version tags found, starting from $LATEST_TAG"
fi
# Extract version components (remove 'v' prefix)
VERSION=${LATEST_TAG#v}
# Parse major.minor.patch using regex
if [[ $VERSION =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
MAJOR=${BASH_REMATCH[1]}
MINOR=${BASH_REMATCH[2]}
PATCH=${BASH_REMATCH[3]}
else
print_error "Invalid version format in tag: $LATEST_TAG"
print_error "Expected format: v0.1.0"
exit 1
fi
# Increment version based on type
if [[ "$increment_type" == "minor" ]]; then
# Minor release: increment minor, zero patch
NEW_MINOR=$((MINOR + 1))
NEW_PATCH=0
NEW_VERSION="v${MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
print_status "Release mode: incrementing minor version"
else
# Default: increment patch
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}"
print_status "Default mode: incrementing patch version"
fi
print_status "Current version: $LATEST_TAG"
print_status "New version: $NEW_VERSION"
# Export for use in other functions
export NEW_VERSION
}
# Function to compile the C-Relay project
compile_project() {
print_status "Compiling C-Relay..."
# Clean previous build
if make clean > /dev/null 2>&1; then
print_success "Cleaned previous build"
else
print_warning "Clean failed or no Makefile found"
fi
# Force regenerate main.h to pick up new tags
if make force-version > /dev/null 2>&1; then
print_success "Regenerated main.h"
else
print_warning "Failed to regenerate main.h"
fi
# Compile the project
if make > /dev/null 2>&1; then
print_success "C-Relay compiled successfully"
else
print_error "Compilation failed"
exit 1
fi
}
# Function to build release binaries
build_release_binaries() {
print_status "Building release binaries..."
# Build x86_64 version
print_status "Building x86_64 version..."
make clean > /dev/null 2>&1
if make x86 > /dev/null 2>&1; then
if [[ -f "build/c_relay_x86" ]]; then
cp build/c_relay_x86 c-relay-x86_64
print_success "x86_64 binary created: c-relay-x86_64"
else
print_error "x86_64 binary not found after compilation"
exit 1
fi
else
print_error "x86_64 build failed"
exit 1
fi
# Try to build ARM64 version
print_status "Attempting ARM64 build..."
make clean > /dev/null 2>&1
if make arm64 > /dev/null 2>&1; then
if [[ -f "build/c_relay_arm64" ]]; then
cp build/c_relay_arm64 c-relay-arm64
print_success "ARM64 binary created: c-relay-arm64"
else
print_warning "ARM64 binary not found after compilation"
fi
else
print_warning "ARM64 build failed - ARM64 cross-compilation not properly set up"
print_status "Only x86_64 binary will be included in release"
fi
# Build static x86_64 version
print_status "Building static x86_64 version..."
make clean > /dev/null 2>&1
if make static-musl-x86_64 > /dev/null 2>&1; then
if [[ -f "build/c_relay_static_musl_x86_64" ]]; then
cp build/c_relay_static_musl_x86_64 c-relay-static-x86_64
print_success "Static x86_64 binary created: c-relay-static-x86_64"
else
print_warning "Static x86_64 binary not found after compilation"
fi
else
print_warning "Static x86_64 build failed - MUSL development packages may not be installed"
print_status "Run 'sudo apt-get install musl-dev libcap-dev libuv1-dev libev-dev' to enable static builds"
fi
# Try to build static ARM64 version
print_status "Attempting static ARM64 build..."
make clean > /dev/null 2>&1
if make static-musl-arm64 > /dev/null 2>&1; then
if [[ -f "build/c_relay_static_musl_arm64" ]]; then
cp build/c_relay_static_musl_arm64 c-relay-static-arm64
print_success "Static ARM64 binary created: c-relay-static-arm64"
else
print_warning "Static ARM64 binary not found after compilation"
fi
else
print_warning "Static ARM64 build failed - ARM64 cross-compilation or MUSL ARM64 packages not set up"
fi
# Restore normal build
make clean > /dev/null 2>&1
make > /dev/null 2>&1
}
# Function to commit and push changes
git_commit_and_push() {
print_status "Preparing git commit..."
# Stage all changes
if git add . > /dev/null 2>&1; then
print_success "Staged all changes"
else
print_error "Failed to stage changes"
exit 1
fi
# Check if there are changes to commit
if git diff --staged --quiet; then
print_warning "No changes to commit"
else
# Commit changes
if git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" > /dev/null 2>&1; then
print_success "Committed changes"
else
print_error "Failed to commit changes"
exit 1
fi
fi
# Create new git tag
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
print_warning "Tag $NEW_VERSION already exists"
fi
# Push changes and tags
print_status "Pushing to remote repository..."
if git push > /dev/null 2>&1; then
print_success "Pushed changes"
else
print_error "Failed to push changes"
exit 1
fi
# Push only the new tag to avoid conflicts with existing tags
if git push origin "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Pushed tag: $NEW_VERSION"
else
print_warning "Tag push failed, trying force push..."
if git push --force origin "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Force-pushed updated tag: $NEW_VERSION"
else
print_error "Failed to push tag: $NEW_VERSION"
exit 1
fi
fi
}
# Function to commit and push changes without creating a tag (tag already created)
git_commit_and_push_no_tag() {
print_status "Preparing git commit..."
# Stage all changes
if git add . > /dev/null 2>&1; then
print_success "Staged all changes"
else
print_error "Failed to stage changes"
exit 1
fi
# Check if there are changes to commit
if git diff --staged --quiet; then
print_warning "No changes to commit"
else
# Commit changes
if git commit -m "$NEW_VERSION - $COMMIT_MESSAGE" > /dev/null 2>&1; then
print_success "Committed changes"
else
print_error "Failed to commit changes"
exit 1
fi
fi
# Push changes and tags
print_status "Pushing to remote repository..."
if git push > /dev/null 2>&1; then
print_success "Pushed changes"
else
print_error "Failed to push changes"
exit 1
fi
# Push only the new tag to avoid conflicts with existing tags
if git push origin "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Pushed tag: $NEW_VERSION"
else
print_warning "Tag push failed, trying force push..."
if git push --force origin "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Force-pushed updated tag: $NEW_VERSION"
else
print_error "Failed to push tag: $NEW_VERSION"
exit 1
fi
fi
}
# Function to create Gitea release
create_gitea_release() {
print_status "Creating Gitea release..."
# Check for Gitea token
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping release creation."
print_warning "Create ~/.gitea_token with your Gitea access token to enable releases."
return 0
fi
local token=$(cat "$HOME/.gitea_token" | tr -d '\n\r')
local api_url="https://git.laantungir.net/api/v1/repos/laantungir/c-relay"
# Create release
print_status "Creating release $NEW_VERSION..."
local response=$(curl -s -X POST "$api_url/releases" \
-H "Authorization: token $token" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"$NEW_VERSION\", \"name\": \"$NEW_VERSION\", \"body\": \"$COMMIT_MESSAGE\"}")
local upload_result=false
if echo "$response" | grep -q '"id"'; then
print_success "Created release $NEW_VERSION"
if upload_release_binaries "$api_url" "$token"; then
upload_result=true
fi
elif echo "$response" | grep -q "already exists"; then
print_warning "Release $NEW_VERSION already exists"
if upload_release_binaries "$api_url" "$token"; then
upload_result=true
fi
else
print_error "Failed to create release $NEW_VERSION"
print_error "Response: $response"
# Try to check if the release exists anyway
print_status "Checking if release exists..."
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then
print_warning "Release exists but creation response was unexpected"
if upload_release_binaries "$api_url" "$token"; then
upload_result=true
fi
else
print_error "Release does not exist and creation failed"
return 1
fi
fi
# Return based on upload success
if [[ "$upload_result" == true ]]; then
return 0
else
print_error "Binary upload failed"
return 1
fi
}
# Function to upload release binaries
upload_release_binaries() {
local api_url="$1"
local token="$2"
local upload_success=true
# Get release ID with more robust parsing
print_status "Getting release ID for $NEW_VERSION..."
local response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -n1 | cut -d: -f2)
if [[ -z "$release_id" ]]; then
print_error "Could not get release ID for $NEW_VERSION"
print_error "API Response: $response"
# Try to list all releases to debug
print_status "Available releases:"
curl -s -H "Authorization: token $token" "$api_url/releases" | grep -o '"tag_name":"[^"]*"' | head -5
return 1
fi
print_success "Found release ID: $release_id"
# Upload x86_64 binary
if [[ -f "c-relay-x86_64" ]]; then
print_status "Uploading x86_64 binary..."
local upload_response=$(curl -s -w "\n%{http_code}" -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@c-relay-x86_64;filename=c-relay-${NEW_VERSION}-linux-x86_64")
local http_code=$(echo "$upload_response" | tail -n1)
local response_body=$(echo "$upload_response" | head -n -1)
if [[ "$http_code" == "201" ]]; then
print_success "Uploaded x86_64 binary successfully"
else
print_error "Failed to upload x86_64 binary (HTTP $http_code)"
print_error "Response: $response_body"
upload_success=false
fi
else
print_warning "x86_64 binary not found: c-relay-x86_64"
fi
# Upload ARM64 binary
if [[ -f "c-relay-arm64" ]]; then
print_status "Uploading ARM64 binary..."
local upload_response=$(curl -s -w "\n%{http_code}" -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@c-relay-arm64;filename=c-relay-${NEW_VERSION}-linux-arm64")
local http_code=$(echo "$upload_response" | tail -n1)
local response_body=$(echo "$upload_response" | head -n -1)
if [[ "$http_code" == "201" ]]; then
print_success "Uploaded ARM64 binary successfully"
else
print_error "Failed to upload ARM64 binary (HTTP $http_code)"
print_error "Response: $response_body"
upload_success=false
fi
else
print_warning "ARM64 binary not found: c-relay-arm64"
fi
# Upload static x86_64 binary
if [[ -f "c-relay-static-x86_64" ]]; then
print_status "Uploading static x86_64 binary..."
local upload_response=$(curl -s -w "\n%{http_code}" -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@c-relay-static-x86_64;filename=c-relay-${NEW_VERSION}-linux-x86_64-static")
local http_code=$(echo "$upload_response" | tail -n1)
local response_body=$(echo "$upload_response" | head -n -1)
if [[ "$http_code" == "201" ]]; then
print_success "Uploaded static x86_64 binary successfully"
else
print_error "Failed to upload static x86_64 binary (HTTP $http_code)"
print_error "Response: $response_body"
upload_success=false
fi
else
print_warning "Static x86_64 binary not found: c-relay-static-x86_64"
fi
# Upload static ARM64 binary
if [[ -f "c-relay-static-arm64" ]]; then
print_status "Uploading static ARM64 binary..."
local upload_response=$(curl -s -w "\n%{http_code}" -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@c-relay-static-arm64;filename=c-relay-${NEW_VERSION}-linux-arm64-static")
local http_code=$(echo "$upload_response" | tail -n1)
local response_body=$(echo "$upload_response" | head -n -1)
if [[ "$http_code" == "201" ]]; then
print_success "Uploaded static ARM64 binary successfully"
else
print_error "Failed to upload static ARM64 binary (HTTP $http_code)"
print_error "Response: $response_body"
upload_success=false
fi
else
print_warning "Static ARM64 binary not found: c-relay-static-arm64"
fi
# Return success/failure status
if [[ "$upload_success" == true ]]; then
return 0
else
return 1
fi
}
# Function to clean up release binaries
cleanup_release_binaries() {
local force_cleanup="$1" # Optional parameter to force cleanup even on failure
if [[ "$force_cleanup" == "force" ]] || [[ "$upload_success" == true ]]; then
if [[ -f "c-relay-x86_64" ]]; then
rm -f c-relay-x86_64
print_status "Cleaned up x86_64 binary"
fi
if [[ -f "c-relay-arm64" ]]; then
rm -f c-relay-arm64
print_status "Cleaned up ARM64 binary"
fi
if [[ -f "c-relay-static-x86_64" ]]; then
rm -f c-relay-static-x86_64
print_status "Cleaned up static x86_64 binary"
fi
if [[ -f "c-relay-static-arm64" ]]; then
rm -f c-relay-static-arm64
print_status "Cleaned up static ARM64 binary"
fi
else
print_warning "Keeping binary files due to upload failures"
print_status "Files available for manual upload:"
if [[ -f "c-relay-x86_64" ]]; then
print_status " - c-relay-x86_64"
fi
if [[ -f "c-relay-arm64" ]]; then
print_status " - c-relay-arm64"
fi
if [[ -f "c-relay-static-x86_64" ]]; then
print_status " - c-relay-static-x86_64"
fi
if [[ -f "c-relay-static-arm64" ]]; then
print_status " - c-relay-static-arm64"
fi
fi
}
# Main execution
main() {
print_status "C-Relay Build and Push Script"
# Check prerequisites
check_git_repo
if [[ "$RELEASE_MODE" == true ]]; then
print_status "=== RELEASE MODE ==="
# Increment minor version for releases
increment_version "minor"
# Create new git tag BEFORE compilation so version.h picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
print_warning "Tag $NEW_VERSION already exists, removing and recreating..."
git tag -d "$NEW_VERSION" > /dev/null 2>&1
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
# Compile project first (will now pick up the new tag)
compile_project
# Build release binaries
build_release_binaries
# Commit and push (but skip tag creation since we already did it)
git_commit_and_push_no_tag
# Create Gitea release with binaries
if create_gitea_release; then
print_success "Release $NEW_VERSION completed successfully!"
print_status "Binaries uploaded to Gitea release"
upload_success=true
else
print_error "Release creation or binary upload failed"
upload_success=false
fi
# Cleanup (only if upload was successful)
cleanup_release_binaries
else
print_status "=== DEFAULT MODE ==="
# Increment patch version for regular commits
increment_version "patch"
# Create new git tag BEFORE compilation so version.h picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then
print_success "Created tag: $NEW_VERSION"
else
print_warning "Tag $NEW_VERSION already exists, removing and recreating..."
git tag -d "$NEW_VERSION" > /dev/null 2>&1
git tag "$NEW_VERSION" > /dev/null 2>&1
fi
# Compile project (will now pick up the new tag)
compile_project
# Commit and push (but skip tag creation since we already did it)
git_commit_and_push_no_tag
print_success "Build and push completed successfully!"
print_status "Version $NEW_VERSION pushed to repository"
fi
}
# Execute main function
main
+95 -14
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# Build fully static MUSL binaries for C-Relay using Alpine Docker
# Build fully static MUSL binaries for C-Relay-PG using Alpine Docker
# Produces truly portable binaries with zero runtime dependencies
set -e
@@ -9,11 +9,54 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder"
echo "=========================================="
# Parse command line arguments
DEBUG_BUILD=false
DB_BACKEND="${DB_BACKEND:-postgres}"
while [[ $# -gt 0 ]]; do
case "$1" in
--debug)
DEBUG_BUILD=true
shift
;;
--db-backend)
if [[ -z "$2" ]]; then
echo "ERROR: --db-backend requires a value (sqlite|postgres)"
exit 1
fi
DB_BACKEND="$2"
shift 2
;;
--db-backend=*)
DB_BACKEND="${1#*=}"
shift
;;
*)
echo "ERROR: Unknown argument: $1"
echo "Usage: $0 [--debug] [--db-backend postgres|sqlite]"
exit 1
;;
esac
done
if [[ "$DB_BACKEND" != "sqlite" && "$DB_BACKEND" != "postgres" ]]; then
echo "ERROR: Invalid DB backend '$DB_BACKEND'. Use sqlite or postgres."
exit 1
fi
if [[ "$DEBUG_BUILD" == "true" ]]; then
echo "=========================================="
echo "C-Relay-PG MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "C-Relay-PG MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Debug build: $DEBUG_BUILD"
echo "DB backend: $DB_BACKEND"
echo ""
# Create build directory
@@ -53,24 +96,52 @@ ARCH=$(uname -m)
case "$ARCH" in
x86_64)
PLATFORM="linux/amd64"
OUTPUT_NAME="c_relay_static_x86_64"
OUTPUT_NAME="c_relay_pg_static_x86_64"
;;
aarch64|arm64)
PLATFORM="linux/arm64"
OUTPUT_NAME="c_relay_static_arm64"
OUTPUT_NAME="c_relay_pg_static_arm64"
;;
*)
echo "WARNING: Unknown architecture: $ARCH"
echo "Defaulting to linux/amd64"
PLATFORM="linux/amd64"
OUTPUT_NAME="c_relay_static_${ARCH}"
OUTPUT_NAME="c_relay_pg_static_${ARCH}"
;;
esac
# Append _debug suffix to output name for debug builds so production binary is never overwritten
if [ "$DEBUG_BUILD" = true ]; then
OUTPUT_NAME="${OUTPUT_NAME}_debug"
fi
echo "Building for platform: $PLATFORM"
echo "Output binary: $OUTPUT_NAME"
echo ""
# Check if Alpine base image is cached
echo "Checking for cached Alpine Docker image..."
if ! docker images alpine:3.19 --format "{{.Repository}}:{{.Tag}}" | grep -q "alpine:3.19"; then
echo "⚠ Alpine 3.19 image not found in cache"
echo "Attempting to pull Alpine 3.19 image..."
if ! docker pull alpine:3.19; then
echo ""
echo "ERROR: Failed to pull Alpine 3.19 image"
echo "This is required for the static build."
echo ""
echo "Possible solutions:"
echo " 1. Check your internet connection"
echo " 2. Try again later (Docker Hub may be temporarily unavailable)"
echo " 3. If you have IPv6 issues, disable IPv6 for Docker"
echo ""
exit 1
fi
echo "✓ Alpine 3.19 image pulled successfully"
else
echo "✓ Alpine 3.19 image found in cache"
fi
echo ""
# Build the Docker image
echo "=========================================="
echo "Step 1: Building Alpine Docker image"
@@ -78,13 +149,15 @@ echo "=========================================="
echo "This will:"
echo " - Use Alpine Linux (native MUSL)"
echo " - Build all dependencies statically"
echo " - Compile c-relay with full static linking"
echo " - Compile c-relay-pg with full static linking"
echo ""
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--build-arg DB_BACKEND=$DB_BACKEND \
-f "$DOCKERFILE" \
-t c-relay-musl-builder:latest \
-t c-relay-pg-musl-builder:latest \
--progress=plain \
. || {
echo ""
@@ -105,16 +178,18 @@ echo "=========================================="
# Build the builder stage to extract the binary
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--build-arg DB_BACKEND=$DB_BACKEND \
--target builder \
-f "$DOCKERFILE" \
-t c-relay-static-builder-stage:latest \
-t c-relay-pg-static-builder-stage:latest \
. > /dev/null 2>&1
# Create a temporary container to copy the binary
CONTAINER_ID=$($DOCKER_CMD create c-relay-static-builder-stage:latest)
CONTAINER_ID=$($DOCKER_CMD create c-relay-pg-static-builder-stage:latest)
# Copy binary from container
$DOCKER_CMD cp "$CONTAINER_ID:/build/c_relay_static" "$BUILD_DIR/$OUTPUT_NAME" || {
$DOCKER_CMD cp "$CONTAINER_ID:/build/c_relay_pg_static" "$BUILD_DIR/$OUTPUT_NAME" || {
echo "ERROR: Failed to extract binary from container"
$DOCKER_CMD rm "$CONTAINER_ID" 2>/dev/null
exit 1
@@ -179,11 +254,17 @@ echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Platform: $PLATFORM"
if [ "$DEBUG_BUILD" = true ]; then
echo "Build Type: DEBUG (with symbols, no optimization)"
else
echo "Build Type: PRODUCTION (optimized, stripped)"
fi
echo "DB Backend: $DB_BACKEND"
if [ "$TRULY_STATIC" = true ]; then
echo "Type: Fully static binary (Alpine MUSL-based)"
echo "Linkage: Fully static binary (Alpine MUSL-based)"
echo "Portability: Works on ANY Linux distribution"
else
echo "Type: Static binary (may have minimal dependencies)"
echo "Linkage: Static binary (may have minimal dependencies)"
fi
echo ""
echo "✓ Build complete!"
+31
View File
@@ -0,0 +1,31 @@
# Database files (from c-relay running in this directory)
*.db
*.db-shm
*.db-wal
# Build artifacts
*.o
*.a
# Binary (built into root, not version-controlled)
caching_relay
caching_relay_static_*
# Logs
*.log
crelay.log
# Test configs (not for version control)
test_config*.jsonc
# Note: caching_relay_config.jsonc IS version-controlled (it's the default
# config + state store). Don't ignore it.
# Docker build context temp (copied nostr_core_lib during static build)
nostr_core_lib/
# Editor
*.swp
*.swo
*~
.vscode/
+128
View File
@@ -0,0 +1,128 @@
# Alpine-based MUSL static binary builder for caching_relay
# Produces a truly portable binary with zero runtime dependencies.
# Adapted from c-relay's Dockerfile.alpine-musl.
ARG DEBUG_BUILD=false
FROM alpine:3.19 AS builder
ARG DEBUG_BUILD=false
# Install build dependencies
RUN apk add --no-cache \
build-base \
musl-dev \
git \
cmake \
pkgconfig \
autoconf \
automake \
libtool \
openssl-dev \
openssl-libs-static \
zlib-dev \
zlib-static \
curl-dev \
curl-static \
sqlite-dev \
postgresql-dev \
linux-headers \
wget \
bash
WORKDIR /build
# Build libsecp256k1 static
RUN cd /tmp && \
git clone https://github.com/bitcoin-core/secp256k1.git && \
cd secp256k1 && \
./autogen.sh && \
./configure --enable-static --disable-shared --prefix=/usr \
CFLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/secp256k1
# Build libwebsockets static with minimal features
RUN cd /tmp && \
git clone --depth 1 --branch v4.3.3 https://github.com/warmcat/libwebsockets.git && \
cd libwebsockets && \
mkdir build && cd build && \
cmake .. \
-DLWS_WITH_STATIC=ON \
-DLWS_WITH_SHARED=OFF \
-DLWS_WITH_SSL=ON \
-DLWS_WITHOUT_TESTAPPS=ON \
-DLWS_WITHOUT_TEST_SERVER=ON \
-DLWS_WITHOUT_TEST_CLIENT=ON \
-DLWS_WITHOUT_TEST_PING=ON \
-DLWS_WITH_HTTP2=OFF \
-DLWS_WITH_LIBUV=OFF \
-DLWS_WITH_LIBEVENT=OFF \
-DLWS_IPV6=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=/usr \
-DCMAKE_C_FLAGS="-fPIC" && \
make -j$(nproc) && \
make install && \
rm -rf /tmp/libwebsockets
# Copy nostr_core_lib source (sibling project)
COPY nostr_core_lib /build/nostr_core_lib/
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
RUN cd nostr_core_lib && \
chmod +x build.sh && \
sed -i 's/CFLAGS="-Wall -Wextra -std=c99 -fPIC -O2"/CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"/' build.sh && \
rm -f *.o *.a 2>/dev/null || true && \
./build.sh --nips=1,4,6,19,42,44 && \
if [ -f libnostr_core_arm64.a ]; then \
cp libnostr_core_arm64.a libnostr_core.a; \
elif [ -f libnostr_core_x64.a ]; then \
cp libnostr_core_x64.a libnostr_core.a; \
else \
echo "ERROR: No supported nostr_core static library produced"; \
ls -la *.a 2>/dev/null || true; \
exit 1; \
fi
# Copy caching_relay source LAST (only this layer rebuilds on source changes)
COPY src/ /build/src/
# Build caching_relay with full static linking.
# No sqlite (the daemon doesn't use it), no c_utils (not needed).
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O2 -DDEBUG -fno-omit-frame-pointer"; \
STRIP_CMD="echo 'Keeping debug symbols'"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/caching_relay_static"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Isrc -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
-I/usr/include/postgresql \
src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
src/backfill.c src/relay_discovery.c \
src/pg_inbox.c src/pg_config.c src/forward_catchup.c \
-o /build/caching_relay_static \
nostr_core_lib/libnostr_core.a \
-lwebsockets -lssl -lcrypto -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl -lpq -lpgcommon -lpgport && \
eval "$STRIP_CMD"
# Verify it's truly static
RUN echo "=== Binary Information ===" && \
file /build/caching_relay_static && \
ls -lh /build/caching_relay_static && \
echo "=== Checking for dynamic dependencies ===" && \
(ldd /build/caching_relay_static 2>&1 || echo "Binary is static") && \
echo "=== Build complete ==="
# Output stage - just the binary
FROM scratch AS output
COPY --from=builder /build/caching_relay_static /caching_relay_static
+67
View File
@@ -0,0 +1,67 @@
# caching_relay Makefile - statically linked C99 binary, c-relay style
# Use bash for reliable glob expansion in recipes (dash handles globs differently).
SHELL := /bin/bash
CC = gcc
CFLAGS = -Wall -Wextra -std=c99 -g -O2
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
NOSTR_CORE_DIR = ../nostr_core_lib
INCLUDES = -I. -Isrc -I$(NOSTR_CORE_DIR) -I$(NOSTR_CORE_DIR)/nostr_core \
-I$(NOSTR_CORE_DIR)/cjson -I$(NOSTR_CORE_DIR)/nostr_websocket \
-I/usr/include/postgresql
# -lsqlite3: nostr_core_lib's request_validator.x64.o references SQLite symbols;
# we use --whole-archive so the linker pulls in all objects (needed for NIP-42
# cross-references within the archive), which means SQLite must be satisfied.
# No c_utils_lib: nostr_core_lib does not depend on it for the NIPs we use.
# -lpq: PostgreSQL client library for the caching_event_inbox integration.
LIBS = -lwebsockets -lssl -lcrypto -lsecp256k1 -lcurl -lz -ldl -lpthread -lm -lpq -lsqlite3
MAIN_SRC = src/main.c src/debug.c src/jsonc_strip.c src/config.c src/state.c \
src/follow_graph.c src/relay_sink.c src/live_subscriber.c \
src/backfill.c src/forward_catchup.c src/relay_discovery.c \
src/pg_inbox.c src/pg_config.c
# Architecture detection
ARCH = $(shell uname -m)
ifeq ($(ARCH),x86_64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
else ifeq ($(ARCH),aarch64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
else ifeq ($(ARCH),arm64)
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_arm64.a
else
NOSTR_CORE_LIB = $(NOSTR_CORE_DIR)/libnostr_core_x64.a
endif
# Binary goes in the c-relay-pg build/ directory so the launcher can find it.
TARGET = ../build/caching_relay
all: $(TARGET)
# Build nostr_core_lib with the NIPs needed by the relay pool + signer:
# 1 (basic), 6 (keys), 19 (npub bech32), 4 (legacy encryption, signer dep),
# 42 (auth, relay pool dep), 44 (modern encryption, signer dep)
$(NOSTR_CORE_LIB):
@echo "Building nostr_core_lib with required NIPs..."
cd $(NOSTR_CORE_DIR) && ./build.sh --nips=1,4,6,19,42,44
$(TARGET): $(MAIN_SRC) $(NOSTR_CORE_LIB)
@echo "Compiling caching_relay for architecture: $(ARCH)"
@# Extract all objects from the static library and link them directly.
@# This avoids archive symbol resolution ordering issues (NIP-42 cross-refs).
@# Use a fixed temp dir and chain all commands with && so failures stop the build.
@rm -rf /tmp/cr_lib && mkdir -p /tmp/cr_lib && \
ar x $(NOSTR_CORE_LIB) --output=/tmp/cr_lib && \
echo "Extracted $$(ls /tmp/cr_lib/*.o | wc -l) objects" && \
$(CC) $(CFLAGS) $(INCLUDES) $(MAIN_SRC) /tmp/cr_lib/*.o -o $(TARGET) $(LIBS) && \
rm -rf /tmp/cr_lib && \
echo "Build complete: $(TARGET)"
clean:
rm -f $(TARGET)
.PHONY: all clean
+297
View File
@@ -0,0 +1,297 @@
# caching_relay
A C99 daemon that caches Nostr events from people you follow into a local relay,
so your Nostr client can point at a single fast local relay instead of fanning
out to dozens of upstream relays.
## What it does
1. Reads a `.jsonc` config file listing your **root npub(s)**, upstream relays,
a local relay URL, and the event kinds to cache.
2. For each root npub, fetches its kind-3 contact list to discover **followed
pubkeys** (your follows + their follows).
3. **Live-subscribes** to new events of the configured kinds from the union of
followed pubkeys.
4. **Backfills** historical events using a progressive window-expansion strategy
(24h → 7d → 30d → 90d → 365d), round-robin per pubkey, throttled to be polite
to upstream relays.
5. **Re-publishes** every fetched event to the local relay via a plain WebSocket
`EVENT` client connection (relay-agnostic - works with c-relay, c-relay-pg,
or any Nostr relay).
6. Persists its backfill progress **in the config file itself** so a restart
resumes where it left off.
## Build
### Static binary (recommended, c-relay style)
Produces a truly portable statically-linked MUSL binary with zero runtime
dependencies:
```bash
./build_static.sh
# or with debug symbols:
./build_static.sh --debug
# or cross-compile for arm64:
./build_static.sh --arch arm64
```
Output: `caching_relay` (in the project root).
Requires Docker. The build runs in an Alpine container that compiles
libsecp256k1, libwebsockets, and nostr_core_lib from source, then statically
links everything.
### Local build (if you have the shared libs installed)
```bash
make
```
Output: `caching_relay` (in the project root).
Requires: `libwebsockets`, `openssl`, `libsecp256k1`, `libcurl`, `zlib` shared
libraries, and a pre-built `../nostr_core_lib/libnostr_core_x64.a`.
## Usage
```bash
./caching_relay -d 3
```
The daemon looks for `caching_relay_config.jsonc` in the current directory by default.
You can override with `-c`:
```bash
./caching_relay -c /path/to/my_config.jsonc -d 3
```
Options:
- `-c, --config <file>` - Path to `.jsonc` config file (default: `./caching_relay_config.jsonc`)
- `-d, --debug <level>` - Log level 1-4 (1=error, 2=warn, 3=info, 4=debug). Default 3.
- `-h, --help` - Show help
Signals:
- `SIGINT` / `SIGTERM` - Graceful shutdown (saves state, closes connections)
- `SIGHUP` - Reload config (preserves backfill state)
## Config file
The config file [`caching_relay_config.jsonc`](caching_relay_config.jsonc) lives in the
project root. It is JSONC (JSON with `//` and `/* */` comments). The daemon
rewrites it as plain JSON when saving state (comments are not preserved on
rewrite).
Key fields:
| Field | Description |
|-------|-------------|
| `root_npubs` | npubs whose kind-3 follows list we crawl |
| `upstream_relays` | bootstrap relays for initial discovery (kind-3, kind-10002). Outbox relays are discovered dynamically via NIP-65. |
| `local_relay` | relay to publish cached events into (never queried) |
| `kinds` | event kinds to cache for followed people (e.g. `[1, 3, 6, 10000, 30023]`) |
| `admin_kinds` | kinds to follow specifically for root (admin) npubs. Use `["*"]` for all kinds. If omitted, admin uses same `kinds` as everyone else. |
| `backfill.window_schedule_seconds` | progressive window sizes in seconds |
| `backfill.events_per_tick` | max events per pubkey per backfill tick |
| `backfill.tick_interval_seconds` | delay between pubkey backfills (throttle) |
| `live.enabled` | enable live subscription |
| `follow_graph_refresh_seconds` | how often to re-resolve the follow graph |
| `state.*` | managed by the daemon - do not hand-edit |
## Architecture
See [`plans/plan.md`](plans/plan.md) for the full architecture document with
Mermaid diagrams.
The daemon uses a **NIP-65 outbox model**: instead of querying a fixed set of
bootstrap relays for everything, it discovers which relays each followed pubkey
actually posts to (via kind 10002 relay lists) and connects to those relays
dynamically. The `upstream_relays` in the config serve only as **bootstrap
relays** -- the initial set used to discover kind-3 and kind-10002 events before
the outbox relay map is built.
### Relay pool selection: minimum covering set
After discovering each followed pubkey's outbox relays (from their kind 10002),
the daemon computes the **minimum set of relays that covers all followed
pubkeys**. This is the classic set cover problem, solved with a greedy
approximation:
1. Build a map: `{relay_url -> set of pubkeys that list it in their 10002}`
2. Greedily pick the relay that covers the most uncovered pubkeys
3. Repeat until all pubkeys are covered (or no more relays to pick)
4. Always include the bootstrap relays in the final set (they may have events
from pubkeys that don't publish a kind 10002)
This minimizes the number of WebSocket connections while ensuring every
followed pubkey is reachable. The daemon logs the selected relay set and which
pubkeys each relay covers.
Pubkeys that have no kind 10002 (or whose 10002 lists no relays) are covered by
the bootstrap relays as a fallback.
```
┌─────────────────────────────────────────┐
│ caching_relay │
│ │
upstream relays │ upstream_pool sink_pool │ local relay
(damus, nos.lol) │ (query + subscribe) (publish only) │ (c-relay)
│ │ │ │ │ │
│ │ follow_graph relay_sink │ │
└──────────►│ live_subscriber ──────►│ ├─────►│
│ backfill ──────────────►│ │ │
│ │ │ │ │
│ state + seen ring │ │ │
└──────────────────────────────────────────┘ │
```
Two `nostr_relay_pool_t` instances:
- **upstream_pool** - holds bootstrap relays initially, then dynamically adds
outbox relays discovered from kind 10002. Used for `query_sync` (kind-3 fetch,
kind-10002 fetch, backfill) and the long-lived live subscription.
- **sink_pool** - holds only the local relay; used exclusively for
`publish_async`. Never queried.
## Startup Flow
### First-time startup (empty local relay)
The daemon has never run before. The local relay has no cached events. The
daemon must bootstrap from the config's `upstream_relays` to discover the
outbox relays for each followed pubkey.
```
START
|
v
Load config (caching_relay_config.jsonc)
| - root_npubs, upstream_relays (bootstrap), kinds, admin_kinds
| - state.backfilled_until == 0 => first-time startup
|
v
Create upstream_pool with bootstrap relays only
Create sink_pool with local_relay
|
v
Phase 1: Resolve follow graph (from bootstrap relays)
| - For each root npub: query_sync kind=3 from bootstrap relays
| - Parse "p" tags => followed pubkey set
| - Log: "follow: resolved N followed pubkeys"
|
v
Phase 2: Discover outbox relays (NIP-65, kind 10002) from bootstrap relays
| - For each followed pubkey: query_sync kind=10002 from bootstrap relays
| - Parse "r" tags => per-pubkey relay list
| - Publish all kind-10002 events to local relay (cache them)
| - Dynamically add discovered relays to upstream_pool
| - Log: "relay_discovery: found N relays from M pubkeys"
| - Log: "upstream_pool: now connected to N relays" + list them
|
v
Phase 3: Open live subscriptions on upstream_pool
| - follows_sub: non-admin pubkeys + regular kinds
| - admin_sub: admin npubs + admin_kinds (or all kinds)
|
v
Phase 4: Begin progressive backfill
| - Window 0: 24h -> query each pubkey from their outbox relays
| - Window 1: 7d -> ...
| - Window 2: 30d -> ...
| - Publish all fetched events to local relay
| - Save state to config file as each window completes
|
v
Steady-state: live sub + periodic follow-graph refresh + periodic
kind-10002 refresh + backfill re-cycle
|
v
SHUTDOWN (SIGINT/SIGTERM) -> save state -> clean exit
```
### Subsequent startup (local relay already has cached events)
The daemon has run before. The local relay already has kind-3 and kind-10002
events cached from the previous run. The daemon can read these from the local
relay directly (fast, no network round-trip to bootstrap relays) and only falls
back to bootstrap relays for pubkeys it cannot find locally.
```
START
|
v
Load config (caching_relay_config.jsonc)
| - state.backfilled_until > 0 => subsequent startup
| - state.current_window_index, backfill_cursor preserved
|
v
Create upstream_pool with bootstrap relays
Create sink_pool with local_relay
|
v
Phase 1: Resolve follow graph (from LOCAL relay first, bootstrap fallback)
| - Query local relay for kind=3 per root npub
| - If found locally: use it (fast, no upstream query)
| - If not found: fall back to bootstrap relays
| - Parse "p" tags => followed pubkey set
| - Log: "follow: resolved N followed pubkeys (L local, B bootstrap)"
|
v
Phase 2: Discover outbox relays (from LOCAL relay first, bootstrap fallback)
| - Query local relay for kind=10002 per followed pubkey
| - If found locally: use it (fast)
| - If not found: fall back to bootstrap relays, cache result to local
| - Parse "r" tags => per-pubkey relay list
| - Dynamically add discovered relays to upstream_pool
| - Log: "relay_discovery: found N relays (L local, B bootstrap)"
| - Log: "upstream_pool: now connected to N relays" + list them
|
v
Phase 3: Open live subscriptions on upstream_pool
| - (same as first-time)
|
v
Phase 4: Resume backfill from saved state
| - Resume at state.current_window_index, state.backfill_cursor
| - No re-pull of already-backfilled windows
| - Continue progressive window expansion from where it left off
|
v
Steady-state (same as first-time)
|
v
SHUTDOWN -> save state -> clean exit
```
### Relay logging
The daemon logs all relay activity so you can see exactly which relays are
being used:
- `upstream: added wss://relay.damus.io` -- each relay added to the pool
- `relay_discovery: found 47 relays from 195 pubkeys` -- outbox discovery summary
- `upstream_pool: 50 relays connected:` -- full relay list at startup
- `relay_discovery: pubkey X -> wss://relay.example.com (local)` -- per-pubkey
relay source (local cache vs bootstrap)
- `backfill: pubkey[3/195] abc123 -> wss://relay.example.com (12 events)` --
which relay served each backfill query
## Dependencies
- [nostr_core_lib](../nostr_core_lib) - built with NIPs 1, 4, 6, 19, 42, 44
- libwebsockets, openssl, libsecp256k1, libcurl, zlib (all statically linked
in the Docker build)
## Testing
Start a local c-relay, then run the daemon:
```bash
# Start local relay (from c-relay project)
cd ../c-relay/build && ./c_relay_static_x86_64 -p 8888 &
# Run the caching daemon (uses ./caching_relay_config.jsonc by default)
./caching_relay -d 3
# Verify events are cached (from another terminal)
nak req -k 1 -l 10 ws://127.0.0.1:8888
```
+1
View File
@@ -0,0 +1 @@
0.0.2
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
# Build fully static MUSL binary for caching_relay using Alpine Docker.
# Produces a truly portable binary with zero runtime dependencies.
# Adapted from c-relay's build_static.sh.
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
# Parse command line arguments
DEBUG_BUILD=false
TARGET_ARCH=""
while [[ $# -gt 0 ]]; do
case "$1" in
--debug)
DEBUG_BUILD=true
shift
;;
--arch)
if [[ -z "$2" ]]; then
echo "ERROR: --arch requires a value"
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
exit 1
fi
case "$2" in
arm64|x86_64)
TARGET_ARCH="$2"
;;
*)
echo "ERROR: Unsupported architecture '$2'"
echo "Supported values: arm64, x86_64"
exit 1
;;
esac
shift 2
;;
*)
echo "ERROR: Unknown argument '$1'"
echo "Usage: $0 [--debug] [--arch <arm64|x86_64>]"
exit 1
;;
esac
done
if [ "$DEBUG_BUILD" = true ]; then
echo "=========================================="
echo "caching_relay MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "caching_relay MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo ""
if ! command -v docker &> /dev/null; then
echo "ERROR: Docker is not installed or not in PATH"
exit 1
fi
if ! docker info &> /dev/null; then
echo "ERROR: Docker daemon is not running or user not in docker group"
exit 1
fi
echo "Docker is available and running"
echo ""
# Detect host architecture
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ARCH="x86_64";;
aarch64) ARCH="arm64";;
arm64) ARCH="arm64";;
esac
if [[ -n "$TARGET_ARCH" ]]; then
ARCH="$TARGET_ARCH"
echo "Using target architecture: $ARCH"
else
echo "Detected host architecture: $ARCH"
fi
echo ""
# nostr_core_lib is inside the c-relay-pg project root (one dir up from caching/).
# Copy it into the Docker build context (excluding .git and build artifacts).
NOSTR_CORE_DIR="$SCRIPT_DIR/../nostr_core_lib"
if [ ! -d "$NOSTR_CORE_DIR" ]; then
echo "ERROR: nostr_core_lib not found at $NOSTR_CORE_DIR"
exit 1
fi
NEEDS_COPY=1
if [ -d "$SCRIPT_DIR/nostr_core_lib" ] && [ ! -L "$SCRIPT_DIR/nostr_core_lib" ]; then
# Already a real directory (e.g. from a previous run); assume it's good.
NEEDS_COPY=0
fi
if [ "$NEEDS_COPY" = "1" ]; then
echo "Copying nostr_core_lib into build context..."
rm -rf "$SCRIPT_DIR/nostr_core_lib"
mkdir -p "$SCRIPT_DIR/nostr_core_lib"
rsync -a --exclude='.git' --exclude='*.a' --exclude='*.o' \
--exclude='.venv*' --exclude='backups' --exclude='verify_*' \
--exclude='rewrite_mirror' --exclude='websocket_debug' \
"$NOSTR_CORE_DIR/" "$SCRIPT_DIR/nostr_core_lib/"
COPIED_NOSTR_CORE=1
fi
# Build args
BUILD_ARGS="--build-arg DEBUG_BUILD=$DEBUG_BUILD"
if [ "$ARCH" = "arm64" ] && [ "$(uname -m)" != "aarch64" ] && [ "$(uname -m)" != "arm64" ]; then
BUILD_ARGS="$BUILD_ARGS --platform linux/arm64"
fi
IMAGE_TAG="caching_relay_builder:latest"
echo "Building Docker image..."
docker build $BUILD_ARGS \
-f "$DOCKERFILE" \
-t "$IMAGE_TAG" \
"$SCRIPT_DIR" 2>&1
BUILD_RC=$?
# Clean up the copied nostr_core_lib to keep the project dir tidy.
if [ "${COPIED_NOSTR_CORE:-0}" = "1" ]; then
echo "Cleaning up copied nostr_core_lib from build context..."
rm -rf "$SCRIPT_DIR/nostr_core_lib"
fi
if [ $BUILD_RC -ne 0 ]; then
exit $BUILD_RC
fi
echo ""
echo "Extracting binary from image..."
# The output stage is FROM scratch with no CMD, so pass an empty command.
CONTAINER_ID=$(docker create "$IMAGE_TAG" "")
docker cp "$CONTAINER_ID:/caching_relay_static" "$SCRIPT_DIR/caching_relay"
docker rm "$CONTAINER_ID" >/dev/null
chmod +x "$SCRIPT_DIR/caching_relay"
echo ""
echo "=== Build complete ==="
ls -lh "$SCRIPT_DIR/caching_relay"
file "$SCRIPT_DIR/caching_relay"
+456
View File
@@ -0,0 +1,456 @@
/*
* caching_relay - relay-by-relay per-author until-cursor drain backfill.
*
* Each (author, relay) pair has its own persistent until_cursor stored in
* the caching_backfill_relay_progress table. Each backfill tick picks the
* next author that still has at least one incomplete relay (round-robin),
* then queries each incomplete relay one at a time with
* since=0 & until=cursor & limit=page_size
* publishes the page, and advances that relay's cursor to
* oldest_event_created_at - 1. A relay is marked complete when a page
* returns fewer than page_size events (the relay is drained for this
* author). An author is marked complete in caching_followed_pubkeys when
* all their relay progress rows are complete.
*/
#define _GNU_SOURCE
#include "backfill.h"
#include "follow_graph.h"
#include "pg_inbox.h"
#include "debug.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* ------------------------------------------------------------------ */
/* Global access for status reporting */
/* ------------------------------------------------------------------ */
/* Singleton pointer set in cr_backfill_init, used by the accessor
* functions below so the main loop / status reporter can see what
* the backfill is currently working on. */
static cr_backfill_t *g_bf = NULL;
const char *cr_backfill_active_pubkey(void) {
return g_bf ? g_bf->active_pubkey : "";
}
const char *cr_backfill_active_relay(void) {
return g_bf ? g_bf->active_relay : "";
}
int cr_backfill_active_got_eose(void) {
return g_bf ? g_bf->active_relay_got_eose : 0;
}
/* ------------------------------------------------------------------ */
/* EOSE callback for synchronous_query_relays_with_progress */
/* ------------------------------------------------------------------ */
/* Context passed to the query callback. Records whether the relay sent
* EOSE (we got everything) or timed out (partial result). */
typedef struct {
int got_eose; /* 1 if EOSE received, 0 if timed out/errored */
int events_count; /* events reported by callback */
char last_status[256]; /* last status string from callback (may include message) */
} backfill_query_ctx_t;
static void backfill_query_callback(const char *relay_url, const char *status,
const char *event_id, int events_received,
int total_relays, int completed_relays,
void *user_data) {
(void)relay_url; (void)total_relays; (void)completed_relays;
backfill_query_ctx_t *ctx = (backfill_query_ctx_t *)user_data;
if (!ctx || !status) return;
/* Ignore the synthetic "all_complete" status from core_relays.c — it
* is sent AFTER the real EOSE/timeout and would overwrite the real
* status with a misleading "all_complete" string, and clobber
* events_count with total_unique_events. The real per-relay status
* (eose/timeout/error) is what we need for completion decisions. */
if (strcmp(status, "all_complete") == 0) {
return;
}
/* For NOTICE/CLOSED/error, event_id carries the descriptive message
* content (relay's own text for NOTICE/CLOSED, or our transport-level
* diagnosis for error). Include it in the status for display.
* Priority: relay's own response (NOTICE/CLOSED) > our error > bare
* status string. */
if ((strcmp(status, "NOTICE") == 0 || strcmp(status, "CLOSED") == 0) &&
event_id && event_id[0] != '\0') {
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s: %s", status, event_id);
} else if (strcmp(status, "error") == 0) {
if (event_id && event_id[0] != '\0') {
snprintf(ctx->last_status, sizeof(ctx->last_status), "error: %s", event_id);
} else {
snprintf(ctx->last_status, sizeof(ctx->last_status), "error");
}
} else {
snprintf(ctx->last_status, sizeof(ctx->last_status), "%s", status);
}
if (strcmp(status, "eose") == 0) {
ctx->got_eose = 1;
} else if (strcmp(status, "timeout") == 0) {
ctx->got_eose = 0;
} else if (strcmp(status, "error") == 0) {
ctx->got_eose = 0;
}
/* NOTICE/CLOSED don't change got_eose — the relay might still send
* events or EOSE after a NOTICE. */
ctx->events_count = events_received;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/* Returns 1 if the PostgreSQL inbox is initialized (PG mode active).
* Probes the config table; a non-NULL result means the connection is up. */
static int pg_mode_active(void) {
char *v = pg_inbox_get_config_value("caching_root_npubs");
if (v) { free(v); return 1; }
char *v2 = pg_inbox_get_config_value("caching_kinds");
if (v2) { free(v2); return 1; }
return 0;
}
/* Find the oldest (minimum) created_at among an array of event JSON objects.
* Returns 1 and sets *out_oldest, or 0 if no events / no valid created_at. */
static int find_oldest_created_at(cJSON **events, int count, long *out_oldest) {
long oldest = 0;
int found = 0;
for (int i = 0; i < count; i++) {
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
if (!ca || !cJSON_IsNumber(ca)) continue;
long ts = (long)ca->valuedouble;
if (!found || ts < oldest) {
oldest = ts;
found = 1;
}
}
if (found && out_oldest) *out_oldest = oldest;
return found;
}
/* Find the newest (maximum) created_at among an array of event JSON objects.
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
long newest = 0;
int found = 0;
for (int i = 0; i < count; i++) {
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
if (!ca || !cJSON_IsNumber(ca)) continue;
long ts = (long)ca->valuedouble;
if (!found || ts > newest) {
newest = ts;
found = 1;
}
}
if (found && out_newest) *out_newest = newest;
return found;
}
/* Build the kinds array for a given pubkey (admin vs regular).
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
int is_admin = cr_follow_is_root(cfg, pk);
if (is_admin && cfg->admin_all_kinds) {
return NULL;
}
cJSON *kinds = cJSON_CreateArray();
if (is_admin && cfg->admin_kind_count > 0) {
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
} else {
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
}
return kinds;
}
/* Resolve the relay URL list to query for a given pubkey. Sets *out_urls and
* *out_n. Caller frees *out_urls (but not the strings, which alias internal
* storage). Returns 0 on success, -1 if no relays available. */
static int resolve_relays(nostr_relay_pool_t *upstream,
const cr_relay_map_t *relay_map,
const char *pk,
const char ***out_urls,
int *out_n) {
*out_urls = NULL;
*out_n = 0;
/* Collect outbox relays for this author (from kind-10002). */
int outbox_count = 0;
const cr_outbox_entry_t *oe = NULL;
if (relay_map) {
oe = cr_relay_map_get_outbox(relay_map, pk);
if (oe) outbox_count = oe->relay_count;
}
/* Also collect all upstream (bootstrap) relays. We query BOTH the
* author's outbox relays AND the bootstrap relays to maximize coverage.
* Different relays may have different subsets of the author's events. */
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int upstream_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
/* Merge outbox + upstream, deduplicating by URL. */
int max_total = outbox_count + upstream_count;
if (max_total <= 0) {
free(listed);
free(statuses);
return 0;
}
const char **urls = malloc(max_total * sizeof(char *));
if (!urls) { free(listed); free(statuses); return -1; }
int n = 0;
/* Add outbox relays first. */
if (oe) {
for (int j = 0; j < oe->relay_count && n < max_total; j++) {
urls[n++] = oe->relays[j];
}
}
/* Add upstream relays, skipping duplicates. */
for (int j = 0; j < upstream_count && n < max_total; j++) {
int dup = 0;
for (int k = 0; k < n; k++) {
if (strcmp(urls[k], listed[j]) == 0) { dup = 1; break; }
}
if (!dup) {
urls[n++] = listed[j];
}
}
free(listed);
free(statuses);
*out_urls = urls;
*out_n = n;
return 0;
}
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg) {
(void)cfg;
memset(bf, 0, sizeof(*bf));
g_bf = bf; /* Set singleton for status accessors */
/* In PG mode, check whether any incomplete authors remain in the DB.
* If so, backfill is in progress; otherwise we're in steady state.
* Outside PG mode there is no per-author table, so we just start. */
if (pg_mode_active()) {
int complete = 0, total = 0;
if (pg_inbox_count_backfill_progress(&complete, &total) == 0) {
bf->in_progress = (total > 0 && complete < total) ? 1 : 0;
DEBUG_INFO("backfill: init complete=%d/%d -> in_progress=%d",
complete, total, bf->in_progress);
return;
}
/* Count query failed - assume in progress so we keep trying. */
bf->in_progress = 1;
DEBUG_WARN("backfill: init could not read progress counts, "
"defaulting to in_progress=1");
return;
}
/* Legacy (non-PG) mode: no per-author table; just start. */
bf->in_progress = 1;
DEBUG_INFO("backfill: init (legacy mode) in_progress=1");
}
/* ------------------------------------------------------------------ */
/* Tick */
/* ------------------------------------------------------------------ */
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed,
cr_sink_t *sink, const cr_relay_map_t *relay_map) {
(void)followed; /* followed set is tracked in the DB now */
(void)relay_map; /* relays are tracked per-author in the DB */
if (!cfg->backfill.enabled) return -2;
if (!bf->in_progress) return -2;
/* Throttle: one query per tick_interval. */
time_t now = time(NULL);
if (bf->last_tick && (now - bf->last_tick) < cfg->backfill.tick_interval_seconds)
return 0;
/* Pick the next author that has at least one incomplete relay progress
* row (round-robin). */
char pk[CR_HEX_LEN];
if (pg_inbox_pick_next_author_with_incomplete_relays(pk, sizeof(pk),
&bf->author_round_cursor) != 0) {
/* No incomplete authors - steady state. */
bf->in_progress = 0;
DEBUG_INFO("backfill: no authors with incomplete relays, steady-state");
return -2;
}
int page_size = cfg->backfill.events_per_tick;
if (page_size < 1) page_size = 500;
bf->last_tick = now;
/* Get the incomplete relays for this author. */
cJSON *relays = pg_inbox_get_incomplete_relays(pk);
if (!relays) {
DEBUG_WARN("backfill: no incomplete relays returned for %s", pk);
/* Author has no relay rows - mark complete so we don't loop. */
pg_inbox_check_and_mark_author_complete(pk);
return 1;
}
int relay_count = cJSON_GetArraySize(relays);
DEBUG_TRACE("backfill: author %s has %d incomplete relay(s)", pk, relay_count);
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
/* Query each incomplete relay one at a time. */
for (int r = 0; r < relay_count; r++) {
cJSON *entry = cJSON_GetArrayItem(relays, r);
if (!entry) continue;
cJSON *url_node = cJSON_GetObjectItem(entry, "relay_url");
cJSON *cur_node = cJSON_GetObjectItem(entry, "until_cursor");
if (!url_node || !cJSON_IsString(url_node)) continue;
const char *relay_url = cJSON_GetStringValue(url_node);
long until_cursor = (cur_node && cJSON_IsNumber(cur_node))
? (long)cur_node->valuedouble : 0;
if (until_cursor == 0) until_cursor = (long)now;
/* Build the filter for this single relay. */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(pk));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = build_kinds(cfg, pk);
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber(0.0));
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)until_cursor));
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
DEBUG_TRACE("backfill: %s @ %s (until=%ld, limit=%d)",
pk, relay_url, until_cursor, page_size);
/* Record the active target for UI status display (both
* in-memory for local accessors and in PG for the relay). */
strncpy(bf->active_pubkey, pk, sizeof(bf->active_pubkey) - 1);
bf->active_pubkey[sizeof(bf->active_pubkey) - 1] = '\0';
strncpy(bf->active_relay, relay_url, sizeof(bf->active_relay) - 1);
bf->active_relay[sizeof(bf->active_relay) - 1] = '\0';
bf->active_relay_got_eose = 0;
pg_inbox_set_active_target(pk, relay_url);
/* Query this single relay using synchronous_query_relays_with_progress
* which gives us an EOSE/timeout callback — the relay's own signal
* of whether it sent everything or we timed out. */
backfill_query_ctx_t qctx = {0, 0};
const char *one_url = relay_url;
int ev_count = 0;
cJSON **events = synchronous_query_relays_with_progress(
&one_url, 1, filter, RELAY_QUERY_ALL_RESULTS,
&ev_count, 30, /* timeout in seconds */
backfill_query_callback, &qctx,
0, NULL /* nip42 disabled, no private key */);
cJSON_Delete(filter);
bf->active_relay_got_eose = qctx.got_eose;
if (!events || ev_count == 0) {
/* No events from this relay - it is drained for this author.
* (ev_count == 0 with EOSE means truly empty; without EOSE it
* means a connection failure — keep incomplete to retry.
* The pg_inbox_update_relay_progress SQL will auto-mark
* complete once consecutive_errors reaches 3.) */
int mark_complete = qctx.got_eose ? 1 : 0;
pg_inbox_update_relay_progress(pk, relay_url, until_cursor,
mark_complete, 0, qctx.last_status);
free(events);
if (!qctx.got_eose) {
/* Error or timeout with no events — surface the
* descriptive status at WARN so operators can see why. */
DEBUG_WARN("backfill: %s @ %s failed: %s (will auto-complete after 3 consecutive errors)",
pk, relay_url, qctx.last_status);
} else {
DEBUG_LOG("backfill: %s @ %s complete (no events, eose)",
pk, relay_url);
}
continue;
}
/* Find oldest and newest timestamps, then publish every event. */
long oldest_ts = 0;
long newest_ts = 0;
find_oldest_created_at(events, ev_count, &oldest_ts);
find_newest_created_at(events, ev_count, &newest_ts);
for (int k = 0; k < ev_count; k++) {
cr_sink_publish(sink, events[k]);
cJSON_Delete(events[k]);
}
free(events);
bf->events_total += ev_count;
/* Update last_event_at for forward catch-up gap tracking. */
if (newest_ts > 0) {
pg_inbox_update_last_event_at(pk, newest_ts);
}
DEBUG_LOG("backfill: %s @ %s -> %d events (until=%ld, oldest=%ld, eose=%d)",
pk, relay_url, ev_count, until_cursor, oldest_ts, qctx.got_eose);
/* Completion decision based on EOSE status:
* - ev_count >= page_size: saturated, advance cursor, keep incomplete
* - ev_count < page_size AND got EOSE: relay is truly drained
* - ev_count < page_size AND no EOSE (timeout): partial result,
* advance cursor but keep incomplete to retry next tick
* - Guard: if cursor didn't advance (oldest_ts >= until_cursor),
* mark complete to avoid infinite loop on identical timestamps. */
long next_cursor = (oldest_ts > 0) ? oldest_ts - 1 : 0;
int cursor_advanced = (oldest_ts > 0 && oldest_ts < until_cursor);
if (ev_count >= page_size) {
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 0, ev_count, qctx.last_status);
} else if (qctx.got_eose) {
/* Relay sent EOSE with < page_size events — truly drained. */
pg_inbox_update_relay_progress(pk, relay_url, next_cursor, 1, ev_count, qctx.last_status);
} else {
/* Timed out before EOSE — partial result. Keep incomplete
* to retry, unless the cursor didn't advance (stall guard).
* Timeouts DO count toward the consecutive_errors limit
* (a relay that consistently times out is effectively dead
* for backfill purposes), so the SQL will auto-mark
* complete after 3 consecutive timeouts/errors. */
int mark_complete = cursor_advanced ? 0 : 1;
pg_inbox_update_relay_progress(pk, relay_url, next_cursor,
mark_complete, ev_count, qctx.last_status);
DEBUG_WARN("backfill: %s @ %s partial: %s (%d events, will auto-complete after 3 consecutive timeouts/errors)",
pk, relay_url, qctx.last_status, ev_count);
}
}
/* Clear active target — done with this author. */
bf->active_relay[0] = '\0';
pg_inbox_set_active_target(pk, "");
cJSON_Delete(relays);
/* After querying all incomplete relays for this author, check whether
* the author is now fully complete. */
int crc = pg_inbox_check_and_mark_author_complete(pk);
if (crc == 1) {
DEBUG_LOG("backfill: author %s fully complete (all relays drained)", pk);
}
return 1;
}
+58
View File
@@ -0,0 +1,58 @@
/*
* caching_relay - per-author until-cursor drain backfill.
*
* Each followed author has a single persistent cursor (starting at `now`)
* stored in the caching_followed_pubkeys table. Each backfill tick picks
* the next incomplete author (round-robin), queries
* since=0 & until=cursor & limit=page_size
* publishes the page, and sets cursor = oldest_event_created_at - 1.
* Repeat until a page returns fewer than page_size events (beginning of
* the author's history reached), then mark the author complete.
*/
#ifndef CACHING_RELAY_BACKFILL_H
#define CACHING_RELAY_BACKFILL_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "relay_discovery.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
typedef struct {
int in_progress; /* 1 if any incomplete authors remain */
long events_total; /* events pulled across all ticks (diagnostic) */
time_t last_tick; /* last time an author was queried (throttle) */
int author_round_cursor;/* round-robin cursor for author selection */
/* Current backfill target (for UI status display). */
char active_pubkey[65]; /* pubkey currently being backfilled (or "") */
char active_relay[256]; /* relay URL currently being queried (or "") */
int active_relay_got_eose; /* 1 if last query got EOSE, 0 if timed out */
} cr_backfill_t;
/* Global access to the current backfill target for status reporting.
* Returns pointers to static storage in the singleton cr_backfill_t.
* Safe to call from the main loop; not thread-safe but backfill is
* single-threaded. */
const char *cr_backfill_active_pubkey(void);
const char *cr_backfill_active_relay(void);
int cr_backfill_active_got_eose(void);
/* Initialize backfill state. Checks the caching_followed_pubkeys table for
* any incomplete authors; sets in_progress accordingly. */
void cr_backfill_init(cr_backfill_t *bf, cr_config_t *cfg);
/* Perform one backfill tick. Picks the next incomplete author from the DB
* (round-robin), queries one page of history, publishes it to the sink, and
* updates the per-author cursor in the DB.
*
* Returns:
* 1 if a tick was performed (an author was queried)
* 0 if throttled (tick_interval not elapsed) - caller should pump pools
* -2 if backfill is complete (no incomplete authors) - caller goes steady-state
*/
int cr_backfill_tick(cr_backfill_t *bf, cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed,
cr_sink_t *sink, const cr_relay_map_t *relay_map);
#endif /* CACHING_RELAY_BACKFILL_H */
+290
View File
@@ -0,0 +1,290 @@
/*
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
*/
#define _GNU_SOURCE
#include "config.h"
#include "jsonc_strip.h"
#include "debug.h"
#include "../nostr_core_lib/cjson/cJSON.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
static int read_file(const char *path, char **out, size_t *out_len) {
FILE *f = fopen(path, "rb");
if (!f) {
DEBUG_ERROR("cannot open config '%s': %s", path, strerror(errno));
return -1;
}
if (fseek(f, 0, SEEK_END) != 0) { fclose(f); return -1; }
long sz = ftell(f);
if (sz < 0) { fclose(f); return -1; }
rewind(f);
char *buf = malloc(sz + 1);
if (!buf) { fclose(f); return -1; }
size_t n = fread(buf, 1, sz, f);
fclose(f);
buf[n] = '\0';
*out = buf;
if (out_len) *out_len = n;
return 0;
}
static void copy_string_array(cJSON *arr, char *dst, int max, int *count_out,
int elem_len) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (!cJSON_IsString(item)) continue;
const char *s = cJSON_GetStringValue(item);
if (!s) continue;
strncpy(dst + count * elem_len, s, elem_len - 1);
dst[count * elem_len + (elem_len - 1)] = '\0';
count++;
}
}
*count_out = count;
}
static void copy_int_array(cJSON *arr, int *dst, int max, int *count_out) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (cJSON_IsNumber(item)) {
dst[count++] = (int)cJSON_GetNumberValue(item);
}
}
}
*count_out = count;
}
static void copy_long_array(cJSON *arr, long *dst, int max, int *count_out) {
int count = 0;
if (arr && cJSON_IsArray(arr)) {
cJSON *item;
cJSON_ArrayForEach(item, arr) {
if (count >= max) break;
if (cJSON_IsNumber(item)) {
dst[count++] = (long)cJSON_GetNumberValue(item);
}
}
}
*count_out = count;
}
int cr_config_load(cr_config_t *cfg, const char *path) {
memset(cfg, 0, sizeof(*cfg));
strncpy(cfg->path, path, sizeof(cfg->path) - 1);
char *raw = NULL;
size_t raw_len = 0;
if (read_file(path, &raw, &raw_len) != 0) return -1;
char *stripped = jsonc_strip_comments(raw, raw_len);
free(raw);
if (!stripped) {
DEBUG_ERROR("failed to strip comments from config");
return -1;
}
cJSON *root = cJSON_Parse(stripped);
free(stripped);
if (!root) {
DEBUG_ERROR("failed to parse config JSON");
return -1;
}
/* root_npubs */
copy_string_array(cJSON_GetObjectItem(root, "root_npubs"),
(char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
&cfg->root_npub_count, CR_NPUB_LEN);
/* upstream_relays */
copy_string_array(cJSON_GetObjectItem(root, "upstream_relays"),
(char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
&cfg->upstream_count, CR_URL_LEN);
/* local_relay */
cJSON *local = cJSON_GetObjectItem(root, "local_relay");
if (local && cJSON_IsString(local)) {
strncpy(cfg->local_relay, cJSON_GetStringValue(local), CR_URL_LEN - 1);
}
/* kinds */
copy_int_array(cJSON_GetObjectItem(root, "kinds"),
cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
/* admin_kinds - kinds to follow specifically for root (admin) npubs.
* Supports [*] to mean "all kinds" (no kind filter for admin). */
cJSON *ak = cJSON_GetObjectItem(root, "admin_kinds");
if (ak && cJSON_IsArray(ak)) {
cJSON *item;
cJSON_ArrayForEach(item, ak) {
if (cJSON_IsString(item)) {
const char *s = cJSON_GetStringValue(item);
if (s && strcmp(s, "*") == 0) {
cfg->admin_all_kinds = 1;
break;
}
}
}
if (!cfg->admin_all_kinds) {
copy_int_array(ak, cfg->admin_kinds, CR_MAX_KINDS, &cfg->admin_kind_count);
}
}
/* backfill */
cJSON *bf = cJSON_GetObjectItem(root, "backfill");
if (bf) {
cfg->backfill.enabled = cJSON_IsTrue(cJSON_GetObjectItem(bf, "enabled"));
cJSON *ept = cJSON_GetObjectItem(bf, "events_per_tick");
if (ept) cfg->backfill.events_per_tick = (int)cJSON_GetNumberValue(ept);
cJSON *tis = cJSON_GetObjectItem(bf, "tick_interval_seconds");
if (tis) cfg->backfill.tick_interval_seconds = (int)cJSON_GetNumberValue(tis);
}
/* live */
cJSON *lv = cJSON_GetObjectItem(root, "live");
if (lv) {
cfg->live.enabled = cJSON_IsTrue(cJSON_GetObjectItem(lv, "enabled"));
cJSON *rsi = cJSON_GetObjectItem(lv, "resubscribe_interval_seconds");
if (rsi) cfg->live.resubscribe_interval_seconds = (int)cJSON_GetNumberValue(rsi);
}
/* follow_graph_refresh_seconds */
cJSON *fgr = cJSON_GetObjectItem(root, "follow_graph_refresh_seconds");
if (fgr) cfg->follow_graph_refresh_seconds = (int)cJSON_GetNumberValue(fgr);
/* state */
cJSON *st = cJSON_GetObjectItem(root, "state");
if (st) {
cJSON *bu = cJSON_GetObjectItem(st, "backfilled_until");
if (bu) cfg->state.backfilled_until = (long)cJSON_GetNumberValue(bu);
}
cJSON_Delete(root);
/* Defaults if missing. */
if (cfg->backfill.events_per_tick == 0) cfg->backfill.events_per_tick = 500;
if (cfg->backfill.tick_interval_seconds == 0) cfg->backfill.tick_interval_seconds = 5;
if (cfg->live.resubscribe_interval_seconds == 0) cfg->live.resubscribe_interval_seconds = 300;
if (cfg->follow_graph_refresh_seconds == 0) cfg->follow_graph_refresh_seconds = 600;
/* Validation. */
if (cfg->root_npub_count == 0) {
DEBUG_ERROR("config: at least one root_npub required");
return -1;
}
if (cfg->upstream_count == 0) {
DEBUG_ERROR("config: at least one upstream_relay required");
return -1;
}
if (cfg->local_relay[0] == '\0') {
DEBUG_ERROR("config: local_relay required");
return -1;
}
if (cfg->kind_count == 0) {
DEBUG_ERROR("config: at least one kind required");
return -1;
}
DEBUG_INFO("config loaded: %d root npubs, %d upstream relays, %d kinds",
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
return 0;
}
/* Build a fresh cJSON tree from the in-memory config and write it out.
* We re-emit plain JSON (comments are not preserved on rewrite). */
int cr_config_save_state(cr_config_t *cfg) {
cJSON *root = cJSON_CreateObject();
cJSON *npubs = cJSON_CreateArray();
for (int i = 0; i < cfg->root_npub_count; i++)
cJSON_AddItemToArray(npubs, cJSON_CreateString(cfg->root_npubs[i]));
cJSON_AddItemToObject(root, "root_npubs", npubs);
cJSON *upstream = cJSON_CreateArray();
for (int i = 0; i < cfg->upstream_count; i++)
cJSON_AddItemToArray(upstream, cJSON_CreateString(cfg->upstream_relays[i]));
cJSON_AddItemToObject(root, "upstream_relays", upstream);
cJSON_AddItemToObject(root, "local_relay", cJSON_CreateString(cfg->local_relay));
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
cJSON_AddItemToObject(root, "kinds", kinds);
/* admin_kinds */
cJSON *akinds = cJSON_CreateArray();
if (cfg->admin_all_kinds) {
cJSON_AddItemToArray(akinds, cJSON_CreateString("*"));
} else {
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(akinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
}
cJSON_AddItemToObject(root, "admin_kinds", akinds);
cJSON *bf = cJSON_CreateObject();
cJSON_AddItemToObject(bf, "enabled", cJSON_CreateBool(cfg->backfill.enabled));
cJSON_AddItemToObject(bf, "events_per_tick", cJSON_CreateNumber(cfg->backfill.events_per_tick));
cJSON_AddItemToObject(bf, "tick_interval_seconds", cJSON_CreateNumber(cfg->backfill.tick_interval_seconds));
cJSON_AddItemToObject(root, "backfill", bf);
cJSON *lv = cJSON_CreateObject();
cJSON_AddItemToObject(lv, "enabled", cJSON_CreateBool(cfg->live.enabled));
cJSON_AddItemToObject(lv, "resubscribe_interval_seconds", cJSON_CreateNumber(cfg->live.resubscribe_interval_seconds));
cJSON_AddItemToObject(root, "live", lv);
cJSON_AddItemToObject(root, "follow_graph_refresh_seconds",
cJSON_CreateNumber(cfg->follow_graph_refresh_seconds));
cJSON *st = cJSON_CreateObject();
cJSON_AddItemToObject(st, "backfilled_until", cJSON_CreateNumber(cfg->state.backfilled_until));
cJSON_AddItemToObject(root, "state", st);
char *json = cJSON_Print(root);
cJSON_Delete(root);
if (!json) {
DEBUG_ERROR("config save: failed to serialize");
return -1;
}
/* Atomic write: temp file + rename. */
char tmp[1100];
snprintf(tmp, sizeof(tmp), "%s.tmp", cfg->path);
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) {
DEBUG_ERROR("config save: cannot open tmp '%s': %s", tmp, strerror(errno));
free(json);
return -1;
}
size_t jlen = strlen(json);
ssize_t w = write(fd, json, jlen);
close(fd);
free(json);
if (w < 0 || (size_t)w != jlen) {
DEBUG_ERROR("config save: short write");
return -1;
}
if (rename(tmp, cfg->path) != 0) {
DEBUG_ERROR("config save: rename failed: %s", strerror(errno));
return -1;
}
DEBUG_LOG("config state saved: backfilled_until=%ld",
cfg->state.backfilled_until);
return 0;
}
void cr_config_free(cr_config_t *cfg) {
(void)cfg;
}
+79
View File
@@ -0,0 +1,79 @@
/*
* caching_relay - config parsing + persistent state (in the .jsonc file itself)
*/
#ifndef CACHING_RELAY_CONFIG_H
#define CACHING_RELAY_CONFIG_H
#include <stddef.h>
#include <time.h>
/* Maximums to keep things statically sized and simple. */
#define CR_MAX_ROOT_NPUBS 16
#define CR_MAX_UPSTREAM 32
#define CR_MAX_KINDS 32
#define CR_NPUB_LEN 64 /* npub1... bech32, generous */
#define CR_URL_LEN 256
#define CR_HEX_PUBKEY_LEN 65 /* 64 hex chars + NUL */
typedef struct {
int enabled;
int events_per_tick; /* per-query page size (default 500) */
int tick_interval_seconds;
} cr_backfill_config_t;
typedef struct {
int enabled;
int resubscribe_interval_seconds;
} cr_live_config_t;
/* Persistent state. The per-author until-cursor drain model stores all
* backfill progress in the caching_followed_pubkeys table, so the only
* state kept here is the legacy-mode first-time flag (used to drive relay
* discovery caching behavior). */
typedef struct {
long backfilled_until; /* legacy: 0 = first-time startup */
} cr_state_t;
typedef struct {
char root_npubs[CR_MAX_ROOT_NPUBS][CR_NPUB_LEN];
int root_npub_count;
/* Decoded hex pubkeys for root npubs (filled by follow_graph). */
char root_hex[CR_MAX_ROOT_NPUBS][CR_HEX_PUBKEY_LEN];
int root_hex_ready;
char upstream_relays[CR_MAX_UPSTREAM][CR_URL_LEN];
int upstream_count;
char local_relay[CR_URL_LEN];
int kinds[CR_MAX_KINDS];
int kind_count;
/* Kinds to follow specifically for the root (admin) npubs.
* If admin_all_kinds is 1, grab everything (no kind filter). */
int admin_kinds[CR_MAX_KINDS];
int admin_kind_count;
int admin_all_kinds;
cr_backfill_config_t backfill;
cr_live_config_t live;
int follow_graph_refresh_seconds;
cr_state_t state;
/* Path the config was loaded from (for state write-back). */
char path[1024];
} cr_config_t;
/* Load + validate config from a .jsonc file. Returns 0 on success, -1 on error.
* On success cfg->path is set and cfg->state is populated from the file. */
int cr_config_load(cr_config_t *cfg, const char *path);
/* Persist the state sub-object back into the config file (temp + rename).
* Preserves all other config fields. Returns 0 on success, -1 on error. */
int cr_config_save_state(cr_config_t *cfg);
/* Free any heap resources held by cfg (currently none, but kept for future). */
void cr_config_free(cr_config_t *cfg);
#endif /* CACHING_RELAY_CONFIG_H */
+71
View File
@@ -0,0 +1,71 @@
#include "debug.h"
#include <stdarg.h>
#include <string.h>
/**
* @file debug.c
* @brief Debug and logging system implementation
*
* Provides a configurable logging system with timestamp formatting,
* level-based filtering, and optional file:line information.
*/
// Global debug level (default: no debug output)
debug_level_t g_debug_level = DEBUG_LEVEL_NONE;
/**
* @brief Initialize the debug system with a specific level
* @param level Debug level (0-5, clamped to valid range)
*/
void debug_init(int level) {
if (level < 0) level = 0;
if (level > 5) level = 5;
g_debug_level = (debug_level_t)level;
}
/**
* @brief Core logging function
* @param level Debug level for this message
* @param file Source file name (__FILE__)
* @param line Source line number (__LINE__)
* @param format printf-style format string
* @param ... Variable arguments for format string
*/
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...) {
// Get timestamp
time_t now = time(NULL);
struct tm* tm_info = localtime(&now);
char timestamp[32];
strftime(timestamp, sizeof(timestamp), "%Y-%m-%d %H:%M:%S", tm_info);
// Get level string
const char* level_str = "UNKNOWN";
switch (level) {
case DEBUG_LEVEL_ERROR: level_str = "ERROR"; break;
case DEBUG_LEVEL_WARN: level_str = "WARN "; break;
case DEBUG_LEVEL_INFO: level_str = "INFO "; break;
case DEBUG_LEVEL_DEBUG: level_str = "DEBUG"; break;
case DEBUG_LEVEL_TRACE: level_str = "TRACE"; break;
default: break;
}
// Print prefix with timestamp and level
printf("[%s] [%s] ", timestamp, level_str);
// Print source location when debug level is TRACE (5) or higher
if (file && g_debug_level >= DEBUG_LEVEL_TRACE) {
// Extract just the filename (not full path)
const char* filename = strrchr(file, '/');
filename = filename ? filename + 1 : file;
printf("[%s:%d] ", filename, line);
}
// Print message
va_list args;
va_start(args, format);
vprintf(format, args);
va_end(args);
printf("\n");
fflush(stdout);
}
+65
View File
@@ -0,0 +1,65 @@
#ifndef C_UTILS_DEBUG_H
#define C_UTILS_DEBUG_H
/**
* @file debug.h
* @brief Debug and logging system with configurable verbosity levels
*
* Provides a simple, efficient logging system with 5 levels:
* - ERROR: Critical errors
* - WARN: Warnings
* - INFO: Informational messages
* - DEBUG: Debug messages
* - TRACE: Detailed trace with file:line info
*/
#include <stdio.h>
#include <time.h>
// Debug levels
typedef enum {
DEBUG_LEVEL_NONE = 0, /**< No debug output */
DEBUG_LEVEL_ERROR = 1, /**< Critical errors only */
DEBUG_LEVEL_WARN = 2, /**< Warnings and above */
DEBUG_LEVEL_INFO = 3, /**< Informational messages and above */
DEBUG_LEVEL_DEBUG = 4, /**< Debug messages and above */
DEBUG_LEVEL_TRACE = 5 /**< Detailed trace with file:line info */
} debug_level_t;
// Global debug level (set at runtime via CLI)
extern debug_level_t g_debug_level;
/**
* @brief Initialize the debug system
* @param level Debug level (0-5, clamped to valid range)
*/
void debug_init(int level);
/**
* @brief Core logging function
* @param level Debug level for this message
* @param file Source file name (__FILE__)
* @param line Source line number (__LINE__)
* @param format printf-style format string
* @param ... Variable arguments for format string
*/
void debug_log(debug_level_t level, const char* file, int line, const char* format, ...);
// Convenience macros that check level before calling
// Note: TRACE level (5) and above include file:line information for ALL messages
#define DEBUG_ERROR(...) \
do { if (g_debug_level >= DEBUG_LEVEL_ERROR) debug_log(DEBUG_LEVEL_ERROR, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_WARN(...) \
do { if (g_debug_level >= DEBUG_LEVEL_WARN) debug_log(DEBUG_LEVEL_WARN, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_INFO(...) \
do { if (g_debug_level >= DEBUG_LEVEL_INFO) debug_log(DEBUG_LEVEL_INFO, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_LOG(...) \
do { if (g_debug_level >= DEBUG_LEVEL_DEBUG) debug_log(DEBUG_LEVEL_DEBUG, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#define DEBUG_TRACE(...) \
do { if (g_debug_level >= DEBUG_LEVEL_TRACE) debug_log(DEBUG_LEVEL_TRACE, __FILE__, __LINE__, __VA_ARGS__); } while(0)
#endif /* C_UTILS_DEBUG_H */
+129
View File
@@ -0,0 +1,129 @@
/*
* caching_relay - follow graph resolution
*/
#define _GNU_SOURCE
#include "follow_graph.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
#include <ctype.h>
int cr_follow_is_root(const cr_config_t *cfg, const char *hex) {
if (!cfg->root_hex_ready) return 0;
for (int i = 0; i < cfg->root_npub_count; i++) {
if (strcmp(cfg->root_hex[i], hex) == 0) return 1;
}
return 0;
}
/* Check if a string is a 64-character hex pubkey (no npub prefix). */
static int is_hex_pubkey(const char *s) {
if (!s || strlen(s) != 64) return 0;
for (int i = 0; i < 64; i++) {
if (!isxdigit((unsigned char)s[i])) return 0;
}
return 1;
}
int cr_follow_decode_roots(cr_config_t *cfg) {
for (int i = 0; i < cfg->root_npub_count; i++) {
const char *input = cfg->root_npubs[i];
if (is_hex_pubkey(input)) {
/* Already a 64-char hex pubkey — copy directly. */
strncpy(cfg->root_hex[i], input, 64);
cfg->root_hex[i][64] = '\0';
} else {
/* Assume npub (bech32) format — decode to hex. */
unsigned char pubkey[32];
if (nostr_decode_npub(input, pubkey) != NOSTR_SUCCESS) {
DEBUG_ERROR("follow: failed to decode root pubkey '%s' "
"(not a valid npub or 64-char hex)", input);
return -1;
}
nostr_bytes_to_hex(pubkey, 32, cfg->root_hex[i]);
}
}
cfg->root_hex_ready = 1;
DEBUG_INFO("follow: decoded %d root pubkeys", cfg->root_npub_count);
return 0;
}
/* Parse "p" tags from a kind-3 event and add pubkeys to the set. */
static int parse_p_tags(cJSON *event, cr_pubkey_set_t *followed) {
cJSON *tags = cJSON_GetObjectItem(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return 0;
int added = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
if (!name || !cJSON_IsString(name)) continue;
if (strcmp(cJSON_GetStringValue(name), "p") != 0) continue;
cJSON *pk = cJSON_GetArrayItem(tag, 1);
if (!pk || !cJSON_IsString(pk)) continue;
const char *hex = cJSON_GetStringValue(pk);
if (strlen(hex) != 64) continue;
if (cr_pubkey_set_add(followed, hex) == 1) added++;
}
return added;
}
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed) {
if (!cfg->root_hex_ready) {
if (cr_follow_decode_roots(cfg) != 0) return -1;
}
/* Seed the followed set with the root pubkeys themselves. */
for (int i = 0; i < cfg->root_npub_count; i++) {
cr_pubkey_set_add(followed, cfg->root_hex[i]);
}
int total_follows = 0;
for (int i = 0; i < cfg->root_npub_count; i++) {
/* Build filter: authors=[root], kinds=[3], limit=1 (most recent). */
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
const char **urls = NULL;
int n = 0;
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int listed_count = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
if (listed_count > 0) {
urls = malloc(listed_count * sizeof(char *));
for (int j = 0; j < listed_count; j++) { urls[n++] = listed[j]; }
}
free(listed);
free(statuses);
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(upstream, urls, n,
filter, &ev_count, 15000);
free(urls);
cJSON_Delete(filter);
if (events && ev_count > 0) {
int added = parse_p_tags(events[0], followed);
total_follows += added;
DEBUG_INFO("follow: root %d: kind-3 found, +%d follows (total set %d)",
i, added, followed->count);
for (int k = 0; k < ev_count; k++) cJSON_Delete(events[k]);
free(events);
} else {
DEBUG_WARN("follow: root %d: no kind-3 found", i);
free(events);
}
}
DEBUG_INFO("follow: resolved %d total followed pubkeys (set size %d)",
total_follows, followed->count);
return 0;
}
+27
View File
@@ -0,0 +1,27 @@
/*
* caching_relay - follow graph resolution.
*
* Decodes root npubs (NIP-19) to hex, queries their most recent kind-3
* contact list from the upstream pool, parses the "p" tags, and populates
* the in-memory followed-pubkey set (root pubkeys + their follows).
*/
#ifndef CACHING_RELAY_FOLLOW_GRAPH_H
#define CACHING_RELAY_FOLLOW_GRAPH_H
#include "config.h"
#include "state.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
/* Check if a hex pubkey is one of the root (admin) npubs. Returns 1 if yes. */
int cr_follow_is_root(const cr_config_t *cfg, const char *hex);
/* Decode all root_npubs in cfg into cfg->root_hex[]. Returns 0 on success. */
int cr_follow_decode_roots(cr_config_t *cfg);
/* Resolve the follow graph: for each root hex pubkey, query the most recent
* kind-3 from the upstream pool, parse "p" tags, and add root + follows to
* the followed set. Returns 0 on success, -1 on hard failure. */
int cr_follow_resolve(cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed);
#endif /* CACHING_RELAY_FOLLOW_GRAPH_H */
+200
View File
@@ -0,0 +1,200 @@
/*
* caching_relay - forward catch-up implementation.
*
* See forward_catchup.h for design.
*/
#include "forward_catchup.h"
#include "pg_inbox.h"
#include "config.h"
#include "follow_graph.h"
#include "relay_sink.h"
#include "debug.h"
#include <cjson/cJSON.h>
#include <time.h>
#include <string.h>
#include <stdlib.h>
/* Find the newest (maximum) created_at among an array of event JSON objects.
* Returns 1 and sets *out_newest, or 0 if no events / no valid created_at. */
static int find_newest_created_at(cJSON **events, int count, long *out_newest) {
long newest = 0;
int found = 0;
for (int i = 0; i < count; i++) {
cJSON *ca = cJSON_GetObjectItem(events[i], "created_at");
if (!ca || !cJSON_IsNumber(ca)) continue;
long ts = (long)ca->valuedouble;
if (!found || ts > newest) {
newest = ts;
found = 1;
}
}
if (found && out_newest) *out_newest = newest;
return found;
}
/* Build the kinds array for a given pubkey (admin vs regular).
* Returns NULL if no kinds filter should be applied (admin_all_kinds). */
static cJSON *build_kinds(cr_config_t *cfg, const char *pk) {
int is_admin = cr_follow_is_root(cfg, pk);
if (is_admin && cfg->admin_all_kinds) {
return NULL;
}
cJSON *kinds = cJSON_CreateArray();
if (is_admin && cfg->admin_kind_count > 0) {
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
} else {
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
}
return kinds;
}
/* Get relay URLs for an author from the relay progress table.
* Returns a cJSON array of relay_url strings. Caller must cJSON_Delete().
* Returns NULL on error or if no relays found. */
static cJSON *get_author_relays(const char *pk) {
/* Use pg_inbox_get_incomplete_relays for incomplete authors.
* For completed authors, we need all relays query directly. */
cJSON *incomplete = pg_inbox_get_incomplete_relays(pk);
if (incomplete && cJSON_GetArraySize(incomplete) > 0) {
/* Extract just the relay_url strings. */
cJSON *urls = cJSON_CreateArray();
int n = cJSON_GetArraySize(incomplete);
for (int i = 0; i < n; i++) {
cJSON *entry = cJSON_GetArrayItem(incomplete, i);
cJSON *url = cJSON_GetObjectItemCaseSensitive(entry, "relay_url");
if (url && cJSON_IsString(url) && url->valuestring[0]) {
cJSON_AddItemToArray(urls, cJSON_CreateString(url->valuestring));
}
}
cJSON_Delete(incomplete);
if (cJSON_GetArraySize(urls) > 0) return urls;
cJSON_Delete(urls);
}
if (incomplete) cJSON_Delete(incomplete);
/* For completed authors, fall back to the upstream pool's connected
* relays. The caller will use the pool directly. */
return NULL;
}
int cr_forward_catchup(cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_sink_t *sink) {
if (!cfg || !upstream || !sink) return -1;
cJSON *authors = pg_inbox_get_authors_for_catchup();
if (!authors) {
DEBUG_INFO("forward_catchup: no authors with last_event_at > 0");
return 0;
}
int author_count = cJSON_GetArraySize(authors);
if (author_count == 0) {
cJSON_Delete(authors);
DEBUG_INFO("forward_catchup: no authors need catch-up");
return 0;
}
DEBUG_INFO("forward_catchup: checking %d authors for missed events", author_count);
long now = (long)time(NULL);
int page_size = cfg->backfill.events_per_tick;
if (page_size < 1) page_size = 500;
cr_sink_set_source_class(sink, CR_SINK_CLASS_BACKFILL);
int total_events_published = 0;
int authors_caught_up = 0;
for (int i = 0; i < author_count; i++) {
cJSON *entry = cJSON_GetArrayItem(authors, i);
if (!entry) continue;
cJSON *pk_node = cJSON_GetObjectItemCaseSensitive(entry, "pubkey");
cJSON *lea_node = cJSON_GetObjectItemCaseSensitive(entry, "last_event_at");
if (!pk_node || !cJSON_IsString(pk_node) || !lea_node || !cJSON_IsNumber(lea_node))
continue;
const char *pk = pk_node->valuestring;
long last_event_at = (long)lea_node->valuedouble;
if (last_event_at <= 0 || last_event_at >= now) continue;
/* Build filter: since = last_event_at + 1, until = now. */
cJSON *filter = cJSON_CreateObject();
cJSON *authors_arr = cJSON_CreateArray();
cJSON_AddItemToArray(authors_arr, cJSON_CreateString(pk));
cJSON_AddItemToObject(filter, "authors", authors_arr);
cJSON *kinds = build_kinds(cfg, pk);
if (kinds) cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)(last_event_at + 1)));
cJSON_AddItemToObject(filter, "until", cJSON_CreateNumber((double)now));
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber((double)page_size));
/* Try to get author-specific relays first; fall back to upstream pool. */
cJSON *author_relays = get_author_relays(pk);
int ev_count = 0;
cJSON **events = NULL;
if (author_relays && cJSON_GetArraySize(author_relays) > 0) {
/* Query using the author's outbox relays. */
int n_relays = cJSON_GetArraySize(author_relays);
const char **urls = malloc(sizeof(char *) * n_relays);
for (int r = 0; r < n_relays; r++) {
cJSON *u = cJSON_GetArrayItem(author_relays, r);
urls[r] = cJSON_IsString(u) ? u->valuestring : "";
}
events = synchronous_query_relays_with_progress(
urls, n_relays, filter, RELAY_QUERY_ALL_RESULTS,
&ev_count, 30, NULL, NULL, 0, NULL);
free(urls);
} else {
/* Fall back: query all connected upstream relays.
* Use the pool's relay list. We pass NULL for urls to let
* the pool use all connected relays. */
events = synchronous_query_relays_with_progress(
NULL, 0, filter, RELAY_QUERY_ALL_RESULTS,
&ev_count, 30, NULL, NULL, 0, NULL);
}
cJSON_Delete(author_relays);
cJSON_Delete(filter);
if (events && ev_count > 0) {
/* Publish all events. */
long newest = 0;
find_newest_created_at(events, ev_count, &newest);
for (int k = 0; k < ev_count; k++) {
cr_sink_publish(sink, events[k]);
cJSON_Delete(events[k]);
}
free(events);
total_events_published += ev_count;
authors_caught_up++;
/* Update last_event_at to the newest event we saw. */
if (newest > 0) {
pg_inbox_update_last_event_at(pk, newest);
}
DEBUG_LOG("forward_catchup: %s -> %d events (since=%ld, until=%ld)",
pk, ev_count, last_event_at + 1, now);
} else {
/* No events in the gap — update last_event_at to now so we
* don't re-query the same empty window next time. */
pg_inbox_update_last_event_at(pk, now);
if (events) free(events);
}
}
cJSON_Delete(authors);
DEBUG_INFO("forward_catchup: %d authors checked, %d had events, %d total events published",
author_count, authors_caught_up, total_events_published);
return 0;
}
+30
View File
@@ -0,0 +1,30 @@
/*
* caching_relay - forward catch-up for gap bridging.
*
* When the caching service starts (or caching is re-enabled after being
* off), events posted by followed authors during the downtime are missed
* by the backward-drain backfill (which walks toward the beginning of
* time) and by the live subscriber (which starts at `since = now`).
*
* The forward catch-up queries `since = last_event_at + 1, until = now`
* for each followed author to bridge that gap. `last_event_at` is the
* `created_at` of the most recent event we've ever seen for the author,
* maintained by the backfill and live subscriber.
*/
#ifndef CACHING_RELAY_FORWARD_CATCHUP_H
#define CACHING_RELAY_FORWARD_CATCHUP_H
#include "config.h"
#include "relay_sink.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
/* Run forward catch-up for all followed authors with last_event_at > 0.
* For each author, queries events from last_event_at + 1 to now on one
* outbox relay and publishes them to the sink.
*
* Returns 0 on success, -1 on error. */
int cr_forward_catchup(cr_config_t *cfg,
nostr_relay_pool_t *upstream,
cr_sink_t *sink);
#endif /* CACHING_RELAY_FORWARD_CATCHUP_H */
+69
View File
@@ -0,0 +1,69 @@
/*
* caching_relay - strip JSONC comments so cJSON can parse it.
*/
#define _GNU_SOURCE
#include "jsonc_strip.h"
#include <stdlib.h>
#include <string.h>
char *jsonc_strip_comments(const char *input, size_t len) {
if (!input) return NULL;
if (len == 0) len = strlen(input);
/* Worst case: output is same size as input (no comments). +1 for NUL. */
char *out = malloc(len + 1);
if (!out) return NULL;
size_t i = 0, o = 0;
int in_string = 0;
int escape = 0;
while (i < len) {
char c = input[i];
if (in_string) {
out[o++] = c;
if (escape) {
escape = 0;
} else if (c == '\\') {
escape = 1;
} else if (c == '"') {
in_string = 0;
}
i++;
continue;
}
/* Not in a string. */
if (c == '"') {
in_string = 1;
out[o++] = c;
i++;
continue;
}
/* Line comment // */
if (c == '/' && i + 1 < len && input[i + 1] == '/') {
i += 2;
while (i < len && input[i] != '\n') i++;
continue;
}
/* Block comment / * ... * / */
if (c == '/' && i + 1 < len && input[i + 1] == '*') {
i += 2;
while (i < len && !(input[i] == '*' && i + 1 < len && input[i + 1] == '/')) i++;
if (i < len) i += 2; /* skip closing */
/* Preserve a space so tokens don't merge. */
out[o++] = ' ';
continue;
}
out[o++] = c;
i++;
}
out[o] = '\0';
return out;
}
+16
View File
@@ -0,0 +1,16 @@
/*
* caching_relay - strip JSONC comments so cJSON can parse it.
*
* cJSON does not natively understand JSONC. This produces a heap buffer the
* caller must free() containing the comment-stripped JSON. String literals are
* respected so a double-slash inside a string is preserved.
*/
#ifndef CACHING_RELAY_JSONC_STRIP_H
#define CACHING_RELAY_JSONC_STRIP_H
#include <stddef.h>
/* Returns malloc'd buffer (null-terminated) or NULL on alloc failure. */
char *jsonc_strip_comments(const char *input, size_t len);
#endif /* CACHING_RELAY_JSONC_STRIP_H */
+199
View File
@@ -0,0 +1,199 @@
/*
* caching_relay - live subscription on the upstream pool
*/
#define _GNU_SOURCE
#include "live_subscriber.h"
#include "follow_graph.h"
#include "pg_inbox.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
#include <time.h>
/* Context passed as user_data to the subscription callbacks. */
typedef struct {
cr_sink_t *sink;
cr_live_t *live;
cr_config_t *cfg;
} cr_live_ctx_t;
static cr_live_ctx_t g_live_ctx;
static void live_on_event(cJSON *event, const char *relay_url, void *user_data) {
cr_live_ctx_t *ctx = (cr_live_ctx_t *)user_data;
(void)relay_url;
ctx->live->events_received++;
cr_sink_publish(ctx->sink, event);
/* Update last_event_at for forward catch-up gap tracking. */
cJSON *ev_pubkey = cJSON_GetObjectItem(event, "pubkey");
cJSON *ev_created = cJSON_GetObjectItem(event, "created_at");
if (ev_pubkey && cJSON_IsString(ev_pubkey) &&
ev_created && cJSON_IsNumber(ev_created)) {
pg_inbox_update_last_event_at(ev_pubkey->valuestring,
(long)ev_created->valuedouble);
}
/* Detect admin kind-3 (contact list) changes from a root pubkey and
* signal the main loop to refresh the follow graph immediately. */
if (ctx->cfg) {
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (kind && cJSON_IsNumber(kind) && kind->valuedouble == 3.0 &&
pubkey && cJSON_IsString(pubkey)) {
if (cr_follow_is_root(ctx->cfg, cJSON_GetStringValue(pubkey))) {
ctx->live->follow_graph_changed = 1;
DEBUG_INFO("live: root kind-3 detected from %s, "
"signaling follow graph refresh",
cJSON_GetStringValue(pubkey));
}
}
}
}
static void live_on_eose(cJSON **events, int event_count, void *user_data) {
(void)events; (void)event_count; (void)user_data;
DEBUG_LOG("live: EOSE (stored events flushed), staying open");
}
/* Build a filter for non-admin followed pubkeys with regular kinds. */
static cJSON *build_follows_filter(cr_config_t *cfg, cr_pubkey_set_t *followed) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < followed->count; i++) {
/* Skip admin pubkeys - they get their own subscription. */
if (cr_follow_is_root(cfg, followed->items[i])) continue;
cJSON_AddItemToArray(authors, cJSON_CreateString(followed->items[i]));
}
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->kinds[i]));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
return filter;
}
/* Build a filter for admin (root) pubkeys with admin_kinds (or all kinds). */
static cJSON *build_admin_filter(cr_config_t *cfg) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < cfg->root_npub_count; i++)
cJSON_AddItemToArray(authors, cJSON_CreateString(cfg->root_hex[i]));
cJSON_AddItemToObject(filter, "authors", authors);
/* If admin_all_kinds, omit the kinds filter entirely (grab everything). */
if (!cfg->admin_all_kinds && cfg->admin_kind_count > 0) {
cJSON *kinds = cJSON_CreateArray();
for (int i = 0; i < cfg->admin_kind_count; i++)
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(cfg->admin_kinds[i]));
cJSON_AddItemToObject(filter, "kinds", kinds);
}
cJSON_AddItemToObject(filter, "since", cJSON_CreateNumber((double)time(NULL)));
return filter;
}
static int get_relay_urls(nostr_relay_pool_t *upstream, const char ***urls_out) {
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
const char **urls = NULL;
if (n > 0) {
urls = malloc(n * sizeof(char *));
for (int j = 0; j < n; j++) urls[j] = listed[j];
}
free(listed);
free(statuses);
*urls_out = urls;
return n;
}
static nostr_pool_subscription_t *open_subscription(nostr_relay_pool_t *upstream,
cJSON *filter) {
const char **urls = NULL;
int n = get_relay_urls(upstream, &urls);
nostr_pool_subscription_t *sub = nostr_relay_pool_subscribe(
upstream, urls, n, filter,
live_on_event, live_on_eose, &g_live_ctx,
0, 1, NOSTR_POOL_EOSE_FULL_SET, 0, 0);
free(urls);
return sub;
}
static int open_subs(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink) {
g_live_ctx.sink = sink;
g_live_ctx.live = live;
g_live_ctx.cfg = cfg;
int admin_count = 0;
int follows_count = 0;
for (int i = 0; i < followed->count; i++) {
if (cr_follow_is_root(cfg, followed->items[i])) admin_count++;
else follows_count++;
}
/* Follows subscription (non-admin pubkeys, regular kinds). */
if (follows_count > 0) {
cJSON *filter = build_follows_filter(cfg, followed);
live->follows_sub = open_subscription(upstream, filter);
cJSON_Delete(filter);
if (!live->follows_sub) {
DEBUG_ERROR("live: follows subscribe failed");
} else {
DEBUG_INFO("live: follows sub - %d authors, %d kinds",
follows_count, cfg->kind_count);
}
}
/* Admin subscription (root npubs, admin_kinds or all kinds). */
if (admin_count > 0 || cfg->root_npub_count > 0) {
cJSON *filter = build_admin_filter(cfg);
live->admin_sub = open_subscription(upstream, filter);
cJSON_Delete(filter);
if (!live->admin_sub) {
DEBUG_ERROR("live: admin subscribe failed");
} else {
if (cfg->admin_all_kinds) {
DEBUG_INFO("live: admin sub - %d authors, ALL kinds",
cfg->root_npub_count);
} else {
DEBUG_INFO("live: admin sub - %d authors, %d kinds",
cfg->root_npub_count, cfg->admin_kind_count);
}
}
}
live->last_resubscribe = time(NULL);
return 0;
}
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink) {
memset(live, 0, sizeof(*live));
return open_subs(live, cfg, upstream, followed, sink);
}
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
cr_sink_t *sink) {
cr_live_close(live);
DEBUG_INFO("live: resubscribing (events so far: %ld)", live->events_received);
return open_subs(live, cfg, upstream, followed, sink);
}
void cr_live_close(cr_live_t *live) {
if (live->follows_sub) {
nostr_pool_subscription_close(live->follows_sub);
live->follows_sub = NULL;
}
if (live->admin_sub) {
nostr_pool_subscription_close(live->admin_sub);
live->admin_sub = NULL;
}
}
+38
View File
@@ -0,0 +1,38 @@
/*
* caching_relay - live subscription on the upstream pool.
*
* Builds filters from the followed-pubkey set + configured kinds, opens
* long-lived subscriptions, and routes on_event to the relay sink.
*
* Two subscriptions are opened:
* 1. "follows" sub: non-admin followed pubkeys + regular kinds
* 2. "admin" sub: root (admin) npubs + admin_kinds (or all kinds if [*])
*/
#ifndef CACHING_RELAY_LIVE_SUBSCRIBER_H
#define CACHING_RELAY_LIVE_SUBSCRIBER_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
typedef struct {
nostr_pool_subscription_t *follows_sub; /* non-admin pubkeys, regular kinds */
nostr_pool_subscription_t *admin_sub; /* admin pubkeys, admin_kinds */
long events_received;
time_t last_resubscribe;
int follow_graph_changed; /* set when a root npub publishes a kind-3 */
} cr_live_t;
/* Open the live subscription(s). Returns 0 on success. */
int cr_live_open(cr_live_t *live, cr_config_t *cfg, nostr_relay_pool_t *upstream,
cr_pubkey_set_t *followed, cr_sink_t *sink);
/* Close + reopen the subscription(s). Returns 0 on success. */
int cr_live_resubscribe(cr_live_t *live, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_pubkey_set_t *followed,
cr_sink_t *sink);
void cr_live_close(cr_live_t *live);
#endif /* CACHING_RELAY_LIVE_SUBSCRIBER_H */
+639
View File
@@ -0,0 +1,639 @@
/*
* caching_relay - a daemon that caches Nostr events from followed people
* into a local relay.
*
* Architecture: see plans/plan.md. Two nostr_relay_pool_t instances:
* - upstream_pool: query/subscribe to upstream relays
* - sink_pool (in cr_sink_t): publish-only to the local relay
*
* Build: see Makefile. Statically linked C99 binary, c-relay style.
*/
#define _GNU_SOURCE
#include "main.h"
#include "debug.h"
#include "config.h"
#include "state.h"
#include "follow_graph.h"
#include "relay_sink.h"
#include "live_subscriber.h"
#include "backfill.h"
#include "forward_catchup.h"
#include "relay_discovery.h"
#include "pg_inbox.h"
#include "pg_config.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include "../nostr_core_lib/nostr_core/nostr_log.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <getopt.h>
/* Forward nostr_core_lib's internal logging to our debug system. */
static void nostr_log_forwarder(int level, const char *component,
const char *message, void *user_data) {
(void)user_data;
/* Map nostr log levels to our debug levels (they're the same 1-5). */
if (level >= 5) {
DEBUG_TRACE("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 4) {
DEBUG_LOG("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 3) {
DEBUG_INFO("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else if (level >= 2) {
DEBUG_WARN("[nostr:%s] %s", component ? component : "?", message ? message : "");
} else {
DEBUG_ERROR("[nostr:%s] %s", component ? component : "?", message ? message : "");
}
}
/* Initialize per-relay backfill progress rows for every followed pubkey.
* For each author, merges its discovered outbox relays (from relay_map)
* with the bootstrap (upstream) relays from cfg, then inserts one
* caching_backfill_relay_progress row per relay (ON CONFLICT DO NOTHING).
* Safe to call repeatedly - existing rows are left untouched. */
static void init_relay_progress_for_all(const cr_config_t *cfg,
const cr_relay_map_t *relay_map,
const cr_pubkey_set_t *followed) {
if (!followed || followed->count <= 0) return;
/* Bootstrap relay URLs from cfg->upstream_relays. */
const char **bootstrap = NULL;
int bootstrap_n = cfg->upstream_count;
if (bootstrap_n > 0) {
bootstrap = malloc(bootstrap_n * sizeof(char *));
if (!bootstrap) return;
for (int i = 0; i < bootstrap_n; i++)
bootstrap[i] = cfg->upstream_relays[i];
}
for (int i = 0; i < followed->count; i++) {
const char *pk = followed->items[i];
if (!pk || !*pk) continue;
/* Collect this author's outbox relays. */
const cr_outbox_entry_t *oe = NULL;
int outbox_n = 0;
if (relay_map) {
oe = cr_relay_map_get_outbox(relay_map, pk);
if (oe) outbox_n = oe->relay_count;
}
int total = outbox_n + bootstrap_n;
if (total <= 0) continue;
const char **urls = malloc(total * sizeof(char *));
if (!urls) continue;
int n = 0;
if (oe) {
for (int j = 0; j < oe->relay_count && n < total; j++)
urls[n++] = oe->relays[j];
}
for (int j = 0; j < bootstrap_n && n < total; j++) {
int dup = 0;
for (int k = 0; k < n; k++) {
if (strcmp(urls[k], bootstrap[j]) == 0) { dup = 1; break; }
}
if (!dup) urls[n++] = bootstrap[j];
}
if (n > 0) {
pg_inbox_init_relay_progress_for_author(pk, urls, n);
}
free(urls);
}
free(bootstrap);
}
volatile sig_atomic_t g_shutdown = 0;
static volatile sig_atomic_t g_reload = 0;
static void on_signal(int sig) {
if (sig == SIGINT || sig == SIGTERM) g_shutdown = 1;
else if (sig == SIGHUP) g_reload = 1;
}
static void usage(const char *prog) {
fprintf(stderr,
"caching_relay %s - cache Nostr events from followed people into a local relay\n"
"\n"
"Usage: %s [-c <config.jsonc>] [-p <pg-conn>] [options]\n"
"\n"
"Options:\n"
" -c, --config <file> Path to .jsonc config file (default: ./caching_relay_config.jsonc)\n"
" -p, --pg-conn <str> PostgreSQL connection string (libpq format). When provided,\n"
" config is read from the c-relay-pg config table and fetched\n"
" events are inserted into the caching_event_inbox table instead\n"
" of being published via WebSocket to a local relay.\n"
" -d, --debug <level> Log level 0-5 (0=none, 1=error, 2=warn, 3=info, 4=debug, 5=trace). Default 3.\n"
" -r, --restart Reset state to first-time startup (ignore local relay cache,\n"
" re-discover all kind-10002 from bootstrap relays, reset backfill)\n"
" -h, --help Show this help\n"
"\n"
"Without -p, the daemon uses the .jsonc config file and publishes to a local relay\n"
"via WebSocket (legacy mode). With -p, it uses PostgreSQL for config and inbox.\n"
"The config file is also the persistent state store in legacy mode; the daemon\n"
"rewrites it as backfill progresses. See plans/plan.md and caching_relay_config.jsonc.\n",
CR_VERSION, prog);
}
int main(int argc, char **argv) {
const char *config_path = NULL;
const char *pg_conn = NULL;
int log_level = DEBUG_LEVEL_INFO;
int restart = 0;
static struct option longopts[] = {
{"config", required_argument, 0, 'c'},
{"pg-conn", required_argument, 0, 'p'},
{"debug", required_argument, 0, 'd'},
{"restart", no_argument, 0, 'r'},
{"help", no_argument, 0, 'h'},
{0, 0, 0, 0}
};
int opt;
while ((opt = getopt_long(argc, argv, "c:p:d:rh", longopts, NULL)) != -1) {
switch (opt) {
case 'c': config_path = optarg; break;
case 'p': pg_conn = optarg; break;
case 'd': log_level = atoi(optarg); break;
case 'r': restart = 1; break;
case 'h': usage(argv[0]); return 0;
default: usage(argv[0]); return 1;
}
}
/* In PostgreSQL mode, the .jsonc config file is optional. */
if (!config_path && !pg_conn) {
/* Default: look for caching_relay.jsonc in the current directory. */
config_path = "caching_relay_config.jsonc";
if (access(config_path, F_OK) != 0) {
fprintf(stderr, "ERROR: no config specified and default '%s' not found\n\n", config_path);
usage(argv[0]);
return 1;
}
}
if (log_level < 0) log_level = 0;
if (log_level > 5) log_level = 5;
debug_init(log_level);
/* Enable nostr_core_lib internal logging and forward to our debug system.
* This shows the actual WebSocket messages (REQ filters, EVENT responses)
* at debug level 5 (trace). */
nostr_set_log_callback(nostr_log_forwarder, NULL);
nostr_set_log_level((nostr_log_level_t)log_level);
DEBUG_INFO("caching_relay %s starting (config=%s, pg-conn=%s, loglevel=%d%s)",
CR_VERSION,
config_path ? config_path : "(none)",
pg_conn ? "yes" : "no",
log_level, restart ? ", RESTART" : "");
/* Signals. */
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = on_signal;
sigaction(SIGINT, &sa, NULL);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGHUP, &sa, NULL);
/* Ignore SIGPIPE - relay pool handles its own socket errors. */
signal(SIGPIPE, SIG_IGN);
/* PostgreSQL inbox mode: connect and load config from the config table. */
long config_generation = -1;
if (pg_conn) {
if (pg_inbox_init(pg_conn) != 0) {
DEBUG_ERROR("failed to connect to PostgreSQL");
return 1;
}
config_generation = pg_inbox_get_config_generation();
if (config_generation < 0) {
DEBUG_WARN("could not read caching_config_generation (defaulting to 0)");
config_generation = 0;
}
DEBUG_INFO("config generation: %ld", config_generation);
}
/* Load config. */
cr_config_t cfg;
if (pg_conn) {
if (pg_config_load(&cfg) != 0) {
DEBUG_ERROR("failed to load config from PostgreSQL");
pg_inbox_shutdown();
return 1;
}
} else {
if (cr_config_load(&cfg, config_path) != 0) return 1;
}
/* --restart: reset state to first-time startup. */
if (restart) {
DEBUG_INFO("RESTART: resetting state to first-time startup");
cfg.state.backfilled_until = 0;
if (pg_conn) {
/* Reset per-author backfill progress (caching_followed_pubkeys)
* so the next run starts fresh. The followed set itself is
* preserved; only cursor/completion state is reset. */
pg_inbox_reset_backfill_progress();
} else {
cr_config_save_state(&cfg);
}
}
/* Init crypto. */
if (nostr_crypto_init() != NOSTR_SUCCESS) {
DEBUG_ERROR("nostr_crypto_init failed");
return 1;
}
/* In-memory state. */
cr_seen_ring_t seen;
cr_seen_ring_init(&seen);
cr_pubkey_set_t followed;
cr_pubkey_set_init(&followed);
/* Upstream pool. */
nostr_relay_pool_t *upstream = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
if (!upstream) {
DEBUG_ERROR("failed to create upstream pool");
return 1;
}
for (int i = 0; i < cfg.upstream_count; i++) {
if (nostr_relay_pool_add_relay(upstream, cfg.upstream_relays[i]) != NOSTR_SUCCESS) {
DEBUG_WARN("failed to add upstream relay %s (continuing)", cfg.upstream_relays[i]);
} else {
DEBUG_INFO("upstream: added %s", cfg.upstream_relays[i]);
}
}
/* Sink: WebSocket pool (legacy) or PostgreSQL inbox. */
cr_sink_t sink;
if (pg_conn) {
if (cr_sink_init_pg(&sink, &seen) != 0) {
DEBUG_ERROR("failed to init sink (pg)");
return 1;
}
} else {
if (cr_sink_init(&sink, cfg.local_relay, &seen) != 0) {
DEBUG_ERROR("failed to init sink");
return 1;
}
}
/* Give the sink pool a moment to connect to the local relay before we
* start publishing (relay discovery caches kind-10002 events to local).
* No-op in PostgreSQL inbox mode (cr_sink_pump does nothing). */
if (!pg_conn) {
DEBUG_INFO("waiting for sink connection to establish...");
for (int i = 0; i < 30 && !g_shutdown; i++) {
cr_sink_pump(&sink, 100);
}
}
/* Resolve follow graph. */
if (g_shutdown) goto shutdown;
if (cr_follow_resolve(&cfg, upstream, &followed) != 0) {
DEBUG_ERROR("follow graph resolution failed");
return 1;
}
/* Sync the followed set to the DB so the backfill tick can iterate
* over it (per-author until-cursor drain). Also run the one-time
* migration from the old caching_backfill_progress table. */
if (pg_conn) {
pg_inbox_migrate_backfill_progress();
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
}
if (g_shutdown) goto shutdown;
/* Discover outbox relays (NIP-65) and compute minimum covering set. */
int is_first_time = (cfg.state.backfilled_until == 0);
cr_relay_map_t relay_map;
if (cr_relay_discovery_run(&relay_map, &cfg, upstream, &sink, &followed,
is_first_time) != 0) {
DEBUG_WARN("relay discovery failed, continuing with bootstrap relays only");
memset(&relay_map, 0, sizeof(relay_map));
}
/* Add discovered outbox relays to the upstream pool. */
for (int i = 0; i < relay_map.selected_count; i++) {
/* Check if already in the pool (bootstrap relays may already be there). */
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
int already = 0;
for (int j = 0; j < n; j++) {
if (strcmp(listed[j], relay_map.selected_relays[i]) == 0) {
already = 1; break;
}
}
free(listed);
free(statuses);
if (!already) {
if (nostr_relay_pool_add_relay(upstream, relay_map.selected_relays[i]) == NOSTR_SUCCESS) {
DEBUG_INFO("upstream: added outbox relay %s", relay_map.selected_relays[i]);
}
}
}
/* Log final upstream pool. */
{
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
DEBUG_INFO("upstream_pool: %d relays connected:", n);
for (int j = 0; j < n; j++) {
DEBUG_INFO(" %s", listed[j]);
}
free(listed);
free(statuses);
}
/* Create per-relay backfill progress rows for every followed pubkey
* (outbox relays + bootstrap relays). Existing rows are left untouched
* so cursor/completion state is preserved across restarts. */
if (pg_conn) {
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
/* Forward catch-up: bridge the gap for events posted while caching
* was off. Runs once at startup (not on --restart, which does a full
* re-drain from now). For each followed author with last_event_at > 0,
* queries since = last_event_at + 1, until = now. */
if (pg_conn && !restart && cfg.backfill.enabled) {
DEBUG_INFO("forward catch-up: bridging gap for followed authors");
cr_forward_catchup(&cfg, upstream, &sink);
}
/* Open live subscription. */
cr_live_t live;
if (cfg.live.enabled) {
if (cr_live_open(&live, &cfg, upstream, &followed, &sink) != 0) {
DEBUG_WARN("live subscription failed to open (will retry on resubscribe)");
}
} else {
memset(&live, 0, sizeof(live));
}
/* Init backfill. */
cr_backfill_t bf;
cr_backfill_init(&bf, &cfg);
time_t last_follow_refresh = time(NULL);
time_t last_state_save = time(NULL);
time_t last_status_heartbeat = 0;
/* Initial status heartbeat in PostgreSQL mode. */
if (pg_conn) {
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("starting", config_generation, (long)time(NULL),
followed.count, relay_map.selected_count, 0,
bf_complete, bf_total, 0, 0, NULL, 0);
}
DEBUG_INFO("entering main loop");
while (!g_shutdown) {
if (g_reload) {
g_reload = 0;
DEBUG_INFO("SIGHUP: reloading config (state preserved)");
cr_config_t newcfg;
int reload_ok = 0;
if (pg_conn) {
if (pg_config_load(&newcfg) == 0 &&
cr_follow_decode_roots(&newcfg) == 0) reload_ok = 1;
} else if (config_path) {
if (cr_config_load(&newcfg, config_path) == 0) reload_ok = 1;
}
if (reload_ok) {
/* Preserve runtime state across reload. */
newcfg.state = cfg.state;
cr_config_free(&cfg);
cfg = newcfg;
DEBUG_INFO("config reloaded");
/* Force an immediate follow-graph refresh so any new
* root npubs are picked up right away. */
last_follow_refresh = 0;
} else {
DEBUG_ERROR("config reload failed, keeping old config");
}
}
/* PostgreSQL: check for config generation change and reload. */
if (pg_conn) {
int changed = pg_config_generation_changed(config_generation);
if (changed == 1) {
long new_gen = pg_inbox_get_config_generation();
DEBUG_INFO("config generation changed (%ld -> %ld), reloading",
config_generation, new_gen);
cr_config_t newcfg;
if (pg_config_load(&newcfg) == 0) {
/* Decode root npubs to hex — pg_config_load fills
* root_npubs[] but NOT root_hex[] / root_hex_ready.
* Without this, cr_follow_is_root() returns 0 for
* everything and the new root's follows are never
* resolved. */
if (cr_follow_decode_roots(&newcfg) != 0) {
DEBUG_ERROR("config reload: failed to decode root npubs, keeping old config");
cr_config_free(&newcfg);
} else {
newcfg.state = cfg.state;
cr_config_free(&cfg);
cfg = newcfg;
config_generation = new_gen;
DEBUG_INFO("config reloaded from PostgreSQL");
/* Force an immediate follow-graph refresh so the
* new root npub's follows are picked up right
* away (instead of waiting up to
* follow_graph_refresh_seconds). */
last_follow_refresh = 0;
/* Resubscribe live with new config. */
if (cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
}
} else {
DEBUG_ERROR("PostgreSQL config reload failed, keeping old config");
}
} else if (changed < 0) {
DEBUG_WARN("config generation check failed");
}
}
/* Pump upstream pool (drives live subscription callbacks). */
nostr_relay_pool_run(upstream, 100);
/* Pump sink pool (flush publish callbacks). */
cr_sink_pump(&sink, 50);
/* Backfill tick. */
int brc = cr_backfill_tick(&bf, &cfg, upstream, &followed, &sink, &relay_map);
(void)brc;
/* Immediate follow-graph refresh when a root npub publishes a new
* kind-3 contact list (detected by the live subscriber). */
if (cfg.live.enabled && live.follow_graph_changed) {
live.follow_graph_changed = 0;
DEBUG_INFO("live: admin kind-3 detected, refreshing follow graph immediately");
cr_pubkey_set_t new_followed;
cr_pubkey_set_init(&new_followed);
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
int changed = (new_followed.count != followed.count);
if (!changed) {
for (int i = 0; i < followed.count; i++) {
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
changed = 1; break;
}
}
}
cr_pubkey_set_free(&followed);
followed = new_followed;
if (pg_conn) {
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
/* Create relay progress rows for newly added follows.
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
if (changed && cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Reset the backfill in_progress flag in case we had entered
* steady-state; new follows need to be drained. */
bf.in_progress = 1;
last_follow_refresh = time(NULL);
} else {
DEBUG_WARN("immediate follow graph refresh failed");
cr_pubkey_set_free(&new_followed);
}
}
/* Periodic follow-graph refresh. */
time_t now = time(NULL);
if (cfg.follow_graph_refresh_seconds > 0 &&
(now - last_follow_refresh) >= cfg.follow_graph_refresh_seconds) {
DEBUG_INFO("refreshing follow graph");
cr_pubkey_set_t new_followed;
cr_pubkey_set_init(&new_followed);
if (cr_follow_resolve(&cfg, upstream, &new_followed) == 0) {
/* If the set changed, resubscribe live. */
int changed = (new_followed.count != followed.count);
if (!changed) {
for (int i = 0; i < followed.count; i++) {
if (!cr_pubkey_set_contains(&new_followed, followed.items[i])) {
changed = 1; break;
}
}
}
cr_pubkey_set_free(&followed);
followed = new_followed;
/* Sync the refreshed followed set to the DB so newly added
* follows get backfilled and dropped follows stop. Existing
* cursor/completion state is preserved (upsert only touches
* is_root and last_seen). */
if (pg_conn) {
const char **root_pks = malloc(cfg.root_npub_count * sizeof(char *));
for (int i = 0; i < cfg.root_npub_count; i++)
root_pks[i] = cfg.root_hex[i];
pg_inbox_sync_followed_pubkeys((const char **)followed.items,
followed.count, root_pks,
cfg.root_npub_count);
free(root_pks);
/* Create relay progress rows for newly added follows.
* Existing rows are preserved (ON CONFLICT DO NOTHING). */
init_relay_progress_for_all(&cfg, &relay_map, &followed);
}
if (changed && cfg.live.enabled) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Reset the backfill in_progress flag in case we had entered
* steady-state; new follows need to be drained. The backfill
* tick will quickly re-enter steady-state if there are no
* incomplete authors, so this is cheap. */
bf.in_progress = 1;
} else {
DEBUG_WARN("follow graph refresh failed");
cr_pubkey_set_free(&new_followed);
}
last_follow_refresh = now;
}
/* Periodic live resubscribe. */
if (cfg.live.enabled && cfg.live.resubscribe_interval_seconds > 0 &&
(now - live.last_resubscribe) >= cfg.live.resubscribe_interval_seconds) {
cr_live_resubscribe(&live, &cfg, upstream, &followed, &sink);
}
/* Periodic state save (in case backfill didn't just save). */
if ((now - last_state_save) >= 60) {
if (!pg_conn) cr_config_save_state(&cfg);
last_state_save = now;
}
/* PostgreSQL: periodic status heartbeat. */
if (pg_conn && (now - last_status_heartbeat) >= 15) {
long events_fetched = live.events_received + bf.events_total;
long inbox_inserts = sink.published_ok;
int connected = 0;
{
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(upstream, &listed, &statuses);
for (int j = 0; j < n; j++) {
if (statuses[j] == NOSTR_POOL_RELAY_CONNECTED) connected++;
}
free(listed);
free(statuses);
}
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("running", config_generation, (long)now,
followed.count, relay_map.selected_count,
connected, bf_complete, bf_total,
events_fetched, inbox_inserts, NULL, 0);
last_status_heartbeat = now;
}
}
/* Graceful shutdown. */
shutdown:
DEBUG_INFO("shutting down...");
cr_live_close(&live);
if (!pg_conn) cr_config_save_state(&cfg);
if (pg_conn) {
int bf_complete = 0, bf_total = 0;
pg_inbox_count_backfill_progress(&bf_complete, &bf_total);
pg_inbox_update_status("stopped", config_generation, (long)time(NULL),
followed.count, relay_map.selected_count, 0,
bf_complete, bf_total,
live.events_received + bf.events_total,
sink.published_ok, NULL, 0);
}
cr_sink_destroy(&sink);
nostr_relay_pool_destroy(upstream);
cr_pubkey_set_free(&followed);
cr_relay_map_free(&relay_map);
cr_config_free(&cfg);
if (pg_conn) pg_inbox_shutdown();
nostr_crypto_cleanup();
DEBUG_INFO("clean exit");
return 0;
}
+16
View File
@@ -0,0 +1,16 @@
/*
* caching_relay - main header
*
* Version information is auto-updated by increment_and_push.sh.
* Git tags are the source of truth for versioning.
*/
#ifndef CACHING_RELAY_MAIN_H
#define CACHING_RELAY_MAIN_H
// Version information (auto-updated by increment_and_push.sh)
#define CR_VERSION_MAJOR 0
#define CR_VERSION_MINOR 0
#define CR_VERSION_PATCH 2
#define CR_VERSION "v0.0.2"
#endif /* CACHING_RELAY_MAIN_H */
+218
View File
@@ -0,0 +1,218 @@
/*
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
* config table and populate the existing cr_config_t structure.
*/
#define _GNU_SOURCE
#include "pg_config.h"
#include "pg_inbox.h"
#include "debug.h"
#include <stdlib.h>
#include <string.h>
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
/* Split a comma-separated string into a string array (fixed-size rows).
* Trims leading/trailing whitespace from each token. */
static void split_csv_str(const char *s, char *dst, int max, int elem_len,
int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
/* Work on a mutable copy because strtok_r modifies its input. */
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
/* Trim leading spaces. */
while (*tok == ' ' || *tok == '\t') tok++;
/* Trim trailing spaces. */
char *end = tok + strlen(tok) - 1;
while (end > tok && (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n')) {
*end-- = '\0';
}
if (*tok != '\0') {
strncpy(dst + count * elem_len, tok, elem_len - 1);
dst[count * elem_len + (elem_len - 1)] = '\0';
count++;
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Split a comma-separated string of integers into an int array. */
static void split_csv_int(const char *s, int *dst, int max, int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
while (*tok == ' ' || *tok == '\t') tok++;
if (*tok != '\0') {
dst[count++] = atoi(tok);
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Split a comma-separated string of longs into a long array. */
static void split_csv_long(const char *s, long *dst, int max, int *count_out) {
int count = 0;
if (!s) { *count_out = 0; return; }
char *copy = strdup(s);
if (!copy) { *count_out = 0; return; }
char *saveptr = NULL;
char *tok = strtok_r(copy, ",", &saveptr);
while (tok && count < max) {
while (*tok == ' ' || *tok == '\t') tok++;
if (*tok != '\0') {
dst[count++] = strtol(tok, NULL, 10);
}
tok = strtok_r(NULL, ",", &saveptr);
}
free(copy);
*count_out = count;
}
/* Read a config key as a freshly-allocated string. Returns NULL if missing. */
static char *get_key(const char *key) {
return pg_inbox_get_config_value(key);
}
/* Read a config key as a boolean ("true"/"1"/"yes" -> 1). */
static int get_bool(const char *key, int default_val) {
char *v = get_key(key);
if (!v) return default_val;
int r = (strcmp(v, "true") == 0 || strcmp(v, "1") == 0 ||
strcmp(v, "yes") == 0 || strcmp(v, "on") == 0);
free(v);
return r;
}
/* Read a config key as a long. */
static long get_long(const char *key, long default_val) {
char *v = get_key(key);
if (!v) return default_val;
long r = strtol(v, NULL, 10);
free(v);
return r;
}
/* Read a config key as an int. */
static int get_int(const char *key, int default_val) {
return (int)get_long(key, (long)default_val);
}
/* ------------------------------------------------------------------ */
/* Public API */
/* ------------------------------------------------------------------ */
int pg_config_load(cr_config_t *cfg) {
if (!cfg) return -1;
memset(cfg, 0, sizeof(*cfg));
/* root_npubs */
char *npubs = get_key("caching_root_npubs");
split_csv_str(npubs, (char *)cfg->root_npubs, CR_MAX_ROOT_NPUBS,
CR_NPUB_LEN, &cfg->root_npub_count);
free(npubs);
/* bootstrap_relays -> upstream_relays */
char *relays = get_key("caching_bootstrap_relays");
split_csv_str(relays, (char *)cfg->upstream_relays, CR_MAX_UPSTREAM,
CR_URL_LEN, &cfg->upstream_count);
free(relays);
/* local_relay - no longer required for PostgreSQL mode, but keep a
* placeholder for compatibility. The caller may override it. */
cfg->local_relay[0] = '\0';
/* kinds */
char *kinds = get_key("caching_kinds");
split_csv_int(kinds, cfg->kinds, CR_MAX_KINDS, &cfg->kind_count);
free(kinds);
/* admin_kinds - supports "*" to mean all kinds */
char *akinds = get_key("caching_admin_kinds");
if (akinds) {
/* Check for "*" anywhere in the list. */
if (strstr(akinds, "*") != NULL) {
cfg->admin_all_kinds = 1;
} else {
split_csv_int(akinds, cfg->admin_kinds, CR_MAX_KINDS,
&cfg->admin_kind_count);
}
free(akinds);
}
/* live */
cfg->live.enabled = get_bool("caching_live_enabled", 1);
cfg->live.resubscribe_interval_seconds =
get_int("caching_live_resubscribe_seconds", 300);
/* backfill */
cfg->backfill.enabled = get_bool("caching_backfill_enabled", 1);
cfg->backfill.events_per_tick =
get_int("caching_backfill_page_size", 500);
/* tick interval is stored in ms in the config table; convert to seconds. */
int tick_ms = get_int("caching_backfill_tick_interval_ms", 5000);
cfg->backfill.tick_interval_seconds = (tick_ms + 999) / 1000;
if (cfg->backfill.tick_interval_seconds < 1)
cfg->backfill.tick_interval_seconds = 1;
/* follow graph refresh */
cfg->follow_graph_refresh_seconds =
get_int("caching_follow_graph_refresh_seconds", 600);
/* Defaults if missing. */
if (cfg->backfill.events_per_tick == 0)
cfg->backfill.events_per_tick = 500;
if (cfg->backfill.tick_interval_seconds == 0)
cfg->backfill.tick_interval_seconds = 5;
if (cfg->live.resubscribe_interval_seconds == 0)
cfg->live.resubscribe_interval_seconds = 300;
if (cfg->follow_graph_refresh_seconds == 0)
cfg->follow_graph_refresh_seconds = 600;
/* State: per-author backfill progress lives in caching_followed_pubkeys.
* backfilled_until is only used as a first-time flag in legacy mode. */
cfg->state.backfilled_until = 0;
/* Validation. */
if (cfg->root_npub_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_root_npubs required");
return -1;
}
if (cfg->upstream_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_bootstrap_relays required");
return -1;
}
if (cfg->kind_count == 0) {
DEBUG_ERROR("pg_config: at least one caching_kinds required");
return -1;
}
DEBUG_INFO("pg_config loaded: %d root npubs, %d upstream relays, %d kinds",
cfg->root_npub_count, cfg->upstream_count, cfg->kind_count);
return 0;
}
int pg_config_generation_changed(long last_generation) {
long cur = pg_inbox_get_config_generation();
if (cur < 0) return -1;
return (cur != last_generation) ? 1 : 0;
}
+19
View File
@@ -0,0 +1,19 @@
/*
* caching_relay - read caching configuration from the c-relay-pg PostgreSQL
* config table and populate the existing cr_config_t structure.
*/
#ifndef CACHING_RELAY_PG_CONFIG_H
#define CACHING_RELAY_PG_CONFIG_H
#include "config.h" /* cr_config_t */
/* Load caching configuration from the c-relay-pg PostgreSQL config table.
* Populates the cr_config_t fields from the config table.
* Returns 0 on success, -1 on error. */
int pg_config_load(cr_config_t *cfg);
/* Check if the config generation has changed since the last load.
* Returns 1 if changed, 0 if not, -1 on error. */
int pg_config_generation_changed(long last_generation);
#endif /* CACHING_RELAY_PG_CONFIG_H */
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
/*
* caching_relay - PostgreSQL inbox connection and operations.
*
* Manages the libpq connection to the c-relay-pg PostgreSQL database and
* provides functions to insert fetched events into the caching_event_inbox
* table, update the caching_service_state singleton, and read configuration
* from the shared config table.
*/
#ifndef CACHING_RELAY_PG_INBOX_H
#define CACHING_RELAY_PG_INBOX_H
#include <stddef.h>
/* Forward declaration so the header does not pull in cJSON everywhere. */
typedef struct cJSON cJSON;
/* Initialize PostgreSQL connection using the given connection string.
* Returns 0 on success, -1 on error. */
int pg_inbox_init(const char *connection_string);
/* Close the PostgreSQL connection. */
void pg_inbox_shutdown(void);
/* Insert an event into the caching_event_inbox table.
* event_json is the raw Nostr event JSON string.
* source_relay may be NULL. source_class is "live", "discovery", or "backfill".
* priority is 0 (live/discovery) or 1 (backfill).
* Returns 0 on success (including conflict-skip), -1 on error. */
int pg_inbox_insert_event(const char *event_json, const char *source_relay,
const char *source_class, int priority);
/* Update the caching_service_state singleton row.
* All string parameters may be NULL (left unchanged).
* Returns 0 on success, -1 on error. */
int pg_inbox_update_status(const char *service_state,
long config_generation,
long heartbeat_at,
int followed_author_count,
int selected_relay_count,
int connected_relay_count,
int backfill_authors_complete,
int backfill_authors_total,
long events_fetched,
long inbox_inserts,
const char *last_error,
long last_error_at);
/* Read a config value from the c-relay-pg config table.
* Returns a malloc'd string (caller must free) or NULL if not found/error. */
char *pg_inbox_get_config_value(const char *key);
/* Read the current config generation.
* Returns the generation number, or -1 on error. */
long pg_inbox_get_config_generation(void);
/* ------------------------------------------------------------------ */
/* Per-author until-cursor drain backfill model */
/* ------------------------------------------------------------------ */
/* Reset all backfill progress: set until_cursor=0 and backfill_complete=FALSE
* for all rows in caching_followed_pubkeys (the followed set itself is
* preserved). Used by the "Reset Backfill" API button and --restart.
* Returns 0 on success, -1 on error. */
int pg_inbox_reset_backfill_progress(void);
/* Upsert all followed pubkeys into caching_followed_pubkeys.
* For each pubkey: INSERT if new (until_cursor=0, backfill_complete=FALSE),
* or UPDATE is_root and last_seen if existing (until_cursor and
* backfill_complete are NOT touched). root_pubkeys marks which entries are
* root follows. Returns 0 on success, -1 on error. */
int pg_inbox_sync_followed_pubkeys(const char **pubkeys, int count,
const char **root_pubkeys, int root_count);
/* Pick the next incomplete author for backfill, round-robin.
* On success fills out_pk, out_until_cursor and out_is_root, advances
* *round_cursor, and returns 0. Returns -1 if no incomplete authors or
* on error. */
int pg_inbox_pick_next_backfill_author(char *out_pk, int pk_len,
long *out_until_cursor,
int *out_is_root,
int *round_cursor);
/* Update the cursor and completion state for an author.
* events_this_page is added to the running events_fetched counter.
* Returns 0 on success, -1 on error. */
int pg_inbox_update_backfill_progress(const char *pk, long until_cursor,
int complete, int events_this_page);
/* One-time migration from the old caching_backfill_progress table to the
* new caching_followed_pubkeys table. Only runs if the new table is empty
* and the old table has rows. Returns 0 on success (including no-op),
* -1 on error. */
int pg_inbox_migrate_backfill_progress(void);
/* Count complete/total authors for status reporting.
* Returns 0 on success, -1 on error. */
int pg_inbox_count_backfill_progress(int *out_complete, int *out_total);
/* ------------------------------------------------------------------ */
/* Per-relay backfill progress (relay-by-relay drain model) */
/* ------------------------------------------------------------------ */
/* Initialize relay progress rows for an author. Creates one row per relay
* URL with until_cursor=0, complete=FALSE. Skips rows that already exist
* (ON CONFLICT DO NOTHING). Returns 0 on success, -1 on error. */
int pg_inbox_init_relay_progress_for_author(const char *pk,
const char **relay_urls,
int relay_count);
/* Pick the next author that has at least one incomplete relay progress row.
* Round-robin via *round_cursor. On success fills out_pk and returns 0.
* Returns -1 if no incomplete authors or on error. */
int pg_inbox_pick_next_author_with_incomplete_relays(char *out_pk, int pk_len,
int *round_cursor);
/* Get incomplete relays for an author. Returns a cJSON array of
* {relay_url, until_cursor} objects (caller frees with cJSON_Delete).
* Returns NULL on error or if no incomplete relays. */
cJSON *pg_inbox_get_incomplete_relays(const char *pk);
/* Update relay progress: advance cursor and/or mark complete.
* events_this_page is added to the running events_fetched counter.
* last_status is a short string like "eose", "timeout", "error", or NULL
* to leave unchanged. Returns 0 on success, -1 on error. */
int pg_inbox_update_relay_progress(const char *pk, const char *relay_url,
long until_cursor, int complete,
int events_this_page,
const char *last_status);
/* Check if all relays for an author are complete. If so, mark the author
* as backfill_complete=TRUE in caching_followed_pubkeys.
* Returns 1 if marked complete, 0 if still incomplete, -1 on error. */
int pg_inbox_check_and_mark_author_complete(const char *pk);
/* ------------------------------------------------------------------ */
/* Active backfill target (for UI status display) */
/* ------------------------------------------------------------------ */
/* Set the currently-active backfill target (pubkey + relay being queried).
* Either parameter may be NULL/empty to clear. Creates the
* caching_backfill_active table if needed (single-row singleton).
* Returns 0 on success, -1 on error. */
int pg_inbox_set_active_target(const char *pubkey, const char *relay_url);
/* Read the active backfill target. Fills out_pubkey/out_relay with
* the current values (or empty strings if none). Buffers must be
* at least 65 and 256 bytes respectively.
* Returns 0 on success, -1 on error. */
int pg_inbox_get_active_target(char *out_pubkey, int pk_len,
char *out_relay, int relay_len);
/* ------------------------------------------------------------------ */
/* Forward catch-up support (last_event_at tracking) */
/* ------------------------------------------------------------------ */
/* Returns a cJSON array of {pubkey, last_event_at} objects for all
* followed authors where last_event_at > 0. Caller must cJSON_Delete().
* Returns NULL on error. */
cJSON* pg_inbox_get_authors_for_catchup(void);
/* Update last_event_at for a pubkey to the GREATEST of current and
* the given value. Returns 0 on success, -1 on error. */
int pg_inbox_update_last_event_at(const char *pk, long event_created_at);
#endif /* CACHING_RELAY_PG_INBOX_H */
+561
View File
@@ -0,0 +1,561 @@
/*
* caching_relay - NIP-65 outbox relay discovery
*/
#define _GNU_SOURCE
#include "relay_discovery.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
#include <signal.h>
/* Access the shutdown flag from main.c so we can abort discovery early. */
extern volatile sig_atomic_t g_shutdown;
/* Normalize a relay URL: strip trailing slash for consistency. */
static void normalize_url(char *url, size_t maxlen) {
size_t len = strlen(url);
while (len > 0 && url[len - 1] == '/') {
url[--len] = '\0';
}
(void)maxlen;
}
/* ---- helpers ---- */
static int get_pool_urls(nostr_relay_pool_t *pool, const char ***urls_out) {
char **listed = NULL;
nostr_pool_relay_status_t *statuses = NULL;
int n = nostr_relay_pool_list_relays(pool, &listed, &statuses);
const char **urls = NULL;
if (n > 0) {
urls = malloc(n * sizeof(char *));
for (int j = 0; j < n; j++) urls[j] = listed[j];
}
free(listed);
free(statuses);
*urls_out = urls;
return n;
}
/* Query the most recent kind 10002 for a single pubkey from a pool.
* Returns the event (caller must cJSON_Delete) or NULL. */
static cJSON *query_kind10002(nostr_relay_pool_t *pool, const char *hex) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
cJSON_AddItemToArray(authors, cJSON_CreateString(hex));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(1));
const char **urls = NULL;
int n = get_pool_urls(pool, &urls);
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 10000);
free(urls);
cJSON_Delete(filter);
cJSON *result = NULL;
if (events && ev_count > 0) {
result = events[0]; /* take first (most recent) */
for (int k = 1; k < ev_count; k++) cJSON_Delete(events[k]);
}
free(events);
return result;
}
/* Parse "r" tags from a kind 10002 event into an outbox entry.
* Only includes relays with "read" marker or no marker (assume both).
* Skips "write" only relays. */
static int parse_r_tags(cJSON *event, cr_outbox_entry_t *entry) {
memset(entry, 0, sizeof(*entry));
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
if (pubkey && cJSON_IsString(pubkey)) {
strncpy(entry->pubkey, cJSON_GetStringValue(pubkey), CR_HEX_LEN - 1);
}
cJSON *tags = cJSON_GetObjectItem(event, "tags");
if (!tags || !cJSON_IsArray(tags)) return 0;
int added = 0;
cJSON *tag;
cJSON_ArrayForEach(tag, tags) {
if (!cJSON_IsArray(tag)) continue;
cJSON *name = cJSON_GetArrayItem(tag, 0);
if (!name || !cJSON_IsString(name)) continue;
if (strcmp(cJSON_GetStringValue(name), "r") != 0) continue;
cJSON *url = cJSON_GetArrayItem(tag, 1);
if (!url || !cJSON_IsString(url)) continue;
const char *relay_url = cJSON_GetStringValue(url);
if (!relay_url || !relay_url[0]) continue;
/* Check marker (3rd element): "read", "write", or absent. */
cJSON *marker = cJSON_GetArrayItem(tag, 2);
if (marker && cJSON_IsString(marker)) {
const char *m = cJSON_GetStringValue(marker);
if (strcmp(m, "write") == 0) continue; /* skip write-only relays */
}
/* No marker or "read" => include. */
if (entry->relay_count >= CR_MAX_RELAYS_PER_PUBKEY) break;
strncpy(entry->relays[entry->relay_count], relay_url, CR_URL_LEN - 1);
entry->relays[entry->relay_count][CR_URL_LEN - 1] = '\0';
normalize_url(entry->relays[entry->relay_count], CR_URL_LEN);
entry->relay_count++;
added++;
}
return added;
}
/* ---- greedy set cover ---- */
/* Build relay -> pubkey coverage map and compute minimum covering set. */
static void compute_covering_set(cr_relay_map_t *map, cr_config_t *cfg,
const cr_pubkey_set_t *followed) {
/* Build a temporary relay -> pubkeys map. */
typedef struct {
char url[CR_URL_LEN];
char pubkeys[CR_MAX_PUBKEYS_PER_RELAY][CR_HEX_LEN];
int pubkey_count;
} relay_cov_t;
relay_cov_t *relays = calloc(CR_MAX_DISCOVERED_RELAYS, sizeof(relay_cov_t));
int relay_count = 0;
/* Always include bootstrap relays in the coverage map. */
for (int i = 0; i < cfg->upstream_count && relay_count < CR_MAX_DISCOVERED_RELAYS; i++) {
strncpy(relays[relay_count].url, cfg->upstream_relays[i], CR_URL_LEN - 1);
relays[relay_count].url[CR_URL_LEN - 1] = '\0';
normalize_url(relays[relay_count].url, CR_URL_LEN);
relay_count++;
}
/* Add outbox relays and build coverage. */
for (int i = 0; i < map->outbox_count; i++) {
cr_outbox_entry_t *oe = &map->outboxes[i];
for (int r = 0; r < oe->relay_count; r++) {
/* Find or create relay entry. */
int found = -1;
for (int j = 0; j < relay_count; j++) {
if (strcmp(relays[j].url, oe->relays[r]) == 0) { found = j; break; }
}
if (found < 0) {
if (relay_count >= CR_MAX_DISCOVERED_RELAYS) break;
strncpy(relays[relay_count].url, oe->relays[r], CR_URL_LEN - 1);
found = relay_count++;
}
/* Add this pubkey to the relay's coverage. */
if (relays[found].pubkey_count < CR_MAX_PUBKEYS_PER_RELAY) {
strncpy(relays[found].pubkeys[relays[found].pubkey_count],
oe->pubkey, CR_HEX_LEN - 1);
relays[found].pubkey_count++;
}
}
}
/* Greedy set cover. */
char *covered = calloc(followed->count, 1); /* 1 if pubkey is covered */
map->selected_count = 0;
map->coverage = calloc(map->outbox_count, sizeof(int));
for (int i = 0; i < map->outbox_count; i++) map->coverage[i] = -1;
int uncovered_count = followed->count;
/* Helper: find index of a pubkey in the followed set. */
/* (linear search, fine for our scale) */
while (uncovered_count > 0 && map->selected_count < CR_MAX_DISCOVERED_RELAYS) {
int best_relay = -1;
int best_count = 0;
for (int r = 0; r < relay_count; r++) {
/* Count how many uncovered pubkeys this relay covers. */
int count = 0;
for (int p = 0; p < relays[r].pubkey_count; p++) {
/* Find this pubkey in followed set. */
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], relays[r].pubkeys[p]) == 0) {
if (!covered[i]) count++;
break;
}
}
}
if (count > best_count) {
best_count = count;
best_relay = r;
}
}
if (best_relay < 0 || best_count == 0) break; /* no more coverage possible */
/* Select this relay. */
strncpy(map->selected_relays[map->selected_count],
relays[best_relay].url, CR_URL_LEN - 1);
map->selected_count++;
/* Mark covered pubkeys and record coverage. */
for (int p = 0; p < relays[best_relay].pubkey_count; p++) {
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], relays[best_relay].pubkeys[p]) == 0) {
if (!covered[i]) {
covered[i] = 1;
uncovered_count--;
/* Record which selected relay covers this outbox entry. */
for (int oe_i = 0; oe_i < map->outbox_count; oe_i++) {
if (strcmp(map->outboxes[oe_i].pubkey,
relays[best_relay].pubkeys[p]) == 0) {
if (map->coverage[oe_i] < 0) {
map->coverage[oe_i] = map->selected_count - 1;
}
}
}
}
break;
}
}
}
}
/* Log results. */
DEBUG_INFO("relay_discovery: selected %d relays to cover %d/%d pubkeys",
map->selected_count, followed->count - uncovered_count, followed->count);
for (int i = 0; i < map->selected_count; i++) {
DEBUG_INFO(" relay[%d]: %s", i, map->selected_relays[i]);
}
if (uncovered_count > 0) {
DEBUG_WARN("relay_discovery: %d pubkeys have no outbox relays (covered by bootstrap)",
uncovered_count);
}
free(covered);
free(relays);
}
/* ---- batched kind-10002 query ---- */
/* Query kind 10002 for a batch of up to 100 pubkeys from a pool.
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
* Sets *out_count to the number of events returned. */
static cJSON **query_kind10002_batch(nostr_relay_pool_t *pool,
const char **pubkeys, int pubkey_count,
int *out_count) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < pubkey_count; i++)
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkeys[i]));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(10002));
cJSON_AddItemToObject(filter, "kinds", kinds);
const char **urls = NULL;
int n = get_pool_urls(pool, &urls);
/* TRACE: log the query being sent. */
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
char *filter_str = cJSON_PrintUnformatted(filter);
DEBUG_TRACE("query_kind10002_batch: querying %d relays for %d pubkeys", n, pubkey_count);
if (filter_str) {
/* Only print first 200 chars of filter to avoid huge output */
if (strlen(filter_str) > 200) filter_str[200] = '\0';
DEBUG_TRACE(" filter: %s...", filter_str);
free(filter_str);
}
}
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 30000);
free(urls);
cJSON_Delete(filter);
DEBUG_TRACE("query_kind10002_batch: returned %d events", ev_count);
*out_count = ev_count;
return events;
}
/* ---- batched kind-3 fallback query ---- */
/* Query kind 3 (contact list) for a batch of pubkeys from a pool.
* Kind-3 events historically contain "r" tags with relay URLs (pre-NIP-65).
* We use this as a fallback for pubkeys that don't have kind-10002.
* Returns a cJSON array of events (caller must cJSON_Delete each + free).
* Sets *out_count to the number of events returned. */
static cJSON **query_kind3_batch(nostr_relay_pool_t *pool,
const char **pubkeys, int pubkey_count,
int *out_count) {
cJSON *filter = cJSON_CreateObject();
cJSON *authors = cJSON_CreateArray();
for (int i = 0; i < pubkey_count; i++)
cJSON_AddItemToArray(authors, cJSON_CreateString(pubkeys[i]));
cJSON_AddItemToObject(filter, "authors", authors);
cJSON *kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(3));
cJSON_AddItemToObject(filter, "kinds", kinds);
/* limit=1 per author: we only need the most recent kind-3.
* The relay pool query_sync returns the most recent events. */
cJSON_AddItemToObject(filter, "limit", cJSON_CreateNumber(pubkey_count));
const char **urls = NULL;
int n = get_pool_urls(pool, &urls);
DEBUG_TRACE("query_kind3_batch: querying %d relays for %d pubkeys", n, pubkey_count);
int ev_count = 0;
cJSON **events = nostr_relay_pool_query_sync(pool, urls, n, filter, &ev_count, 30000);
free(urls);
cJSON_Delete(filter);
DEBUG_TRACE("query_kind3_batch: returned %d events", ev_count);
*out_count = ev_count;
return events;
}
/* Process a batch of kind-10002 events: parse r-tags, populate outbox entries,
* publish to local relay. Returns count of events processed. */
static int process_10002_batch(cJSON **events, int ev_count,
cr_relay_map_t *map, cr_sink_t *sink,
const char *source) {
int processed = 0;
for (int k = 0; k < ev_count; k++) {
cJSON *event = events[k];
if (!event) continue;
/* Get pubkey from event. */
cJSON *pk = cJSON_GetObjectItem(event, "pubkey");
if (!pk || !cJSON_IsString(pk)) { cJSON_Delete(event); continue; }
const char *hex = cJSON_GetStringValue(pk);
/* Find or create outbox entry for this pubkey. */
cr_outbox_entry_t *oe = NULL;
for (int i = 0; i < map->outbox_count; i++) {
if (strcmp(map->outboxes[i].pubkey, hex) == 0) { oe = &map->outboxes[i]; break; }
}
if (!oe) {
if (map->outbox_count >= /* followed->count passed in via map size */ 99999) {
cJSON_Delete(event); continue;
}
oe = &map->outboxes[map->outbox_count];
strncpy(oe->pubkey, hex, CR_HEX_LEN - 1);
map->outbox_count++;
}
int n = parse_r_tags(event, oe);
(void)n;
if (strcmp(source, "bootstrap") == 0) {
cr_sink_publish(sink, event);
/* Pump sink pool every 16 events to avoid pending publish overflow
* (NOSTR_POOL_MAX_PENDING_PUBLISHES = 32). */
if (processed > 0 && (processed % 16) == 0) {
cr_sink_pump(sink, 200);
}
}
processed++;
cJSON_Delete(event);
}
free(events);
/* Log sink stats after each batch. */
DEBUG_INFO("relay_discovery: batch processed %d events (sink: ok=%ld reject=%ld dup=%ld err=%ld)",
processed, sink->published_ok, sink->published_failed,
sink->published_dup, 0L);
return processed;
}
/* ---- main discovery function ---- */
#define CR_10002_BATCH_SIZE 100
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_sink_t *sink,
const cr_pubkey_set_t *followed, int is_first_time) {
memset(map, 0, sizeof(*map));
map->outboxes = calloc(followed->count, sizeof(cr_outbox_entry_t));
if (!map->outboxes) return -1;
map->outbox_count = 0;
DEBUG_INFO("relay_discovery: discovering outbox relays for %d pubkeys (%s)",
followed->count, is_first_time ? "first-time" : "local-first");
int found_local = 0, found_bootstrap = 0, found_none = 0;
/* Pre-populate outbox entries for all followed pubkeys (empty, for coverage
* tracking of pubkeys with no 10002). */
for (int i = 0; i < followed->count; i++) {
strncpy(map->outboxes[i].pubkey, followed->items[i], CR_HEX_LEN - 1);
}
map->outbox_count = followed->count;
/* Track which pubkeys we've found 10002 for. */
char *found = calloc(followed->count, 1);
/* Phase 1: If subsequent startup, batch-query local relay first. */
if (!is_first_time && sink && sink->pool) {
DEBUG_INFO("relay_discovery: querying local relay for kind-10002 (batched)...");
for (int start = 0; start < followed->count && !g_shutdown; start += CR_10002_BATCH_SIZE) {
int batch = followed->count - start;
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
int ev_count = 0;
cJSON **events = query_kind10002_batch(sink->pool,
(const char **)&followed->items[start],
batch, &ev_count);
if (events && ev_count > 0) {
for (int k = 0; k < ev_count; k++) {
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
if (pk && cJSON_IsString(pk)) {
const char *hex = cJSON_GetStringValue(pk);
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], hex) == 0) {
if (!found[i]) { found[i] = 1; found_local++; }
break;
}
}
}
}
process_10002_batch(events, ev_count, map, sink, "local");
/* Pump sink pool to flush published events. */
cr_sink_pump(sink, 500);
} else {
free(events);
}
}
DEBUG_INFO("relay_discovery: local relay provided %d kind-10002 events", found_local);
}
/* Phase 2: Batch-query bootstrap relays for pubkeys not found locally. */
int remaining = 0;
for (int i = 0; i < followed->count; i++) if (!found[i]) remaining++;
if (remaining > 0) {
DEBUG_INFO("relay_discovery: querying bootstrap relays for %d remaining pubkeys (batched)...",
remaining);
/* Build list of remaining pubkeys. */
const char **remaining_pk = malloc(remaining * sizeof(char *));
int ridx = 0;
for (int i = 0; i < followed->count; i++) {
if (!found[i]) remaining_pk[ridx++] = followed->items[i];
}
for (int start = 0; start < remaining && !g_shutdown; start += CR_10002_BATCH_SIZE) {
int batch = remaining - start;
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
int ev_count = 0;
cJSON **events = query_kind10002_batch(upstream,
&remaining_pk[start],
batch, &ev_count);
if (events && ev_count > 0) {
for (int k = 0; k < ev_count; k++) {
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
if (pk && cJSON_IsString(pk)) {
const char *hex = cJSON_GetStringValue(pk);
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], hex) == 0) {
if (!found[i]) { found[i] = 1; found_bootstrap++; }
break;
}
}
}
}
process_10002_batch(events, ev_count, map, sink, "bootstrap");
/* Pump sink pool to flush published events. */
cr_sink_pump(sink, 500);
} else {
free(events);
}
}
free(remaining_pk);
}
/* Phase 3: Kind-3 fallback for pubkeys still without relay lists.
* Kind-3 (contact list) events historically contain "r" tags with relay
* URLs (pre-NIP-65). This catches older users who never published kind-10002. */
int remaining_k3 = 0;
for (int i = 0; i < followed->count; i++) if (!found[i]) remaining_k3++;
if (remaining_k3 > 0) {
DEBUG_INFO("relay_discovery: querying bootstrap relays for kind-3 fallback (%d pubkeys)...",
remaining_k3);
const char **remaining_pk = malloc(remaining_k3 * sizeof(char *));
int ridx = 0;
for (int i = 0; i < followed->count; i++) {
if (!found[i]) remaining_pk[ridx++] = followed->items[i];
}
int found_k3 = 0;
for (int start = 0; start < remaining_k3 && !g_shutdown; start += CR_10002_BATCH_SIZE) {
int batch = remaining_k3 - start;
if (batch > CR_10002_BATCH_SIZE) batch = CR_10002_BATCH_SIZE;
int ev_count = 0;
cJSON **events = query_kind3_batch(upstream,
&remaining_pk[start],
batch, &ev_count);
if (events && ev_count > 0) {
for (int k = 0; k < ev_count; k++) {
cJSON *pk = cJSON_GetObjectItem(events[k], "pubkey");
if (pk && cJSON_IsString(pk)) {
const char *hex = cJSON_GetStringValue(pk);
for (int i = 0; i < followed->count; i++) {
if (strcmp(followed->items[i], hex) == 0) {
if (!found[i]) { found[i] = 1; found_k3++; }
break;
}
}
}
}
/* process_10002_batch works for kind-3 too — same r-tag format */
process_10002_batch(events, ev_count, map, sink, "bootstrap");
cr_sink_pump(sink, 500);
} else {
free(events);
}
}
free(remaining_pk);
DEBUG_INFO("relay_discovery: kind-3 fallback found %d additional pubkeys with relays", found_k3);
}
found_none = followed->count - found_local - found_bootstrap;
/* Recalculate found_none after kind-3 fallback */
int still_none = 0;
for (int i = 0; i < followed->count; i++) if (!found[i]) still_none++;
DEBUG_INFO("relay_discovery: %d local, %d bootstrap, %d none",
found_local, found_bootstrap, still_none);
free(found);
/* Compute minimum covering set. */
compute_covering_set(map, cfg, followed);
return 0;
}
void cr_relay_map_free(cr_relay_map_t *map) {
if (map->outboxes) {
free(map->outboxes);
map->outboxes = NULL;
}
if (map->coverage) {
free(map->coverage);
map->coverage = NULL;
}
map->outbox_count = 0;
map->selected_count = 0;
}
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex) {
for (int i = 0; i < map->outbox_count; i++) {
if (strcmp(map->outboxes[i].pubkey, hex) == 0) return &map->outboxes[i];
}
return NULL;
}
+67
View File
@@ -0,0 +1,67 @@
/*
* caching_relay - NIP-65 outbox relay discovery.
*
* For each followed pubkey, fetches their kind 10002 (relay list) and parses
* the "r" tags to build a per-pubkey outbox relay map. Then computes the
* minimum covering set of relays (greedy set cover) that covers all followed
* pubkeys.
*
* On first-time startup, queries bootstrap relays for kind 10002.
* On subsequent startups, queries the local relay first (fast), falls back
* to bootstrap relays for any pubkey not found locally.
*/
#ifndef CACHING_RELAY_RELAY_DISCOVERY_H
#define CACHING_RELAY_RELAY_DISCOVERY_H
#include "config.h"
#include "state.h"
#include "relay_sink.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#define CR_MAX_RELAYS_PER_PUBKEY 8
#define CR_MAX_PUBKEYS_PER_RELAY 512
#define CR_MAX_DISCOVERED_RELAYS 128
/* Per-pubkey outbox relay list. */
typedef struct {
char pubkey[CR_HEX_LEN];
char relays[CR_MAX_RELAYS_PER_PUBKEY][CR_URL_LEN];
int relay_count;
} cr_outbox_entry_t;
/* Result of relay discovery. */
typedef struct {
cr_outbox_entry_t *outboxes; /* per-pubkey relay lists (heap) */
int outbox_count;
/* The selected minimum covering set of relay URLs. */
char selected_relays[CR_MAX_DISCOVERED_RELAYS][CR_URL_LEN];
int selected_count;
/* Per-pubkey: which selected relay covers this pubkey (index into selected_relays).
* -1 means covered by bootstrap relays only. */
int *coverage; /* heap, outbox_count entries */
} cr_relay_map_t;
/* Discover outbox relays for all followed pubkeys.
*
* cfg - config (provides bootstrap relays, local_relay, root_hex)
* upstream - upstream pool (has bootstrap relays; discovered relays will be added)
* sink - sink pool (local relay, for publishing 10002 events + querying on subsequent startup)
* followed - the followed pubkey set
* is_first_time - 1 if first startup (query bootstrap only), 0 if subsequent (local-first)
*
* Returns 0 on success, -1 on hard error. On success, map is populated.
* Caller must call cr_relay_map_free() when done.
*/
int cr_relay_discovery_run(cr_relay_map_t *map, cr_config_t *cfg,
nostr_relay_pool_t *upstream, cr_sink_t *sink,
const cr_pubkey_set_t *followed, int is_first_time);
/* Free heap resources in a relay map. */
void cr_relay_map_free(cr_relay_map_t *map);
/* Get the outbox relays for a specific pubkey. Returns NULL if not found. */
const cr_outbox_entry_t *cr_relay_map_get_outbox(const cr_relay_map_t *map, const char *hex);
#endif /* CACHING_RELAY_RELAY_DISCOVERY_H */
+161
View File
@@ -0,0 +1,161 @@
/*
* caching_relay - relay sink: publishes fetched events to the local relay.
*
* See relay_sink.h for the dual-mode (WebSocket / PostgreSQL inbox) design.
*/
#define _GNU_SOURCE
#include "relay_sink.h"
#include "pg_inbox.h"
#include "debug.h"
#include <string.h>
#include <stdlib.h>
/* ------------------------------------------------------------------ */
/* WebSocket mode callback */
/* ------------------------------------------------------------------ */
static void sink_publish_cb(const char *relay_url, const char *event_id,
int success, const char *message, void *user_data) {
cr_sink_t *sink = (cr_sink_t *)user_data;
if (success) {
sink->published_ok++;
DEBUG_TRACE("sink: OK %s from %s", event_id ? event_id : "?",
relay_url ? relay_url : "?");
} else {
sink->published_failed++;
/* REJECT is important - show at INFO level so it's visible at level 3. */
DEBUG_INFO("sink: REJECT %s from %s: %s", event_id ? event_id : "?",
relay_url ? relay_url : "?",
message ? message : "(no message)");
}
}
/* ------------------------------------------------------------------ */
/* Init */
/* ------------------------------------------------------------------ */
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen) {
memset(sink, 0, sizeof(*sink));
sink->seen = seen;
sink->source_class = CR_SINK_CLASS_LIVE;
strncpy(sink->url, local_relay_url, sizeof(sink->url) - 1);
sink->pool = nostr_relay_pool_create(nostr_pool_reconnect_config_default());
if (!sink->pool) {
DEBUG_ERROR("sink: failed to create pool");
return -1;
}
if (nostr_relay_pool_add_relay(sink->pool, local_relay_url) != NOSTR_SUCCESS) {
DEBUG_ERROR("sink: failed to add relay %s", local_relay_url);
nostr_relay_pool_destroy(sink->pool);
sink->pool = NULL;
return -1;
}
DEBUG_INFO("sink: initialized (WebSocket) for %s", local_relay_url);
return 0;
}
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen) {
memset(sink, 0, sizeof(*sink));
sink->seen = seen;
sink->source_class = CR_SINK_CLASS_LIVE;
sink->pool = NULL; /* PostgreSQL inbox mode - no WebSocket pool. */
DEBUG_INFO("sink: initialized (PostgreSQL inbox)");
return 0;
}
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr) {
if (sink) sink->source_class = class_ptr ? class_ptr : CR_SINK_CLASS_LIVE;
}
void cr_sink_destroy(cr_sink_t *sink) {
if (sink && sink->pool) {
nostr_relay_pool_destroy(sink->pool);
sink->pool = NULL;
}
}
/* ------------------------------------------------------------------ */
/* Publish */
/* ------------------------------------------------------------------ */
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay) {
if (!sink || !event) return -1;
cJSON *id = cJSON_GetObjectItem(event, "id");
if (!id || !cJSON_IsString(id)) {
DEBUG_WARN("sink: event missing id, skipping");
return -1;
}
const char *eid = cJSON_GetStringValue(id);
if (cr_seen_ring_add(sink->seen, eid) == 0) {
sink->published_dup++;
return 0; /* already seen recently */
}
/* PostgreSQL inbox mode. */
if (!sink->pool) {
char *json = cJSON_PrintUnformatted(event);
if (!json) {
DEBUG_WARN("sink: failed to serialize event %s", eid);
sink->published_failed++;
return -1;
}
const char *klass = sink->source_class ? sink->source_class
: CR_SINK_CLASS_LIVE;
int priority = (strcmp(klass, CR_SINK_CLASS_BACKFILL) == 0) ? 1 : 0;
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
DEBUG_TRACE("sink: INBOX event id=%s kind=%d pubkey=%s class=%s",
eid, kind_num,
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
klass);
int rc = pg_inbox_insert_event(json, source_relay, klass, priority);
free(json);
if (rc != 0) {
sink->published_failed++;
return -1;
}
sink->published_ok++;
return 1;
}
/* WebSocket mode. */
const char *urls[1] = { sink->url };
cJSON *kind = cJSON_GetObjectItem(event, "kind");
cJSON *pubkey = cJSON_GetObjectItem(event, "pubkey");
int kind_num = (kind && cJSON_IsNumber(kind)) ? (int)cJSON_GetNumberValue(kind) : -1;
DEBUG_TRACE("sink: SEND event id=%s kind=%d pubkey=%s to %s",
eid, kind_num,
pubkey && cJSON_IsString(pubkey) ? cJSON_GetStringValue(pubkey) : "?",
sink->url);
int rc = nostr_relay_pool_publish_async(sink->pool, urls, 1, event,
sink_publish_cb, sink);
if (rc < 0) {
DEBUG_WARN("sink: publish_async error rc=%d for %s", rc, eid);
return -1;
}
if (rc == 0) {
DEBUG_INFO("sink: publish SKIPPED (not connected) for %s", eid);
return 0;
}
DEBUG_TRACE("sink: publish_async rc=%d for %s", rc, eid);
return 1;
}
int cr_sink_publish(cr_sink_t *sink, cJSON *event) {
return cr_sink_publish_from(sink, event, NULL);
}
void cr_sink_pump(cr_sink_t *sink, int timeout_ms) {
if (sink && sink->pool) {
nostr_relay_pool_run(sink->pool, timeout_ms);
}
/* No-op in PostgreSQL inbox mode. */
}
+75
View File
@@ -0,0 +1,75 @@
/*
* caching_relay - relay sink: publishes fetched events to the local relay.
*
* Two modes:
* - WebSocket mode (legacy): a dedicated single-relay pool publishes via
* nostr_relay_pool_publish_async(). Used when no PostgreSQL connection
* string is provided.
* - PostgreSQL inbox mode: events are inserted directly into the
* caching_event_inbox table via pg_inbox_insert_event(). Used when the
* daemon is started with --pg-conn.
*
* The public function signatures are identical in both modes so that callers
* (live_subscriber, backfill, relay_discovery) do not need to change.
*/
#ifndef CACHING_RELAY_RELAY_SINK_H
#define CACHING_RELAY_RELAY_SINK_H
#include "state.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
/* Source class labels for the inbox priority field. */
#define CR_SINK_CLASS_LIVE "live"
#define CR_SINK_CLASS_DISCOVERY "discovery"
#define CR_SINK_CLASS_BACKFILL "backfill"
typedef struct {
/* WebSocket mode only (NULL in PostgreSQL inbox mode). */
nostr_relay_pool_t *pool;
char url[256];
/* PostgreSQL inbox mode: source class used for priority assignment.
* Defaults to "live". Set to "backfill" by the backfill module.
* Ignored in WebSocket mode. */
const char *source_class;
cr_seen_ring_t *seen; /* shared seen ring for dedup */
long published_ok;
long published_failed;
long published_dup;
} cr_sink_t;
/* Create the sink.
*
* In WebSocket mode (pg_mode = 0): creates a relay pool and adds
* local_relay_url.
* In PostgreSQL inbox mode (pg_mode != 0): no pool is created; the URL is
* ignored. Events are inserted via pg_inbox_insert_event() (the pg_inbox
* module must be initialized beforehand).
*
* The original two-argument signature is preserved via cr_sink_init() below
* for backward compatibility; it defaults to WebSocket mode. */
int cr_sink_init(cr_sink_t *sink, const char *local_relay_url, cr_seen_ring_t *seen);
/* Initialize the sink in PostgreSQL inbox mode. */
int cr_sink_init_pg(cr_sink_t *sink, cr_seen_ring_t *seen);
/* Set the source class for priority assignment in PostgreSQL inbox mode.
* class_ptr must point to a static string literal (e.g. CR_SINK_CLASS_LIVE). */
void cr_sink_set_source_class(cr_sink_t *sink, const char *class_ptr);
void cr_sink_destroy(cr_sink_t *sink);
/* Publish a cJSON event. Dedups against the seen ring.
* Returns 1 if enqueued/inserted, 0 if dup (skipped), -1 on error. */
int cr_sink_publish(cr_sink_t *sink, cJSON *event);
/* Variant that records the originating relay URL in PostgreSQL inbox mode.
* In WebSocket mode the source_relay is ignored. */
int cr_sink_publish_from(cr_sink_t *sink, cJSON *event, const char *source_relay);
/* Pump the sink pool briefly to flush pending publish callbacks.
* No-op in PostgreSQL inbox mode. */
void cr_sink_pump(cr_sink_t *sink, int timeout_ms);
#endif /* CACHING_RELAY_RELAY_SINK_H */
+76
View File
@@ -0,0 +1,76 @@
/*
* caching_relay - in-memory runtime state
*/
#define _GNU_SOURCE
#include "state.h"
#include <stdlib.h>
#include <string.h>
/* ---- pubkey set ---- */
void cr_pubkey_set_init(cr_pubkey_set_t *s) {
s->items = NULL;
s->count = 0;
s->capacity = 0;
}
void cr_pubkey_set_free(cr_pubkey_set_t *s) {
for (int i = 0; i < s->count; i++) free(s->items[i]);
free(s->items);
s->items = NULL;
s->count = 0;
s->capacity = 0;
}
void cr_pubkey_set_clear(cr_pubkey_set_t *s) {
cr_pubkey_set_free(s);
cr_pubkey_set_init(s);
}
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex) {
for (int i = 0; i < s->count; i++) {
if (strcmp(s->items[i], hex) == 0) return 1;
}
return 0;
}
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex) {
if (!hex || !hex[0]) return 0;
if (cr_pubkey_set_contains(s, hex)) return 0;
if (s->count >= s->capacity) {
int newcap = s->capacity ? s->capacity * 2 : 64;
char **ni = realloc(s->items, newcap * sizeof(char *));
if (!ni) return -1;
s->items = ni;
s->capacity = newcap;
}
s->items[s->count] = strdup(hex);
if (!s->items[s->count]) return -1;
s->count++;
return 1;
}
/* ---- seen-event ring buffer ---- */
void cr_seen_ring_init(cr_seen_ring_t *r) {
memset(r, 0, sizeof(*r));
}
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id) {
for (int i = 0; i < r->count; i++) {
int idx = (r->head - 1 - i + CR_SEEN_RING_SIZE) % CR_SEEN_RING_SIZE;
if (strcmp(r->ids[idx], id) == 0) return 1;
}
return 0;
}
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id) {
if (!id || !id[0]) return 0;
if (cr_seen_ring_contains(r, id)) return 0;
strncpy(r->ids[r->head], id, CR_HEX_LEN - 1);
r->ids[r->head][CR_HEX_LEN - 1] = '\0';
r->head = (r->head + 1) % CR_SEEN_RING_SIZE;
if (r->count < CR_SEEN_RING_SIZE) r->count++;
return 1;
}
+41
View File
@@ -0,0 +1,41 @@
/*
* caching_relay - in-memory runtime state:
* - followed-pubkey set (hex pubkeys)
* - seen-event-id ring buffer (to avoid redundant publish attempts)
*/
#ifndef CACHING_RELAY_STATE_H
#define CACHING_RELAY_STATE_H
#include <stddef.h>
/* Hex pubkey is 64 chars + NUL. Event id is 64 hex chars + NUL. */
#define CR_HEX_LEN 65
/* A simple dynamic string set for followed pubkeys. */
typedef struct {
char **items;
int count;
int capacity;
} cr_pubkey_set_t;
void cr_pubkey_set_init(cr_pubkey_set_t *s);
void cr_pubkey_set_free(cr_pubkey_set_t *s);
int cr_pubkey_set_add(cr_pubkey_set_t *s, const char *hex); /* 1 if added, 0 if dup */
int cr_pubkey_set_contains(const cr_pubkey_set_t *s, const char *hex);
void cr_pubkey_set_clear(cr_pubkey_set_t *s);
/* Fixed-size ring buffer of event ids for dedup. */
#define CR_SEEN_RING_SIZE 4096
typedef struct {
char ids[CR_SEEN_RING_SIZE][CR_HEX_LEN];
int head;
int count;
} cr_seen_ring_t;
void cr_seen_ring_init(cr_seen_ring_t *r);
/* Returns 1 if the id was newly inserted, 0 if it was already present. */
int cr_seen_ring_add(cr_seen_ring_t *r, const char *id);
int cr_seen_ring_contains(const cr_seen_ring_t *r, const char *id);
#endif /* CACHING_RELAY_STATE_H */
+1
View File
@@ -0,0 +1 @@
key,value,data_type,description,category,requires_restart,created_at,updated_at
1 key value data_type description category requires_restart created_at updated_at
+10 -1
View File
@@ -1,3 +1,12 @@
#!/bin/bash
# Restart the service
sudo systemctl stop c-relay-pg.service
cp build/c_relay_x86 ~/Storage/c_relay/crelay
# Copy the binary to the deployment location
cp build/c_relay_pg_static_x86_64 ~/Storage/c_relay_pg/crelay
# Restart the service
sudo systemctl restart c-relay-pg.service
# Show service status
sudo systemctl status c-relay-pg.service --no-pager -l
Executable
+203
View File
@@ -0,0 +1,203 @@
#!/bin/bash
# C-Relay-PG PostgreSQL Deployment Script
# Deploys static relay binary and configures PostgreSQL 18 + systemd service on remote server.
set -euo pipefail
# Configuration
REMOTE_HOST="ubuntu@laantungir.net"
LOCAL_BINARY="build/c_relay_pg_static_x86_64"
LOCAL_CACHING_BINARY="build/caching_relay"
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
SERVICE_NAME="c-relay-pg"
LOCAL_SERVICE_FILE="systemd/c-relay.service"
LOCAL_PG_SETUP_SCRIPT="systemd/setup_postgres_18.sh"
if [ ! -f "$LOCAL_BINARY" ]; then
echo "ERROR: Binary not found: $LOCAL_BINARY"
echo "Build it first (e.g. ./make_and_restart_relay.sh)"
exit 1
fi
if [ ! -f "$LOCAL_SERVICE_FILE" ]; then
echo "ERROR: Service file not found: $LOCAL_SERVICE_FILE"
exit 1
fi
if [ ! -f "$LOCAL_PG_SETUP_SCRIPT" ]; then
echo "ERROR: PostgreSQL setup script not found: $LOCAL_PG_SETUP_SCRIPT"
exit 1
fi
echo "==> Uploading artifacts to $REMOTE_HOST"
scp "$LOCAL_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg.tmp"
scp "$LOCAL_SERVICE_FILE" "$REMOTE_HOST:/tmp/c-relay-pg.service"
scp "$LOCAL_PG_SETUP_SCRIPT" "$REMOTE_HOST:/tmp/setup_postgres_18.sh"
# Upload the caching_relay binary if it was built locally. The caching
# service is NOT started by this deploy (the systemd unit does not pass
# --start-caching), but the binary is placed in the relay's WorkingDirectory
# so it is ready for when caching is enabled via event-based config.
if [ -f "$LOCAL_CACHING_BINARY" ]; then
scp "$LOCAL_CACHING_BINARY" "$REMOTE_HOST:/tmp/caching_relay.tmp"
else
echo "WARNING: caching_relay binary not found locally ($LOCAL_CACHING_BINARY) — skipping caching binary upload"
fi
# Upload admin PHP files (tar to preserve directory structure, exclude
# cache logs and the dev-only serve.sh / php_server.log).
echo "==> Packaging admin files"
tar czf /tmp/admin_deploy.tar.gz \
--exclude='admin/serve.sh' \
--exclude='admin/php_server.log' \
--exclude='admin/cache' \
-C . admin/
scp /tmp/admin_deploy.tar.gz "$REMOTE_HOST:/tmp/admin_deploy.tar.gz"
echo "==> Running remote install/configuration"
ssh "$REMOTE_HOST" 'bash -s' <<'EOF'
set -euo pipefail
SERVICE_NAME="c-relay-pg"
RELAY_USER="c-relay-pg"
REMOTE_BINARY_DIR="/usr/local/bin/c_relay_pg"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
REMOTE_CACHING_BINARY_PATH="/opt/c-relay-pg/caching_relay"
echo "[remote] Ensuring service user exists"
if ! id "$RELAY_USER" >/dev/null 2>&1; then
sudo useradd --system --home-dir /opt/c-relay-pg --shell /usr/sbin/nologin "$RELAY_USER"
fi
echo "[remote] Ensuring required directories exist"
sudo mkdir -p "$REMOTE_BINARY_DIR" /opt/c-relay-pg /etc/c-relay-pg
sudo chown "$RELAY_USER:$RELAY_USER" /opt/c-relay-pg
echo "[remote] Installing PostgreSQL 18 (PGDG) if missing"
if ! dpkg -s postgresql-18 >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y curl ca-certificates lsb-release gnupg
sudo install -d /usr/share/postgresql-common/pgdg
sudo curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc -o /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list >/dev/null
sudo apt-get update
sudo apt-get install -y postgresql-18
fi
sudo systemctl enable postgresql
sudo systemctl restart postgresql
echo "[remote] Configuring PostgreSQL role/database"
sudo chmod +x /tmp/setup_postgres_18.sh
sudo /tmp/setup_postgres_18.sh
echo "[remote] Installing relay binary"
if [ -f "$REMOTE_BINARY_PATH" ]; then
sudo cp "$REMOTE_BINARY_PATH" "${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)"
fi
sudo mv /tmp/c_relay_pg.tmp "$REMOTE_BINARY_PATH"
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_BINARY_PATH"
sudo chmod +x "$REMOTE_BINARY_PATH"
echo "[remote] Installing caching_relay binary (if uploaded)"
if [ -f /tmp/caching_relay.tmp ]; then
sudo mv /tmp/caching_relay.tmp "$REMOTE_CACHING_BINARY_PATH"
sudo chown "$RELAY_USER:$RELAY_USER" "$REMOTE_CACHING_BINARY_PATH"
sudo chmod +x "$REMOTE_CACHING_BINARY_PATH"
echo " -> caching_relay installed at $REMOTE_CACHING_BINARY_PATH (not started; enable via config when ready)"
else
echo " -> no caching_relay binary uploaded, skipping"
fi
echo "[remote] Installing admin PHP files"
# Back up existing config.php and .htpasswd so we don't lose production
# credentials (DB password, basic-auth) across deploys.
if [ -f /opt/c-relay-pg/admin/lib/config.php ]; then
sudo cp /opt/c-relay-pg/admin/lib/config.php /tmp/admin_config_backup
echo " -> backed up config.php"
fi
if [ -f /opt/c-relay-pg/admin/.htpasswd ]; then
sudo cp /opt/c-relay-pg/admin/.htpasswd /tmp/admin_htpasswd_backup
echo " -> backed up .htpasswd"
fi
# Extract the new admin files (overwrites old admin at /opt/c-relay-pg/admin/).
sudo mkdir -p /opt/c-relay-pg/admin
sudo tar xzf /tmp/admin_deploy.tar.gz -C /opt/c-relay-pg/
# Restore config.php and .htpasswd if they were backed up.
if [ -f /tmp/admin_config_backup ]; then
sudo cp /tmp/admin_config_backup /opt/c-relay-pg/admin/lib/config.php
echo " -> restored config.php"
fi
if [ -f /tmp/admin_htpasswd_backup ]; then
sudo cp /tmp/admin_htpasswd_backup /opt/c-relay-pg/admin/.htpasswd
echo " -> restored .htpasswd"
fi
# Set ownership: www-data (nginx/php-fpm) for web-served files,
# but keep .htpasswd readable by nginx.
sudo chown -R www-data:www-data /opt/c-relay-pg/admin/
sudo chmod 640 /opt/c-relay-pg/admin/.htpasswd 2>/dev/null || true
# Ensure the cache directory exists (for chart text files).
sudo mkdir -p /opt/c-relay-pg/admin/cache
sudo chown www-data:www-data /opt/c-relay-pg/admin/cache
echo " -> admin files installed at /opt/c-relay-pg/admin/"
# Also update admin2 if it exists (symlink or copy to keep it in sync).
if [ -d /opt/c-relay-pg/admin2 ]; then
sudo rm -rf /opt/c-relay-pg/admin2
sudo cp -a /opt/c-relay-pg/admin /opt/c-relay-pg/admin2
sudo chown -R www-data:www-data /opt/c-relay-pg/admin2/
echo " -> admin2 synced at /opt/c-relay-pg/admin2/"
fi
echo "[remote] Installing systemd unit"
sudo mv /tmp/c-relay-pg.service /etc/systemd/system/c-relay-pg.service
sudo chown root:root /etc/systemd/system/c-relay-pg.service
sudo chmod 644 /etc/systemd/system/c-relay-pg.service
echo "[remote] Killing stale caching_relay processes (safety)"
# The caching service is forked by the relay with setsid(), so it detaches
# from the relay's process group and survives when the relay is killed.
# Without this, every restart orphans the previous caching_relay child and
# a new relay forks another, leading to many stale processes with dead PG
# connections spamming errors. Kill them here so the new relay starts clean.
CACHING_PIDS=$(pgrep -f "caching_relay" || echo "")
if [ -n "$CACHING_PIDS" ]; then
echo " -> killing stale caching_relay PIDs: $CACHING_PIDS"
kill -9 $CACHING_PIDS 2>/dev/null || true
sleep 1
else
echo " -> no stale caching_relay processes found"
fi
echo "[remote] Reloading and restarting service"
sudo systemctl daemon-reload
sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME"
echo "[remote] Health checks"
sudo systemctl --no-pager --full status "$SERVICE_NAME" | sed -n '1,25p'
sudo -u "$RELAY_USER" psql -d crelay -c "SELECT current_user, current_database();"
# Verify admin page is accessible (HTTP 200 or 401 = auth required, both are OK).
ADMIN_CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1/admin/" 2>/dev/null || echo "000")
if [ "$ADMIN_CODE" = "200" ] || [ "$ADMIN_CODE" = "401" ]; then
echo " -> admin page accessible (HTTP $ADMIN_CODE)"
else
echo " -> WARNING: admin page returned HTTP $ADMIN_CODE (expected 200 or 401)"
fi
# Clean up temp files.
rm -f /tmp/admin_deploy.tar.gz
sudo rm -f /tmp/admin_htpasswd_backup /tmp/admin_config_backup
EOF
# Clean up local temp tarball.
rm -f /tmp/admin_deploy.tar.gz
echo "Deployment complete: $REMOTE_HOST"
echo ""
echo "Admin interface: https://laantungir.net/admin/"
echo "Caching is NOT auto-started. Enable it via the admin UI when ready."
+122
View File
@@ -0,0 +1,122 @@
#!/bin/bash
# C-Relay-PG Debug Binary Deployment Script
# Deploys build/c_relay_pg_static_x86_64_debug to server for CPU profiling
#
# Usage:
# ./deploy_lt_debug.sh -- deploy debug binary and restart
# ./deploy_lt_debug.sh --profile -- deploy, then run perf and fetch results
#
# After deploying, profile with:
# sudo perf record -g -p $(pgrep c_relay_pg) -- sleep 30
# sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -80
#
# Restore production binary:
# ./deploy_lt.sh
set -e
LOCAL_DEBUG_BINARY="build/c_relay_pg_static_x86_64_debug"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay_pg/c_relay_pg"
REMOTE_PROD_BACKUP="/usr/local/bin/c_relay_pg/c_relay_pg.production"
SERVICE_NAME="c-relay-pg"
REMOTE_HOST="ubuntu@laantungir.com"
# Check debug binary exists
if [ ! -f "$LOCAL_DEBUG_BINARY" ]; then
echo "ERROR: Debug binary not found: $LOCAL_DEBUG_BINARY"
echo ""
echo "Build it first with:"
echo " ./build_static.sh --debug"
echo ""
exit 1
fi
echo "=========================================="
echo "C-Relay-PG Debug Deployment"
echo "=========================================="
echo "Binary: $LOCAL_DEBUG_BINARY ($(du -h "$LOCAL_DEBUG_BINARY" | cut -f1))"
echo "Target: $REMOTE_HOST:$REMOTE_BINARY_PATH"
echo ""
echo "WARNING: Debug binary has symbols and is larger than production."
echo " It is safe to run but should not be left deployed long-term."
echo ""
# Backup production binary (only if not already backed up)
echo "Backing up production binary..."
ssh "$REMOTE_HOST" "
if [ ! -f '$REMOTE_PROD_BACKUP' ]; then
sudo cp '$REMOTE_BINARY_PATH' '$REMOTE_PROD_BACKUP'
echo 'Production binary backed up to $REMOTE_PROD_BACKUP'
else
echo 'Production backup already exists, skipping'
fi
"
# Upload debug binary
echo "Uploading debug binary..."
scp "$LOCAL_DEBUG_BINARY" "$REMOTE_HOST:/tmp/c_relay_pg_debug.tmp"
# Install debug binary
echo "Installing debug binary..."
ssh "$REMOTE_HOST" "
sudo mv '/tmp/c_relay_pg_debug.tmp' '$REMOTE_BINARY_PATH'
sudo chown c-relay-pg:c-relay-pg '$REMOTE_BINARY_PATH'
sudo chmod +x '$REMOTE_BINARY_PATH'
"
# Restart service
echo "Restarting c-relay-pg service..."
ssh "$REMOTE_HOST" "sudo systemctl daemon-reload && sudo systemctl restart '$SERVICE_NAME'"
echo ""
echo "✓ Debug binary deployed and service restarted"
echo ""
# If --profile flag, run perf automatically
if [ "$1" = "--profile" ]; then
echo "=========================================="
echo "Running perf profile (30 seconds)..."
echo "=========================================="
echo ""
# Wait for relay to start
sleep 3
ssh "$REMOTE_HOST" "
PID=\$(pgrep c_relay_pg)
if [ -z \"\$PID\" ]; then
echo 'ERROR: c_relay_pg not running'
exit 1
fi
echo \"Profiling PID \$PID for 30 seconds...\"
sudo perf record -g -p \$PID -- sleep 30
echo ''
echo '=== TOP FUNCTIONS BY CPU ==='
sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60
"
else
echo "=========================================="
echo "Next Steps: Profile the relay"
echo "=========================================="
echo ""
echo "SSH to server and run:"
echo " sudo perf record -g -p \$(pgrep c_relay_pg) -- sleep 30"
echo " sudo perf report --stdio --sort=symbol --no-children -n 2>/dev/null | head -60"
echo ""
echo "Or run with --profile to do it automatically:"
echo " ./deploy_lt_debug.sh --profile"
fi
echo ""
echo "=========================================="
echo "Restore Production Binary When Done"
echo "=========================================="
echo ""
echo "Run this to restore the production binary:"
echo " ./deploy_lt.sh"
echo ""
echo "Or manually on the server:"
echo " sudo cp '$REMOTE_PROD_BACKUP' '$REMOTE_BINARY_PATH'"
echo " sudo systemctl restart $SERVICE_NAME"
echo ""
-28
View File
@@ -1,28 +0,0 @@
#!/bin/bash
# C-Relay Static Binary Deployment Script
# Deploys build/c_relay_static_x86_64 to server via ssh
set -e
# Configuration
LOCAL_BINARY="build/c_relay_static_x86_64"
REMOTE_BINARY_PATH="/usr/local/bin/c_relay/c_relay"
SERVICE_NAME="c-relay"
# Create backup
ssh ubuntu@laantungir.com "sudo cp '$REMOTE_BINARY_PATH' '${REMOTE_BINARY_PATH}.backup.$(date +%Y%m%d_%H%M%S)'" 2>/dev/null || true
# Upload binary to temp location
scp "$LOCAL_BINARY" "ubuntu@laantungir.com:/tmp/c_relay.tmp"
# Install binary
ssh ubuntu@laantungir.com "sudo mv '/tmp/c_relay.tmp' '$REMOTE_BINARY_PATH'"
ssh ubuntu@laantungir.com "sudo chown c-relay:c-relay '$REMOTE_BINARY_PATH'"
ssh ubuntu@laantungir.com "sudo chmod +x '$REMOTE_BINARY_PATH'"
# Reload systemd and restart service
ssh ubuntu@laantungir.com "sudo systemctl daemon-reload"
ssh ubuntu@laantungir.com "sudo systemctl restart '$SERVICE_NAME'"
echo "Deployment complete!"
+2 -2
View File
@@ -1,4 +1,4 @@
# C-Relay Administrator API Implementation Plan
# C-Relay-PG Administrator API Implementation Plan
## Problem Analysis
@@ -161,7 +161,7 @@ Would require changing schema, migration scripts, and storage logic.
#### README.md Documentation Format:
```markdown
# C-Relay Administrator API
# C-Relay-PG Administrator API
## Authentication
All admin commands require signing with the admin private key generated during first startup.
+190
View File
@@ -0,0 +1,190 @@
# Agent Browser Testing Guide for C-Relay-PG
This document explains how to use the `agent-browser` CLI tool to test the c-relay-pg admin web UI from an AI agent context (no physical display required).
## Prerequisites
- **agent-browser** installed globally: `npm install -g agent-browser`
- Binary location: `/home/user/.nvm/versions/node/v24.14.1/bin/agent-browser`
- The relay must be running locally (default port 8888)
- Test keys configured in `.test_keys`
## Starting the Relay for Local Testing
```bash
./make_and_restart_relay.sh -t
```
This reads `.test_keys` and starts the relay with:
- `ADMIN_PUBKEY` — the hex public key of the admin account
- `ADMIN_PRIVKEY` — the hex secret key (used for browser login, not by the relay itself)
- `SERVER_PRIVKEY` — the relay's own private key
## Key URLs
| URL | Purpose |
|-----|---------|
| `http://127.0.0.1:8888/api/index.html` | Admin web UI (the correct entry point) |
| `http://127.0.0.1:8888/` | Returns 406 without NIP-11 Accept header — **do not use for browser testing** |
| `http://127.0.0.1:8888/` with `Accept: application/nostr+json` | NIP-11 relay info JSON |
**Important:** The root URL `/` is a WebSocket/NIP-11 endpoint, not an HTML page. Always use `/api/index.html` for browser testing.
## Admin Login Credentials
The admin nsec for the current `.test_keys` configuration:
```
nsec: nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm
hex: 15a6fbfd759330c9948ba07d9ed77e49d414c4377a2516d47fc0ab0d54d411f5
npub: npub1tlyzk6slzt8d989f4pn3synk98fea64ea3jmrdc7dtd0kw8uxlas9a9fwr
hex pubkey: 5fc82b6a1f12ced29ca9a86718127629d39eeab9ec65b1b71e6adafb38fc37fb
```
## Complete Login Flow with agent-browser
### Step 1: Open the admin UI
```bash
agent-browser open http://127.0.0.1:8888/api/index.html
agent-browser wait --load networkidle
```
### Step 2: Take an interactive snapshot to see what is on screen
```bash
agent-browser snapshot -i
```
You will see a login modal with buttons like:
- `"Browser Extension"` — skip this
- `"Local Key"` — **use this one**
- `"Seed Phrase"` — skip
- `"Nostr Connect"` — skip
- `"Read Only"` — skip
### Step 3: Click "Local Key"
```bash
agent-browser click @e15
```
(The ref number may vary — use the ref from the snapshot output for the "Local Key" button.)
After clicking, a text input appears asking for the secret key.
### Step 4: Enter the admin nsec
```bash
agent-browser fill @e14 "nsec1zkn0hlt4jvcvn9yt5p7ea4m7f82pf3ph0gj3d4rlcz4s64x5z86satv9qm"
```
(Use the ref from the snapshot for the textbox element.)
### Step 5: Click "Import Key"
```bash
agent-browser snapshot -i
```
Check the snapshot — the "Import Key" button should now be enabled. Click it:
```bash
agent-browser click @e15
```
### Step 6: Click "Continue" on the success screen
After import, a success screen appears with "Continue" button:
```bash
agent-browser wait 800
agent-browser snapshot -i
agent-browser click @e19
```
(Use the ref from the snapshot for the "Continue" button.)
### Step 7: Wait for admin UI to load
```bash
agent-browser wait 5000
agent-browser snapshot -i
```
You should now see the admin dashboard with:
- Statistics table (Database Size, Total Events, PID, etc.)
- Navigation buttons (Statistics, Subscriptions, Configuration, Authorization, etc.)
- Admin profile area showing "admin" label
## Navigating Admin Sections
After login, use the sidebar navigation buttons:
```bash
# View configuration
agent-browser click @e8 # Configuration button ref
# View authorization rules
agent-browser click @e9 # Authorization button ref
# View statistics
agent-browser click @e6 # Statistics button ref
# Always snapshot after navigation to see results
agent-browser wait 2500
agent-browser snapshot -i
```
## Checking for Errors
```bash
# View browser console logs
agent-browser console
# View JavaScript errors
agent-browser errors
# View relay server logs
tail -n 100 relay.log
```
## Chained Command Example (Full Login in One Shot)
```bash
agent-browser open http://127.0.0.1:8888/api/index.html && \
agent-browser wait --load networkidle && \
agent-browser snapshot -i
```
Then use refs from the snapshot to complete login steps.
## Tips for AI Agents
1. **Always use `/api/index.html`** — never the root URL
2. **Use `snapshot -i`** after every action to see the current interactive elements and their refs
3. **Refs change** between snapshots — always re-snapshot before clicking
4. **Wait after clicks** — use `agent-browser wait 2000` (milliseconds) between actions that trigger async operations
5. **The login flow has 3 screens**: method selection → key input → success confirmation
6. **Console logs are cumulative** — they show all logs since page load, which is useful for debugging admin API responses
7. **The relay log** at `relay.log` shows server-side processing of admin commands
8. **Command chaining** with `&&` works — the browser daemon persists between commands
## Verifying Admin API is Working
After login, the statistics page should show populated data:
- Database Size (e.g., "4 KB")
- Process ID (the relay PID)
- WebSocket Connections count
- Memory Usage
If these show "-" or "Loading...", check:
1. `relay.log` for errors
2. `agent-browser console` for JavaScript errors
3. `agent-browser errors` for page-level errors
## Closing the Browser
```bash
agent-browser close --all
```
+11 -11
View File
@@ -324,7 +324,7 @@ int main() {
}
```
## Migration Plan for c-relay
## Migration Plan for c-relay-pg
### Phase 1: Extract Debug System
1. Create `c_utils_lib` repository
@@ -333,16 +333,16 @@ int main() {
4. Add basic tests
### Phase 2: Add Versioning System
1. Extract version generation logic from c-relay
1. Extract version generation logic from c-relay-pg
2. Create reusable version utilities
3. Update c-relay to use new system
3. Update c-relay-pg to use new system
4. Update nostr_core_lib to use new system
### Phase 3: Add as Submodule
1. Add `c_utils_lib` as submodule to c-relay
2. Update c-relay Makefile
3. Update includes in c-relay source files
4. Remove old debug files from c-relay
1. Add `c_utils_lib` as submodule to c-relay-pg
2. Update c-relay-pg Makefile
3. Update includes in c-relay-pg source files
4. Remove old debug files from c-relay-pg
### Phase 4: Documentation & Examples
1. Create comprehensive README
@@ -352,7 +352,7 @@ int main() {
## Benefits
### For c-relay
### For c-relay-pg
- Cleaner separation of concerns
- Reusable utilities across projects
- Easier to maintain and test
@@ -379,7 +379,7 @@ int main() {
- Memory leak detection (valgrind)
### Integration Tests
- Test with real projects (c-relay, nostr_core_lib)
- Test with real projects (c-relay-pg, nostr_core_lib)
- Cross-platform testing
- Performance benchmarks
@@ -433,7 +433,7 @@ MIT License - permissive and suitable for learning and commercial use.
## Success Criteria
1. ✅ Successfully integrated into c-relay
1. ✅ Successfully integrated into c-relay-pg
2. ✅ Successfully integrated into nostr_core_lib
3. ✅ All tests passing
4. ✅ Documentation complete
@@ -449,7 +449,7 @@ MIT License - permissive and suitable for learning and commercial use.
4. Create build system
5. Write tests
6. Create documentation
7. Integrate into c-relay
7. Integrate into c-relay-pg
8. Publish to GitHub
---
+16 -16
View File
@@ -2,13 +2,13 @@
## Overview
This document provides a step-by-step implementation plan for creating the `c_utils_lib` library and integrating it into the c-relay project.
This document provides a step-by-step implementation plan for creating the `c_utils_lib` library and integrating it into the c-relay-pg project.
## Phase 1: Repository Setup & Structure
### Step 1.1: Create Repository Structure
**Location**: Create outside c-relay project (sibling directory)
**Location**: Create outside c-relay-pg project (sibling directory)
```bash
# Create directory structure
@@ -42,7 +42,7 @@ git branch -M main
### Step 2.1: Move Debug Files
**Source files** (from c-relay):
**Source files** (from c-relay-pg):
- `src/debug.c``c_utils_lib/src/debug.c`
- `src/debug.h``c_utils_lib/include/c_utils/debug.h`
@@ -484,20 +484,20 @@ How to integrate into projects:
3. Code examples
4. Migration from standalone utilities
## Phase 7: Integration with c-relay
## Phase 7: Integration with c-relay-pg
### Step 7.1: Add as Submodule
```bash
cd /path/to/c-relay
cd /path/to/c-relay-pg
git submodule add <repo-url> c_utils_lib
git submodule update --init --recursive
```
### Step 7.2: Update c-relay Makefile
### Step 7.2: Update c-relay-pg Makefile
```makefile
# Add to c-relay Makefile
# Add to c-relay-pg Makefile
C_UTILS_LIB = c_utils_lib/libc_utils.a
# Update includes
@@ -514,7 +514,7 @@ $(C_UTILS_LIB):
$(TARGET): $(C_UTILS_LIB) ...
```
### Step 7.3: Update c-relay Source Files
### Step 7.3: Update c-relay-pg Source Files
**Changes needed**:
@@ -543,7 +543,7 @@ $(TARGET): $(C_UTILS_LIB) ...
### Step 7.4: Test Integration
```bash
cd c-relay
cd c-relay-pg
make clean
make
./make_and_restart_relay.sh
@@ -556,7 +556,7 @@ Verify:
## Phase 8: Version System Integration
### Step 8.1: Update c-relay Makefile for Versioning
### Step 8.1: Update c-relay-pg Makefile for Versioning
```makefile
# Add version generation
@@ -567,7 +567,7 @@ src/version.h: .git/refs/tags/*
$(TARGET): src/version.h ...
```
### Step 8.2: Update c-relay to Use Generated Version
### Step 8.2: Update c-relay-pg to Use Generated Version
Replace hardcoded version in `src/main.h` with:
```c
@@ -583,7 +583,7 @@ Replace hardcoded version in `src/main.h` with:
- **Phase 4**: Build System - 2 hours
- **Phase 5**: Examples & Tests - 3 hours
- **Phase 6**: Documentation - 3 hours
- **Phase 7**: c-relay Integration - 2 hours
- **Phase 7**: c-relay-pg Integration - 2 hours
- **Phase 8**: Version Integration - 2 hours
**Total**: ~19 hours
@@ -593,11 +593,11 @@ Replace hardcoded version in `src/main.h` with:
- [ ] c_utils_lib builds successfully
- [ ] All tests pass
- [ ] Examples compile and run
- [ ] c-relay integrates successfully
- [ ] Debug output works in c-relay
- [ ] c-relay-pg integrates successfully
- [ ] Debug output works in c-relay-pg
- [ ] Version generation works
- [ ] Documentation complete
- [ ] No regressions in c-relay functionality
- [ ] No regressions in c-relay-pg functionality
## Next Steps
@@ -608,7 +608,7 @@ Replace hardcoded version in `src/main.h` with:
5. Create build system
6. Write tests and examples
7. Create documentation
8. Integrate into c-relay
8. Integrate into c-relay-pg
9. Test thoroughly
10. Publish to GitHub
+18 -6
View File
@@ -78,9 +78,9 @@ Configuration events follow the standard Nostr event format with kind 33334:
#### `relay_software`
- **Description**: Software identifier for NIP-11
- **Default**: `"c-relay"`
- **Default**: `"c-relay-pg"`
- **Format**: String, max 64 characters
- **Example**: `"c-relay v1.0.0"`
- **Example**: `"c-relay-pg v1.0.0"`
#### `relay_version`
- **Description**: Software version string
@@ -175,6 +175,18 @@ Configuration events follow the standard Nostr event format with kind 33334:
- **Impact**: Allows some flexibility in expiration timing
- **Example**: `"600"` (10 minute grace period)
### NIP-59 Gift Wrap Timestamp Configuration
#### `nip59_timestamp_max_delay_sec`
- **Description**: Controls timestamp randomization for NIP-59 gift wraps
- **Default**: `"0"` (no randomization)
- **Range**: `0` to `604800` (7 days)
- **Impact**: Affects compatibility with other Nostr clients for direct messaging
- **Values**:
- `"0"`: No randomization (maximum compatibility)
- `"1-604800"`: Random timestamp between now and N seconds ago
- **Example**: `"172800"` (2 days randomization for privacy)
## Configuration Examples
### Basic Relay Setup
@@ -354,7 +366,7 @@ sqlite3 relay.nrdb "SELECT json_pretty(json_object(
#### Invalid Parameter Values
```bash
# Check relay logs for validation errors
journalctl -u c-relay | grep "Configuration.*invalid\|Invalid.*configuration"
journalctl -u c-relay-pg | grep "Configuration.*invalid\|Invalid.*configuration"
# Common issues:
# - Numeric values outside valid ranges
@@ -383,10 +395,10 @@ ORDER BY date DESC;"
#### Resource Usage After Changes
```bash
# Monitor system resources after configuration updates
top -p $(pgrep c_relay)
top -p $(pgrep c_relay_pg)
# Check for memory leaks
ps aux | grep c_relay | awk '{print $6}' # RSS memory
ps aux | grep c_relay_pg | awk '{print $6}' # RSS memory
```
### Emergency Recovery
@@ -413,7 +425,7 @@ nostrtool event \
# If database is corrupted, backup and recreate
cp relay.nrdb relay.nrdb.backup
rm relay.nrdb*
./build/c_relay_x86 # Creates fresh database with new keys
./build/c_relay_pg_x86 # Creates fresh database with new keys
```
---
+14 -14
View File
@@ -21,22 +21,22 @@ typedef enum {
```bash
# Production (default - no debug output)
./c_relay_x86
./c_relay_pg_x86
# Show errors only
./c_relay_x86 --debug-level=1
./c_relay_pg_x86 --debug-level=1
# Show errors and warnings
./c_relay_x86 --debug-level=2
./c_relay_pg_x86 --debug-level=2
# Show errors, warnings, and info (recommended for development)
./c_relay_x86 --debug-level=3
./c_relay_pg_x86 --debug-level=3
# Show all debug messages
./c_relay_x86 --debug-level=4
./c_relay_pg_x86 --debug-level=4
# Show everything including trace with file:line (very verbose)
./c_relay_x86 --debug-level=5
./c_relay_pg_x86 --debug-level=5
```
## Implementation
@@ -475,7 +475,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
### Level 3 (Errors + Warnings + Info)
```
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay-PG v0.4.6
[2025-01-12 14:30:15] [INFO ] Loading configuration from database
[2025-01-12 14:30:15] [ERROR] Failed to open database: permission denied
[2025-01-12 14:30:16] [WARN ] Port 8888 unavailable, trying 8889
@@ -484,7 +484,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
### Level 4 (All Debug Messages)
```
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [INFO ] Initializing C-Relay-PG v0.4.6
[2025-01-12 14:30:15] [DEBUG] Opening database: build/abc123...def.db
[2025-01-12 14:30:15] [DEBUG] Executing schema initialization
[2025-01-12 14:30:15] [INFO ] SQLite WAL mode enabled
@@ -496,7 +496,7 @@ When `g_debug_level = 0` (constant), you'll see the compiler has removed all deb
### Level 5 (Everything Including file:line for ALL messages)
```
[2025-01-12 14:30:15] [INFO ] [main.c:1607] Initializing C-Relay v0.4.6
[2025-01-12 14:30:15] [INFO ] [main.c:1607] Initializing C-Relay-PG v0.4.6
[2025-01-12 14:30:15] [DEBUG] [main.c:348] Opening database: build/abc123...def.db
[2025-01-12 14:30:15] [TRACE] [main.c:330] Entering init_database()
[2025-01-12 14:30:15] [ERROR] [config.c:125] Database locked
@@ -525,11 +525,11 @@ Update the existing `log_*` functions to use the new debug macros
make clean && make
# Test different levels
./build/c_relay_x86 # No output
./build/c_relay_x86 --debug-level=1 # Errors only
./build/c_relay_x86 --debug-level=3 # Info + warnings + errors
./build/c_relay_x86 --debug-level=4 # All debug messages
./build/c_relay_x86 --debug-level=5 # Everything with file:line on TRACE
./build/c_relay_pg_x86 # No output
./build/c_relay_pg_x86 --debug-level=1 # Errors only
./build/c_relay_pg_x86 --debug-level=3 # Info + warnings + errors
./build/c_relay_pg_x86 --debug-level=4 # All debug messages
./build/c_relay_pg_x86 --debug-level=5 # Everything with file:line on TRACE
```
### Step 5: Gradual Migration (Ongoing)
+1 -1
View File
@@ -34,7 +34,7 @@ static const struct {
// NIP-11 Relay Information (relay keys will be populated at runtime)
{"relay_description", "High-performance C Nostr relay with SQLite storage"},
{"relay_contact", ""},
{"relay_software", "https://git.laantungir.net/laantungir/c-relay.git"},
{"relay_software", "https://git.laantungir.net/laantungir/c-relay-pg.git"},
{"relay_version", "v1.0.0"},
// NIP-13 Proof of Work (pow_min_difficulty = 0 means PoW disabled)
+51 -51
View File
@@ -52,11 +52,11 @@ sudo apt install -y build-essential git sqlite3 libsqlite3-dev \
#### User and Directory Setup
```bash
# Create dedicated system user
sudo useradd --system --home-dir /opt/c-relay --shell /bin/false c-relay
sudo useradd --system --home-dir /opt/c-relay-pg --shell /bin/false c-relay-pg
# Create application directory
sudo mkdir -p /opt/c-relay
sudo chown c-relay:c-relay /opt/c-relay
sudo mkdir -p /opt/c-relay-pg
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg
```
### Build and Installation
@@ -64,8 +64,8 @@ sudo chown c-relay:c-relay /opt/c-relay
#### Automated Installation (Recommended)
```bash
# Clone repository
git clone https://github.com/your-org/c-relay.git
cd c-relay
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
git submodule update --init --recursive
# Build
@@ -81,12 +81,12 @@ sudo systemd/install-service.sh
make clean && make
# Install binary
sudo cp build/c_relay_x86 /opt/c-relay/
sudo chown c-relay:c-relay /opt/c-relay/c_relay_x86
sudo chmod +x /opt/c-relay/c_relay_x86
sudo cp build/c_relay_pg_x86 /opt/c-relay-pg/
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg/c_relay_pg_x86
sudo chmod +x /opt/c-relay-pg/c_relay_pg_x86
# Install systemd service
sudo cp systemd/c-relay.service /etc/systemd/system/
sudo cp systemd/c-relay-pg.service /etc/systemd/system/
sudo systemctl daemon-reload
```
@@ -95,22 +95,22 @@ sudo systemctl daemon-reload
#### Start and Enable Service
```bash
# Start the service
sudo systemctl start c-relay
sudo systemctl start c-relay-pg
# Enable auto-start on boot
sudo systemctl enable c-relay
sudo systemctl enable c-relay-pg
# Check status
sudo systemctl status c-relay
sudo systemctl status c-relay-pg
```
#### Capture Admin Keys (CRITICAL)
```bash
# View startup logs to get admin keys
sudo journalctl -u c-relay --since "5 minutes ago" | grep -A 10 "IMPORTANT: SAVE THIS ADMIN PRIVATE KEY"
sudo journalctl -u c-relay-pg --since "5 minutes ago" | grep -A 10 "IMPORTANT: SAVE THIS ADMIN PRIVATE KEY"
# Or check the full log
sudo journalctl -u c-relay --no-pager | grep "Admin Private Key"
sudo journalctl -u c-relay-pg --no-pager | grep "Admin Private Key"
```
⚠️ **CRITICAL**: Save the admin private key immediately - it's only shown once and is needed for all configuration updates!
@@ -151,8 +151,8 @@ sudo iptables-save > /etc/iptables/rules.v4
ssh -i your-key.pem ubuntu@your-instance-ip
# Use the simple deployment script
git clone https://github.com/your-org/c-relay.git
cd c-relay
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
sudo examples/deployment/simple-vps/deploy.sh
```
@@ -168,10 +168,10 @@ sudo examples/deployment/simple-vps/deploy.sh
sudo mkfs.ext4 /dev/xvdf
sudo mkdir /data
sudo mount /dev/xvdf /data
sudo chown c-relay:c-relay /data
sudo chown c-relay-pg:c-relay-pg /data
# Update systemd service to use /data
sudo sed -i 's/WorkingDirectory=\/opt\/c-relay/WorkingDirectory=\/data/' /etc/systemd/system/c-relay.service
sudo sed -i 's/WorkingDirectory=\/opt\/c-relay-pg/WorkingDirectory=\/data/' /etc/systemd/system/c-relay-pg.service
sudo systemctl daemon-reload
```
@@ -180,7 +180,7 @@ sudo systemctl daemon-reload
#### Compute Engine Setup
```bash
# Create VM instance (e2-micro or larger)
gcloud compute instances create c-relay-instance \
gcloud compute instances create c-relay-pg-instance \
--image-family=ubuntu-2204-lts \
--image-project=ubuntu-os-cloud \
--machine-type=e2-micro \
@@ -193,9 +193,9 @@ gcloud compute firewall-rules create allow-nostr-relay \
--target-tags nostr-relay
# SSH and deploy
gcloud compute ssh c-relay-instance
git clone https://github.com/your-org/c-relay.git
cd c-relay
gcloud compute ssh c-relay-pg-instance
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
sudo examples/deployment/simple-vps/deploy.sh
```
@@ -203,13 +203,13 @@ sudo examples/deployment/simple-vps/deploy.sh
```bash
# Create and attach persistent disk
gcloud compute disks create relay-data --size=50GB
gcloud compute instances attach-disk c-relay-instance --disk=relay-data
gcloud compute instances attach-disk c-relay-pg-instance --disk=relay-data
# Format and mount
sudo mkfs.ext4 /dev/sdb
sudo mkdir /data
sudo mount /dev/sdb /data
sudo chown c-relay:c-relay /data
sudo chown c-relay-pg:c-relay-pg /data
```
### DigitalOcean
@@ -223,8 +223,8 @@ sudo chown c-relay:c-relay /data
ssh root@your-droplet-ip
# Deploy relay
git clone https://github.com/your-org/c-relay.git
cd c-relay
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
examples/deployment/simple-vps/deploy.sh
```
@@ -245,8 +245,8 @@ The `examples/deployment/` directory contains ready-to-use scripts:
### Simple VPS Deployment
```bash
# Clone repository and run automated deployment
git clone https://github.com/your-org/c-relay.git
cd c-relay
git clone https://github.com/your-org/c-relay-pg.git
cd c-relay-pg
sudo examples/deployment/simple-vps/deploy.sh
```
@@ -417,9 +417,9 @@ LOG_FILE="/var/log/relay-monitor.log"
DATE=$(date '+%Y-%m-%d %H:%M:%S')
# Check if relay is running
if ! pgrep -f "c_relay_x86" > /dev/null; then
if ! pgrep -f "c_relay_pg_x86" > /dev/null; then
echo "[$DATE] ERROR: Relay process not running" >> $LOG_FILE
systemctl restart c-relay
systemctl restart c-relay-pg
fi
# Check port availability
@@ -428,14 +428,14 @@ if ! netstat -tln | grep -q ":8888"; then
fi
# Check database file
RELAY_DB=$(find /opt/c-relay -name "*.nrdb" | head -1)
RELAY_DB=$(find /opt/c-relay-pg -name "*.nrdb" | head -1)
if [[ -n "$RELAY_DB" ]]; then
DB_SIZE=$(du -h "$RELAY_DB" | cut -f1)
echo "[$DATE] INFO: Database size: $DB_SIZE" >> $LOG_FILE
fi
# Check memory usage
MEM_USAGE=$(ps aux | grep c_relay_x86 | grep -v grep | awk '{print $6}')
MEM_USAGE=$(ps aux | grep c_relay_pg_x86 | grep -v grep | awk '{print $6}')
if [[ -n "$MEM_USAGE" ]]; then
echo "[$DATE] INFO: Memory usage: ${MEM_USAGE}KB" >> $LOG_FILE
fi
@@ -454,8 +454,8 @@ sudo chmod +x /usr/local/bin/relay-monitor.sh
#### Centralized Logging with rsyslog
```bash
# /etc/rsyslog.d/50-c-relay.conf
if $programname == 'c-relay' then /var/log/c-relay.log
# /etc/rsyslog.d/50-c-relay-pg.conf
if $programname == 'c-relay-pg' then /var/log/c-relay-pg.log
& stop
```
@@ -465,7 +465,7 @@ if $programname == 'c-relay' then /var/log/c-relay.log
```yaml
# /etc/prometheus/prometheus.yml
scrape_configs:
- job_name: 'c-relay'
- job_name: 'c-relay-pg'
static_configs:
- targets: ['localhost:8888']
metrics_path: '/metrics' # If implemented
@@ -479,30 +479,30 @@ scrape_configs:
#### Service User Restrictions
```bash
# Restrict service user
sudo usermod -s /bin/false c-relay
sudo usermod -d /opt/c-relay c-relay
sudo usermod -s /bin/false c-relay-pg
sudo usermod -d /opt/c-relay-pg c-relay-pg
# Set proper permissions
sudo chmod 700 /opt/c-relay
sudo chown -R c-relay:c-relay /opt/c-relay
sudo chmod 700 /opt/c-relay-pg
sudo chown -R c-relay-pg:c-relay-pg /opt/c-relay-pg
```
#### File System Restrictions
```bash
# Mount data directory with appropriate options
echo "/dev/sdb /opt/c-relay ext4 defaults,noexec,nosuid,nodev 0 2" >> /etc/fstab
echo "/dev/sdb /opt/c-relay-pg ext4 defaults,noexec,nosuid,nodev 0 2" >> /etc/fstab
```
### Network Security
#### Fail2Ban Configuration
```ini
# /etc/fail2ban/jail.d/c-relay.conf
[c-relay-dos]
# /etc/fail2ban/jail.d/c-relay-pg.conf
[c-relay-pg-dos]
enabled = true
port = 8888
filter = c-relay-dos
logpath = /var/log/c-relay.log
filter = c-relay-pg-dos
logpath = /var/log/c-relay-pg.log
maxretry = 10
findtime = 60
bantime = 300
@@ -534,9 +534,9 @@ sudo mkfs.ext4 /dev/mapper/relay-data
#!/bin/bash
# /usr/local/bin/backup-relay.sh
BACKUP_DIR="/backup/c-relay"
BACKUP_DIR="/backup/c-relay-pg"
DATE=$(date +%Y%m%d_%H%M%S)
RELAY_DB=$(find /opt/c-relay -name "*.nrdb" | head -1)
RELAY_DB=$(find /opt/c-relay-pg -name "*.nrdb" | head -1)
mkdir -p "$BACKUP_DIR"
@@ -574,7 +574,7 @@ sudo apt install -y awscli
aws configure
# Sync backups to S3
aws s3 sync /backup/c-relay/ s3://your-backup-bucket/c-relay/ --delete
aws s3 sync /backup/c-relay-pg/ s3://your-backup-bucket/c-relay-pg/ --delete
```
### Disaster Recovery
@@ -583,16 +583,16 @@ aws s3 sync /backup/c-relay/ s3://your-backup-bucket/c-relay/ --delete
```bash
# 1. Restore from backup
gunzip backup/relay_backup_20231201_020000.nrdb.gz
cp backup/relay_backup_20231201_020000.nrdb /opt/c-relay/
cp backup/relay_backup_20231201_020000.nrdb /opt/c-relay-pg/
# 2. Fix permissions
sudo chown c-relay:c-relay /opt/c-relay/*.nrdb
sudo chown c-relay-pg:c-relay-pg /opt/c-relay-pg/*.nrdb
# 3. Restart service
sudo systemctl restart c-relay
sudo systemctl restart c-relay-pg
# 4. Verify recovery
sudo journalctl -u c-relay --since "1 minute ago"
sudo journalctl -u c-relay-pg --since "1 minute ago"
```
---
+298
View File
@@ -0,0 +1,298 @@
# Libwebsockets Proper Pattern - Message Queue Design
## Problem Analysis
### Current Violation
We're calling `lws_write()` directly from multiple code paths:
1. **Event broadcast** (subscriptions.c:667) - when events arrive
2. **OK responses** (websockets.c:855) - when processing EVENT messages
3. **EOSE responses** (websockets.c:976) - when processing REQ messages
4. **COUNT responses** (websockets.c:1922) - when processing COUNT messages
This violates libwebsockets' design pattern which requires:
- **`lws_write()` ONLY called from `LWS_CALLBACK_SERVER_WRITEABLE`**
- Application queues messages and requests writeable callback
- Libwebsockets handles write timing and socket buffer management
### Consequences of Violation
1. Partial writes when socket buffer is full
2. Multiple concurrent write attempts before callback fires
3. "write already pending" errors with single buffer
4. Frame corruption from interleaved partial writes
5. "Invalid frame header" errors on client side
## Correct Architecture
### Message Queue Pattern
```
┌─────────────────────────────────────────────────────────────┐
│ Application Layer │
├─────────────────────────────────────────────────────────────┤
│ │
│ Event Arrives → Queue Message → Request Writeable Callback │
│ REQ Received → Queue EOSE → Request Writeable Callback │
│ EVENT Received→ Queue OK → Request Writeable Callback │
│ COUNT Received→ Queue COUNT → Request Writeable Callback │
│ │
└─────────────────────────────────────────────────────────────┘
lws_callback_on_writable(wsi)
┌─────────────────────────────────────────────────────────────┐
│ LWS_CALLBACK_SERVER_WRITEABLE │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. Dequeue next message from queue │
│ 2. Call lws_write() with message data │
│ 3. If queue not empty, request another callback │
│ │
└─────────────────────────────────────────────────────────────┘
libwebsockets handles:
- Socket buffer management
- Partial write handling
- Frame atomicity
```
## Data Structures
### Message Queue Node
```c
typedef struct message_queue_node {
unsigned char* data; // Message data (with LWS_PRE space)
size_t length; // Message length (without LWS_PRE)
enum lws_write_protocol type; // LWS_WRITE_TEXT, etc.
struct message_queue_node* next;
} message_queue_node_t;
```
### Per-Session Data Updates
```c
struct per_session_data {
// ... existing fields ...
// Message queue (replaces single buffer)
message_queue_node_t* message_queue_head;
message_queue_node_t* message_queue_tail;
int message_queue_count;
int writeable_requested; // Flag to prevent duplicate requests
};
```
## Implementation Functions
### 1. Queue Message (Application Layer)
```c
int queue_message(struct lws* wsi, struct per_session_data* pss,
const char* message, size_t length,
enum lws_write_protocol type)
{
// Allocate node
message_queue_node_t* node = malloc(sizeof(message_queue_node_t));
// Allocate buffer with LWS_PRE space
node->data = malloc(LWS_PRE + length);
memcpy(node->data + LWS_PRE, message, length);
node->length = length;
node->type = type;
node->next = NULL;
// Add to queue (FIFO)
pthread_mutex_lock(&pss->session_lock);
if (!pss->message_queue_head) {
pss->message_queue_head = node;
pss->message_queue_tail = node;
} else {
pss->message_queue_tail->next = node;
pss->message_queue_tail = node;
}
pss->message_queue_count++;
pthread_mutex_unlock(&pss->session_lock);
// Request writeable callback (only if not already requested)
if (!pss->writeable_requested) {
pss->writeable_requested = 1;
lws_callback_on_writable(wsi);
}
return 0;
}
```
### 2. Process Queue (Writeable Callback)
```c
int process_message_queue(struct lws* wsi, struct per_session_data* pss)
{
pthread_mutex_lock(&pss->session_lock);
// Get next message from queue
message_queue_node_t* node = pss->message_queue_head;
if (!node) {
pss->writeable_requested = 0;
pthread_mutex_unlock(&pss->session_lock);
return 0; // Queue empty
}
// Remove from queue
pss->message_queue_head = node->next;
if (!pss->message_queue_head) {
pss->message_queue_tail = NULL;
}
pss->message_queue_count--;
pthread_mutex_unlock(&pss->session_lock);
// Write message (libwebsockets handles partial writes)
int result = lws_write(wsi, node->data + LWS_PRE, node->length, node->type);
// Free node
free(node->data);
free(node);
// If queue not empty, request another callback
pthread_mutex_lock(&pss->session_lock);
if (pss->message_queue_head) {
lws_callback_on_writable(wsi);
} else {
pss->writeable_requested = 0;
}
pthread_mutex_unlock(&pss->session_lock);
return (result < 0) ? -1 : 0;
}
```
## Refactoring Changes
### Before (WRONG - Direct Write)
```c
// websockets.c:855 - OK response
int write_result = lws_write(wsi, buf + LWS_PRE, response_len, LWS_WRITE_TEXT);
if (write_result < 0) {
DEBUG_ERROR("Write failed");
} else if ((size_t)write_result != response_len) {
// Partial write - queue remaining data
queue_websocket_write(wsi, pss, ...);
}
```
### After (CORRECT - Queue Message)
```c
// websockets.c:855 - OK response
queue_message(wsi, pss, response_str, response_len, LWS_WRITE_TEXT);
// That's it! Writeable callback will handle the actual write
```
### Before (WRONG - Direct Write in Broadcast)
```c
// subscriptions.c:667 - EVENT broadcast
int write_result = lws_write(current_temp->wsi, buf + LWS_PRE, msg_len, LWS_WRITE_TEXT);
if (write_result < 0) {
DEBUG_ERROR("Write failed");
} else if ((size_t)write_result != msg_len) {
queue_websocket_write(...);
}
```
### After (CORRECT - Queue Message)
```c
// subscriptions.c:667 - EVENT broadcast
struct per_session_data* pss = lws_wsi_user(current_temp->wsi);
queue_message(current_temp->wsi, pss, msg_str, msg_len, LWS_WRITE_TEXT);
// Writeable callback will handle the actual write
```
## Benefits of Correct Pattern
1. **No Partial Write Handling Needed**
- Libwebsockets handles partial writes internally
- We just queue complete messages
2. **No "Write Already Pending" Errors**
- Queue can hold unlimited messages
- Each processed sequentially from callback
3. **Thread Safety**
- Queue operations protected by session lock
- Write only from single callback thread
4. **Frame Atomicity**
- Libwebsockets ensures complete frame transmission
- No interleaved partial writes
5. **Simpler Code**
- No complex partial write state machine
- Just queue and forget
6. **Better Performance**
- Libwebsockets optimizes write timing
- Batches writes when socket ready
## Migration Steps
1. ✅ Identify all `lws_write()` call sites
2. ✅ Confirm violation of libwebsockets pattern
3. ⏳ Design message queue structure
4. ⏳ Implement `queue_message()` function
5. ⏳ Implement `process_message_queue()` function
6. ⏳ Update `per_session_data` structure
7. ⏳ Refactor OK response to use queue
8. ⏳ Refactor EOSE response to use queue
9. ⏳ Refactor COUNT response to use queue
10. ⏳ Refactor EVENT broadcast to use queue
11. ⏳ Update `LWS_CALLBACK_SERVER_WRITEABLE` handler
12. ⏳ Add queue cleanup in `LWS_CALLBACK_CLOSED`
13. ⏳ Remove old partial write code
14. ⏳ Test with rapid multiple events
15. ⏳ Test with large events (>4KB)
16. ⏳ Test under load
17. ⏳ Verify no frame errors
## Testing Strategy
### Test 1: Multiple Rapid Events
```bash
# Send 10 events rapidly to same client
for i in {1..10}; do
echo '["EVENT",{"kind":1,"content":"test'$i'","created_at":'$(date +%s)',...}]' | \
websocat ws://localhost:8888 &
done
```
**Expected**: All events queued and sent sequentially, no errors
### Test 2: Large Events
```bash
# Send event >4KB (forces multiple socket writes)
nak event --content "$(head -c 5000 /dev/urandom | base64)" | \
websocat ws://localhost:8888
```
**Expected**: Event queued, libwebsockets handles partial writes internally
### Test 3: Concurrent Connections
```bash
# 100 concurrent connections, each sending events
for i in {1..100}; do
(echo '["REQ","sub'$i'",{}]'; sleep 1) | websocat ws://localhost:8888 &
done
```
**Expected**: All subscriptions work, events broadcast correctly
## Success Criteria
- ✅ No `lws_write()` calls outside `LWS_CALLBACK_SERVER_WRITEABLE`
- ✅ No "write already pending" errors in logs
- ✅ No "Invalid frame header" errors on client side
- ✅ All messages delivered in correct order
- ✅ Large events (>4KB) handled correctly
- ✅ Multiple rapid events to same client work
- ✅ Concurrent connections stable under load
## References
- [libwebsockets documentation](https://libwebsockets.org/lws-api-doc-main/html/index.html)
- [LWS_CALLBACK_SERVER_WRITEABLE](https://libwebsockets.org/lws-api-doc-main/html/group__callback-when-writeable.html)
- [lws_callback_on_writable()](https://libwebsockets.org/lws-api-doc-main/html/group__callback-when-writeable.html#ga96f3ad8e1e2c3e0c8e0b0e5e5e5e5e5e)
+601
View File
@@ -0,0 +1,601 @@
# Simplified Monitoring Implementation Plan
## Kind 34567 Event Kind Distribution Reporting
**Date:** 2025-10-16
**Status:** Implementation Ready
---
## Overview
Simplified real-time monitoring system that:
- Reports event kind distribution (which includes total event count)
- Uses kind 34567 addressable events with `d=event_kinds`
- Controlled by two config variables
- Enabled on-demand when admin logs in
- Uses simple throttling to prevent performance impact
---
## Configuration Variables
### Database Config Table
Add two new configuration keys:
```sql
INSERT INTO config (key, value, data_type, description, category) VALUES
('kind_34567_reporting_enabled', 'false', 'boolean',
'Enable/disable kind 34567 event kind distribution reporting', 'monitoring'),
('kind_34567_reporting_throttling_sec', '5', 'integer',
'Minimum seconds between kind 34567 reports (throttling)', 'monitoring');
```
### Configuration Access
```c
// In src/monitoring.c or src/api.c
int is_monitoring_enabled(void) {
return get_config_bool("kind_34567_reporting_enabled", 0);
}
int get_monitoring_throttle_seconds(void) {
return get_config_int("kind_34567_reporting_throttling_sec", 5);
}
```
---
## Event Structure
### Kind 34567 Event Format
```json
{
"id": "<event_id>",
"pubkey": "<relay_pubkey>",
"created_at": 1697123456,
"kind": 34567,
"content": "{\"data_type\":\"event_kinds\",\"timestamp\":1697123456,\"data\":{\"total_events\":125000,\"distribution\":[{\"kind\":1,\"count\":45000,\"percentage\":36.0},{\"kind\":3,\"count\":12500,\"percentage\":10.0}]}}",
"tags": [
["d", "event_kinds"],
["relay", "<relay_pubkey>"]
],
"sig": "<signature>"
}
```
### Content JSON Structure
```json
{
"data_type": "event_kinds",
"timestamp": 1697123456,
"data": {
"total_events": 125000,
"distribution": [
{
"kind": 1,
"count": 45000,
"percentage": 36.0
},
{
"kind": 3,
"count": 12500,
"percentage": 10.0
}
]
},
"metadata": {
"query_time_ms": 18
}
}
```
---
## Implementation
### File Structure
```
src/
monitoring.h # New file - monitoring system header
monitoring.c # New file - monitoring implementation
main.c # Modified - add trigger hook
config.c # Modified - add config keys (or use migration)
```
### 1. Header File: `src/monitoring.h`
```c
#ifndef MONITORING_H
#define MONITORING_H
#include <time.h>
#include <cjson/cJSON.h>
// Initialize monitoring system
int init_monitoring_system(void);
// Cleanup monitoring system
void cleanup_monitoring_system(void);
// Called when an event is stored (from main.c)
void monitoring_on_event_stored(void);
// Enable/disable monitoring (called from admin API)
int set_monitoring_enabled(int enabled);
// Get monitoring status
int is_monitoring_enabled(void);
// Get throttle interval
int get_monitoring_throttle_seconds(void);
#endif /* MONITORING_H */
```
### 2. Implementation: `src/monitoring.c`
```c
#include "monitoring.h"
#include "config.h"
#include "debug.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <sqlite3.h>
#include <string.h>
#include <time.h>
// External references
extern sqlite3* g_db;
extern int broadcast_event_to_subscriptions(cJSON* event);
extern int store_event(cJSON* event);
extern const char* get_config_value(const char* key);
extern int get_config_bool(const char* key, int default_value);
extern int get_config_int(const char* key, int default_value);
extern char* get_relay_private_key(void);
// Throttling state
static time_t last_report_time = 0;
// Initialize monitoring system
int init_monitoring_system(void) {
DEBUG_LOG("Monitoring system initialized");
last_report_time = 0;
return 0;
}
// Cleanup monitoring system
void cleanup_monitoring_system(void) {
DEBUG_LOG("Monitoring system cleaned up");
}
// Check if monitoring is enabled
int is_monitoring_enabled(void) {
return get_config_bool("kind_34567_reporting_enabled", 0);
}
// Get throttle interval
int get_monitoring_throttle_seconds(void) {
return get_config_int("kind_34567_reporting_throttling_sec", 5);
}
// Enable/disable monitoring
int set_monitoring_enabled(int enabled) {
// Update config table
const char* value = enabled ? "true" : "false";
// This would call update_config_in_table() or similar
// For now, assume we have a function to update config
extern int update_config_in_table(const char* key, const char* value);
return update_config_in_table("kind_34567_reporting_enabled", value);
}
// Query event kind distribution from database
static char* query_event_kind_distribution(void) {
if (!g_db) {
DEBUG_ERROR("Database not available for monitoring query");
return NULL;
}
struct timespec start_time;
clock_gettime(CLOCK_MONOTONIC, &start_time);
// Query total events
sqlite3_stmt* stmt;
int total_events = 0;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM events", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
total_events = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
// Query kind distribution
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "data_type", "event_kinds");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
cJSON* data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "total_events", total_events);
cJSON* distribution = cJSON_CreateArray();
const char* sql =
"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";
if (sqlite3_prepare_v2(g_db, sql, -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(distribution, kind_obj);
}
sqlite3_finalize(stmt);
}
cJSON_AddItemToObject(data, "distribution", distribution);
cJSON_AddItemToObject(response, "data", data);
// Calculate query time
struct timespec end_time;
clock_gettime(CLOCK_MONOTONIC, &end_time);
double query_time_ms = (end_time.tv_sec - start_time.tv_sec) * 1000.0 +
(end_time.tv_nsec - start_time.tv_nsec) / 1000000.0;
cJSON* metadata = cJSON_CreateObject();
cJSON_AddNumberToObject(metadata, "query_time_ms", query_time_ms);
cJSON_AddItemToObject(response, "metadata", metadata);
char* json_string = cJSON_Print(response);
cJSON_Delete(response);
return json_string;
}
// Generate and broadcast kind 34567 event
static int generate_monitoring_event(const char* json_content) {
if (!json_content) return -1;
// Get relay keys
const char* relay_pubkey = get_config_value("relay_pubkey");
char* relay_privkey_hex = get_relay_private_key();
if (!relay_pubkey || !relay_privkey_hex) {
if (relay_privkey_hex) free(relay_privkey_hex);
DEBUG_ERROR("Could not get relay keys for monitoring event");
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);
DEBUG_ERROR("Failed to convert relay private key");
return -1;
}
free(relay_privkey_hex);
// Create tags array
cJSON* tags = cJSON_CreateArray();
// d tag for addressable event
cJSON* d_tag = cJSON_CreateArray();
cJSON_AddItemToArray(d_tag, cJSON_CreateString("d"));
cJSON_AddItemToArray(d_tag, cJSON_CreateString("event_kinds"));
cJSON_AddItemToArray(tags, d_tag);
// relay tag
cJSON* relay_tag = cJSON_CreateArray();
cJSON_AddItemToArray(relay_tag, cJSON_CreateString("relay"));
cJSON_AddItemToArray(relay_tag, cJSON_CreateString(relay_pubkey));
cJSON_AddItemToArray(tags, relay_tag);
// Create and sign event
cJSON* event = nostr_create_and_sign_event(
34567, // kind
json_content, // content
tags, // tags
relay_privkey, // private key
time(NULL) // timestamp
);
if (!event) {
DEBUG_ERROR("Failed to create and sign monitoring event");
return -1;
}
// Broadcast to subscriptions
broadcast_event_to_subscriptions(event);
// Store in database
int result = store_event(event);
cJSON_Delete(event);
return result;
}
// Called when an event is stored
void monitoring_on_event_stored(void) {
// Check if monitoring is enabled
if (!is_monitoring_enabled()) {
return;
}
// Check throttling
time_t now = time(NULL);
int throttle_seconds = get_monitoring_throttle_seconds();
if (now - last_report_time < throttle_seconds) {
return; // Too soon, skip this update
}
// Query event kind distribution
char* json_content = query_event_kind_distribution();
if (!json_content) {
DEBUG_ERROR("Failed to query event kind distribution");
return;
}
// Generate and broadcast monitoring event
int result = generate_monitoring_event(json_content);
free(json_content);
if (result == 0) {
last_report_time = now;
DEBUG_LOG("Generated kind 34567 monitoring event");
} else {
DEBUG_ERROR("Failed to generate monitoring event");
}
}
```
### 3. Integration: Modify `src/main.c`
Add monitoring hook to event storage:
```c
// At top of file
#include "monitoring.h"
// In main() function, after init_database()
if (init_monitoring_system() != 0) {
DEBUG_WARN("Failed to initialize monitoring system");
// Continue anyway - monitoring is optional
}
// In store_event() function, after successful storage
int store_event(cJSON* event) {
// ... existing code ...
if (rc != SQLITE_DONE) {
// ... error handling ...
}
free(tags_json);
// Trigger monitoring update
monitoring_on_event_stored();
return 0;
}
// In cleanup section of main()
cleanup_monitoring_system();
```
### 4. Admin API: Enable/Disable Monitoring
Add admin command to enable monitoring (in `src/dm_admin.c` or `src/api.c`):
```c
// Handle admin command to enable monitoring
if (strcmp(command, "enable_monitoring") == 0) {
set_monitoring_enabled(1);
send_nip17_response(sender_pubkey,
"✅ Kind 34567 monitoring enabled",
error_msg, sizeof(error_msg));
return 0;
}
// Handle admin command to disable monitoring
if (strcmp(command, "disable_monitoring") == 0) {
set_monitoring_enabled(0);
send_nip17_response(sender_pubkey,
"🔴 Kind 34567 monitoring disabled",
error_msg, sizeof(error_msg));
return 0;
}
// Handle admin command to set throttle interval
if (strncmp(command, "set_monitoring_throttle ", 24) == 0) {
int seconds = atoi(command + 24);
if (seconds >= 1 && seconds <= 3600) {
char value[16];
snprintf(value, sizeof(value), "%d", seconds);
update_config_in_table("kind_34567_reporting_throttling_sec", value);
char response[128];
snprintf(response, sizeof(response),
"✅ Monitoring throttle set to %d seconds", seconds);
send_nip17_response(sender_pubkey, response, error_msg, sizeof(error_msg));
}
return 0;
}
```
---
## Frontend Integration
### Admin Dashboard Subscription
```javascript
// When admin logs in to dashboard
async function enableMonitoring() {
// Send admin command to enable monitoring
await sendAdminCommand(['enable_monitoring']);
// Subscribe to kind 34567 events
const subscription = {
kinds: [34567],
authors: [relayPubkey],
"#d": ["event_kinds"]
};
relay.subscribe([subscription], {
onevent: (event) => {
handleMonitoringEvent(event);
}
});
}
// Handle incoming monitoring events
function handleMonitoringEvent(event) {
const content = JSON.parse(event.content);
if (content.data_type === 'event_kinds') {
updateEventKindsChart(content.data);
updateTotalEventsDisplay(content.data.total_events);
}
}
// When admin logs out or closes dashboard
async function disableMonitoring() {
await sendAdminCommand(['disable_monitoring']);
}
```
### Display Event Kind Distribution
```javascript
function updateEventKindsChart(data) {
const { total_events, distribution } = data;
// Update total events display
document.getElementById('total-events').textContent =
total_events.toLocaleString();
// Update chart/table with distribution
const tableBody = document.getElementById('kind-distribution-table');
tableBody.innerHTML = '';
distribution.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>Kind ${item.kind}</td>
<td>${item.count.toLocaleString()}</td>
<td>${item.percentage}%</td>
`;
tableBody.appendChild(row);
});
}
```
---
## Configuration Migration
### Add to Schema or Migration Script
```sql
-- Add monitoring configuration
INSERT INTO config (key, value, data_type, description, category) VALUES
('kind_34567_reporting_enabled', 'false', 'boolean',
'Enable/disable kind 34567 event kind distribution reporting', 'monitoring'),
('kind_34567_reporting_throttling_sec', '5', 'integer',
'Minimum seconds between kind 34567 reports (throttling)', 'monitoring');
```
Or add to existing config initialization in `src/config.c`.
---
## Testing
### 1. Enable Monitoring
```bash
# Via admin command (NIP-17 DM)
echo '["enable_monitoring"]' | nak event --kind 14 --content - ws://localhost:8888
```
### 2. Subscribe to Monitoring Events
```bash
# Subscribe to kind 34567 events
nak req --kinds 34567 --authors <relay_pubkey> ws://localhost:8888
```
### 3. Generate Events
```bash
# Send some test events to trigger monitoring
for i in {1..10}; do
nak event -c "Test event $i" ws://localhost:8888
sleep 1
done
```
### 4. Verify Monitoring Events
You should see kind 34567 events every 5 seconds (or configured throttle interval) with event kind distribution.
---
## Performance Impact
### With 3 events/second (relay.damus.io scale)
**Query execution**:
- Frequency: Every 5 seconds (throttled)
- Query time: ~700ms (for 1M events)
- Overhead: 700ms / 5000ms = 14% (acceptable)
**Per-event overhead**:
- Check if enabled: < 0.01ms
- Check throttle: < 0.01ms
- Total: < 0.02ms per event (negligible)
**Overall impact**: < 1% on event processing, 14% on query thread (separate from event processing)
---
## Future Enhancements
Once this is working, easy to add:
1. **More data types**: Add `d=connections`, `d=subscriptions`, etc.
2. **Materialized counters**: Optimize queries for very large databases
3. **Historical data**: Store monitoring events for trending
4. **Alerts**: Trigger on thresholds (e.g., > 90% capacity)
---
## Summary
This simplified plan provides:
**Single data type**: Event kind distribution (includes total events)
**Two config variables**: Enable/disable and throttle control
**On-demand activation**: Enabled when admin logs in
**Simple throttling**: Prevents performance impact
**Clean implementation**: ~200 lines of code
**Easy to extend**: Add more data types later
**Estimated implementation time**: 4-6 hours
**Files to create/modify**:
- Create: `src/monitoring.h` (~30 lines)
- Create: `src/monitoring.c` (~200 lines)
- Modify: `src/main.c` (~10 lines)
- Modify: `src/config.c` or migration (~5 lines)
- Modify: `src/dm_admin.c` or `src/api.c` (~30 lines)
- Create: `api/monitoring.js` (frontend, ~100 lines)
**Total new code**: ~375 lines
+25 -25
View File
@@ -2,7 +2,7 @@
## Overview
This guide explains how to build truly portable MUSL-based static binaries of c-relay using Alpine Linux Docker containers. These binaries have **zero runtime dependencies** and work on any Linux distribution.
This guide explains how to build truly portable MUSL-based static binaries of c-relay-pg using Alpine Linux Docker containers. These binaries have **zero runtime dependencies** and work on any Linux distribution.
## Why MUSL?
@@ -36,8 +36,8 @@ This guide explains how to build truly portable MUSL-based static binaries of c-
./build_static.sh
# The binary will be created at:
# build/c_relay_static_musl_x86_64 (on x86_64)
# build/c_relay_static_musl_arm64 (on ARM64)
# build/c_relay_pg_static_musl_x86_64 (on x86_64)
# build/c_relay_pg_static_musl_arm64 (on ARM64)
```
### What Happens During Build
@@ -56,7 +56,7 @@ This guide explains how to build truly portable MUSL-based static binaries of c-
- Includes required NIPs: 001, 006, 013, 017, 019, 044, 059
- Produces static library (~316KB)
4. **c-relay Compilation**: Links everything statically:
4. **c-relay-pg Compilation**: Links everything statically:
- All source files compiled with `-static` flag
- Fortification disabled to avoid `__*_chk` symbols
- Results in ~7.6MB stripped binary
@@ -78,7 +78,7 @@ FROM alpine:3.19 AS builder
- Install build tools and static libraries
- Build dependencies from source
- Compile nostr_core_lib with MUSL flags
- Compile c-relay with full static linking
- Compile c-relay-pg with full static linking
- Strip binary to reduce size
# Stage 2: Output (scratch)
@@ -93,7 +93,7 @@ FROM scratch AS output
CFLAGS="-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 -Wall -Wextra -std=c99 -fPIC -O2"
```
**For c-relay:**
**For c-relay-pg:**
```bash
gcc -static -O2 -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
@@ -110,7 +110,7 @@ gcc -static -O2 -Wall -Wextra -std=c99 \
- `-U_FORTIFY_SOURCE` (undefine any existing definition)
- `-D_FORTIFY_SOURCE=0` (set to 0)
This must be applied to **both** nostr_core_lib and c-relay compilation.
This must be applied to **both** nostr_core_lib and c-relay-pg compilation.
### NIP Dependencies
@@ -129,26 +129,26 @@ The build includes these NIPs in nostr_core_lib:
```bash
# Should show "statically linked"
file build/c_relay_static_musl_x86_64
file build/c_relay_pg_static_musl_x86_64
# Should show "not a dynamic executable"
ldd build/c_relay_static_musl_x86_64
ldd build/c_relay_pg_static_musl_x86_64
# Check size (should be ~7.6MB)
ls -lh build/c_relay_static_musl_x86_64
ls -lh build/c_relay_pg_static_musl_x86_64
```
### Test Execution
```bash
# Show help
./build/c_relay_static_musl_x86_64 --help
./build/c_relay_pg_static_musl_x86_64 --help
# Show version
./build/c_relay_static_musl_x86_64 --version
./build/c_relay_pg_static_musl_x86_64 --version
# Run relay
./build/c_relay_static_musl_x86_64 --port 8888
./build/c_relay_pg_static_musl_x86_64 --port 8888
```
### Cross-Distribution Testing
@@ -157,16 +157,16 @@ Test the binary on different distributions to verify portability:
```bash
# Alpine Linux
docker run --rm -v $(pwd)/build:/app alpine:latest /app/c_relay_static_musl_x86_64 --version
docker run --rm -v $(pwd)/build:/app alpine:latest /app/c_relay_pg_static_musl_x86_64 --version
# Ubuntu
docker run --rm -v $(pwd)/build:/app ubuntu:latest /app/c_relay_static_musl_x86_64 --version
docker run --rm -v $(pwd)/build:/app ubuntu:latest /app/c_relay_pg_static_musl_x86_64 --version
# Debian
docker run --rm -v $(pwd)/build:/app debian:latest /app/c_relay_static_musl_x86_64 --version
docker run --rm -v $(pwd)/build:/app debian:latest /app/c_relay_pg_static_musl_x86_64 --version
# CentOS
docker run --rm -v $(pwd)/build:/app centos:latest /app/c_relay_static_musl_x86_64 --version
docker run --rm -v $(pwd)/build:/app centos:latest /app/c_relay_pg_static_musl_x86_64 --version
```
## Troubleshooting
@@ -187,7 +187,7 @@ newgrp docker # Or logout and login again
**Solution**: Ensure fortification is disabled in both:
1. nostr_core_lib build.sh (line 534)
2. c-relay compilation flags in Dockerfile
2. c-relay-pg compilation flags in Dockerfile
### Binary Won't Execute
@@ -213,26 +213,26 @@ newgrp docker # Or logout and login again
```bash
# Copy binary to server
scp build/c_relay_static_musl_x86_64 user@server:/opt/c-relay/
scp build/c_relay_pg_static_musl_x86_64 user@server:/opt/c-relay-pg/
# Run on server (no dependencies needed!)
ssh user@server
cd /opt/c-relay
./c_relay_static_musl_x86_64 --port 8888
cd /opt/c-relay-pg
./c_relay_pg_static_musl_x86_64 --port 8888
```
### SystemD Service
```ini
[Unit]
Description=C-Relay Nostr Relay (MUSL Static)
Description=C-Relay-PG Nostr Relay (MUSL Static)
After=network.target
[Service]
Type=simple
User=c-relay
WorkingDirectory=/opt/c-relay
ExecStart=/opt/c-relay/c_relay_static_musl_x86_64
User=c-relay-pg
WorkingDirectory=/opt/c-relay-pg
ExecStart=/opt/c-relay-pg/c_relay_pg_static_musl_x86_64
Restart=always
RestartSec=5
+517
View File
@@ -0,0 +1,517 @@
# NIP-59 Timestamp Configuration Implementation Plan
## Overview
Add configurable timestamp randomization for NIP-59 gift wraps to improve compatibility with Nostr apps that don't implement timestamp randomization.
## Problem Statement
The NIP-59 protocol specifies that timestamps on gift wraps should have randomness to prevent time-analysis attacks. However, some Nostr platforms don't implement this, causing compatibility issues with direct messaging (NIP-17).
## Solution
Add a configuration parameter `nip59_timestamp_max_delay_sec` that controls the maximum random delay applied to timestamps:
- **Value = 0**: Use current timestamp (no randomization) for maximum compatibility
- **Value > 0**: Use random timestamp between now and N seconds ago
- **Default = 0**: Maximum compatibility mode (no randomization)
## Implementation Approach: Option B (Direct Parameter Addition)
We chose Option B because:
1. Explicit and stateless - value flows through call chain
2. Thread-safe by design
3. No global state needed in nostr_core_lib
4. DMs are sent rarely, so database query per call is acceptable
---
## Detailed Implementation Steps
### Phase 1: Configuration Setup in c-relay-pg
#### 1.1 Add Configuration Parameter
**File:** `src/default_config_event.h`
**Location:** Line 82 (after `trust_proxy_headers`)
```c
// NIP-59 Gift Wrap Timestamp Configuration
{"nip59_timestamp_max_delay_sec", "0"} // Default: 0 (no randomization for compatibility)
```
**Rationale:**
- Default of 0 seconds (no randomization) for maximum compatibility
- Placed after proxy settings, before closing brace
- Follows existing naming convention
#### 1.2 Add Configuration Validation
**File:** `src/config.c`
**Function:** `validate_config_field()` (around line 923)
Add validation case:
```c
else if (strcmp(key, "nip59_timestamp_max_delay_sec") == 0) {
long value = strtol(value_str, NULL, 10);
if (value < 0 || value > 604800) { // Max 7 days
snprintf(error_msg, error_size,
"nip59_timestamp_max_delay_sec must be between 0 and 604800 (7 days)");
return -1;
}
}
```
**Rationale:**
- 0 = no randomization (compatibility mode)
- 604800 = 7 days maximum (reasonable upper bound)
- Prevents negative values or excessive delays
---
### Phase 2: Modify nostr_core_lib Functions
#### 2.1 Update random_past_timestamp() Function
**File:** `nostr_core_lib/nostr_core/nip059.c`
**Current Location:** Lines 31-36
**Current Code:**
```c
static time_t random_past_timestamp(void) {
time_t now = time(NULL);
// Random time up to 2 days (172800 seconds) in the past
long random_offset = (long)(rand() % 172800);
return now - random_offset;
}
```
**New Code:**
```c
static time_t random_past_timestamp(long max_delay_sec) {
time_t now = time(NULL);
// If max_delay_sec is 0, return current timestamp (no randomization)
if (max_delay_sec == 0) {
return now;
}
// Random time up to max_delay_sec in the past
long random_offset = (long)(rand() % max_delay_sec);
return now - random_offset;
}
```
**Changes:**
- Add `long max_delay_sec` parameter
- Handle special case: `max_delay_sec == 0` returns current time
- Use `max_delay_sec` instead of hardcoded 172800
#### 2.2 Update nostr_nip59_create_seal() Function
**File:** `nostr_core_lib/nostr_core/nip059.c`
**Current Location:** Lines 144-215
**Function Signature Change:**
```c
// OLD:
cJSON* nostr_nip59_create_seal(cJSON* rumor,
const unsigned char* sender_private_key,
const unsigned char* recipient_public_key);
// NEW:
cJSON* nostr_nip59_create_seal(cJSON* rumor,
const unsigned char* sender_private_key,
const unsigned char* recipient_public_key,
long max_delay_sec);
```
**Code Change at Line 181:**
```c
// OLD:
time_t seal_time = random_past_timestamp();
// NEW:
time_t seal_time = random_past_timestamp(max_delay_sec);
```
#### 2.3 Update nostr_nip59_create_gift_wrap() Function
**File:** `nostr_core_lib/nostr_core/nip059.c`
**Current Location:** Lines 220-323
**Function Signature Change:**
```c
// OLD:
cJSON* nostr_nip59_create_gift_wrap(cJSON* seal,
const char* recipient_public_key_hex);
// NEW:
cJSON* nostr_nip59_create_gift_wrap(cJSON* seal,
const char* recipient_public_key_hex,
long max_delay_sec);
```
**Code Change at Line 275:**
```c
// OLD:
time_t wrap_time = random_past_timestamp();
// NEW:
time_t wrap_time = random_past_timestamp(max_delay_sec);
```
#### 2.4 Update nip059.h Header
**File:** `nostr_core_lib/nostr_core/nip059.h`
**Locations:** Lines 38-39 and 48
**Update Function Declarations:**
```c
// Line 38-39: Update nostr_nip59_create_seal
cJSON* nostr_nip59_create_seal(cJSON* rumor,
const unsigned char* sender_private_key,
const unsigned char* recipient_public_key,
long max_delay_sec);
// Line 48: Update nostr_nip59_create_gift_wrap
cJSON* nostr_nip59_create_gift_wrap(cJSON* seal,
const char* recipient_public_key_hex,
long max_delay_sec);
```
**Update Documentation Comments:**
```c
/**
* NIP-59: Create a seal (kind 13) wrapping a rumor
*
* @param rumor The rumor event to seal (cJSON object)
* @param sender_private_key 32-byte sender private key
* @param recipient_public_key 32-byte recipient public key (x-only)
* @param max_delay_sec Maximum random delay in seconds (0 = no randomization)
* @return cJSON object representing the seal event, or NULL on error
*/
/**
* NIP-59: Create a gift wrap (kind 1059) wrapping a seal
*
* @param seal The seal event to wrap (cJSON object)
* @param recipient_public_key_hex Recipient's public key in hex format
* @param max_delay_sec Maximum random delay in seconds (0 = no randomization)
* @return cJSON object representing the gift wrap event, or NULL on error
*/
```
---
### Phase 3: Update NIP-17 Integration
#### 3.1 Update nostr_nip17_send_dm() Function
**File:** `nostr_core_lib/nostr_core/nip017.c`
**Current Location:** Lines 260-320
**Function Signature Change:**
```c
// OLD:
int nostr_nip17_send_dm(cJSON* dm_event,
const char** recipient_pubkeys,
int num_recipients,
const unsigned char* sender_private_key,
cJSON** gift_wraps_out,
int max_gift_wraps);
// NEW:
int nostr_nip17_send_dm(cJSON* dm_event,
const char** recipient_pubkeys,
int num_recipients,
const unsigned char* sender_private_key,
cJSON** gift_wraps_out,
int max_gift_wraps,
long max_delay_sec);
```
**Code Changes:**
At line 281 (seal creation):
```c
// OLD:
cJSON* seal = nostr_nip59_create_seal(dm_event, sender_private_key, recipient_public_key);
// NEW:
cJSON* seal = nostr_nip59_create_seal(dm_event, sender_private_key, recipient_public_key, max_delay_sec);
```
At line 287 (gift wrap creation):
```c
// OLD:
cJSON* gift_wrap = nostr_nip59_create_gift_wrap(seal, recipient_pubkeys[i]);
// NEW:
cJSON* gift_wrap = nostr_nip59_create_gift_wrap(seal, recipient_pubkeys[i], max_delay_sec);
```
At line 306 (sender seal creation):
```c
// OLD:
cJSON* sender_seal = nostr_nip59_create_seal(dm_event, sender_private_key, sender_public_key);
// NEW:
cJSON* sender_seal = nostr_nip59_create_seal(dm_event, sender_private_key, sender_public_key, max_delay_sec);
```
At line 309 (sender gift wrap creation):
```c
// OLD:
cJSON* sender_gift_wrap = nostr_nip59_create_gift_wrap(sender_seal, sender_pubkey_hex);
// NEW:
cJSON* sender_gift_wrap = nostr_nip59_create_gift_wrap(sender_seal, sender_pubkey_hex, max_delay_sec);
```
#### 3.2 Update nip017.h Header
**File:** `nostr_core_lib/nostr_core/nip017.h`
**Location:** Lines 102-107
**Update Function Declaration:**
```c
int nostr_nip17_send_dm(cJSON* dm_event,
const char** recipient_pubkeys,
int num_recipients,
const unsigned char* sender_private_key,
cJSON** gift_wraps_out,
int max_gift_wraps,
long max_delay_sec);
```
**Update Documentation Comment (lines 88-100):**
```c
/**
* NIP-17: Send a direct message to recipients
*
* This function creates the appropriate rumor, seals it, gift wraps it,
* and returns the final gift wrap events ready for publishing.
*
* @param dm_event The unsigned DM event (kind 14 or 15)
* @param recipient_pubkeys Array of recipient public keys (hex strings)
* @param num_recipients Number of recipients
* @param sender_private_key 32-byte sender private key
* @param gift_wraps_out Array to store resulting gift wrap events (caller must free)
* @param max_gift_wraps Maximum number of gift wraps to create
* @param max_delay_sec Maximum random timestamp delay in seconds (0 = no randomization)
* @return Number of gift wrap events created, or -1 on error
*/
```
---
### Phase 4: Update c-relay-pg Call Sites
#### 4.1 Update src/api.c
**Location:** Line 1319
**Current Code:**
```c
int send_result = nostr_nip17_send_dm(
dm_response, // dm_event
recipient_pubkeys, // recipient_pubkeys
1, // num_recipients
relay_privkey, // sender_private_key
gift_wraps, // gift_wraps_out
1 // max_gift_wraps
);
```
**New Code:**
```c
// Get timestamp delay configuration
long max_delay_sec = get_config_int("nip59_timestamp_max_delay_sec", 0);
int send_result = nostr_nip17_send_dm(
dm_response, // dm_event
recipient_pubkeys, // recipient_pubkeys
1, // num_recipients
relay_privkey, // sender_private_key
gift_wraps, // gift_wraps_out
1, // max_gift_wraps
max_delay_sec // max_delay_sec
);
```
#### 4.2 Update src/dm_admin.c
**Location:** Line 371
**Current Code:**
```c
int send_result = nostr_nip17_send_dm(
success_dm, // dm_event
sender_pubkey_array, // recipient_pubkeys
1, // num_recipients
relay_privkey, // sender_private_key
success_gift_wraps, // gift_wraps_out
1 // max_gift_wraps
);
```
**New Code:**
```c
// Get timestamp delay configuration
long max_delay_sec = get_config_int("nip59_timestamp_max_delay_sec", 0);
int send_result = nostr_nip17_send_dm(
success_dm, // dm_event
sender_pubkey_array, // recipient_pubkeys
1, // num_recipients
relay_privkey, // sender_private_key
success_gift_wraps, // gift_wraps_out
1, // max_gift_wraps
max_delay_sec // max_delay_sec
);
```
**Note:** Both files already include `config.h`, so `get_config_int()` is available.
---
## Testing Plan
### Test Case 1: No Randomization (Compatibility Mode)
**Configuration:** `nip59_timestamp_max_delay_sec = 0`
**Expected Behavior:**
- Gift wrap timestamps should equal current time
- Seal timestamps should equal current time
- No random delay applied
**Test Command:**
```bash
# Set config via admin API
# Send test DM
# Verify timestamps are current (within 1 second of send time)
```
### Test Case 2: Custom Delay
**Configuration:** `nip59_timestamp_max_delay_sec = 1000`
**Expected Behavior:**
- Gift wrap timestamps should be between now and 1000 seconds ago
- Seal timestamps should be between now and 1000 seconds ago
- Random delay applied within specified range
**Test Command:**
```bash
# Set config via admin API
# Send test DM
# Verify timestamps are in past but within 1000 seconds
```
### Test Case 3: Default Behavior
**Configuration:** `nip59_timestamp_max_delay_sec = 0` (default)
**Expected Behavior:**
- Gift wrap timestamps should equal current time
- Seal timestamps should equal current time
- No randomization (maximum compatibility)
**Test Command:**
```bash
# Use default config
# Send test DM
# Verify timestamps are current (within 1 second of send time)
```
### Test Case 4: Configuration Validation
**Test Invalid Values:**
- Negative value: Should be rejected
- Value > 604800: Should be rejected
- Valid boundary values (0, 604800): Should be accepted
### Test Case 5: Interoperability
**Test with Other Nostr Clients:**
- Send DM with `max_delay_sec = 0` to clients that don't randomize
- Send DM with `max_delay_sec = 172800` to clients that do randomize
- Verify both scenarios work correctly
---
## Documentation Updates
### Update docs/configuration_guide.md
Add new section:
```markdown
### NIP-59 Gift Wrap Timestamp Configuration
#### nip59_timestamp_max_delay_sec
- **Type:** Integer
- **Default:** 0 (no randomization)
- **Range:** 0 to 604800 (7 days)
- **Description:** Controls timestamp randomization for NIP-59 gift wraps
The NIP-59 protocol recommends randomizing timestamps on gift wraps to prevent
time-analysis attacks. However, some Nostr platforms don't implement this,
causing compatibility issues.
**Values:**
- `0` (default): No randomization - uses current timestamp (maximum compatibility)
- `1-604800`: Random timestamp between now and N seconds ago
**Use Cases:**
- Keep default `0` for maximum compatibility with clients that don't randomize
- Set to `172800` for privacy per NIP-59 specification (2 days randomization)
- Set to custom value (e.g., `3600`) for 1-hour randomization window
**Example:**
```json
["nip59_timestamp_max_delay_sec", "0"] // Default: compatibility mode
["nip59_timestamp_max_delay_sec", "3600"] // 1 hour randomization
["nip59_timestamp_max_delay_sec", "172800"] // 2 days randomization
```
```
---
## Implementation Checklist
### nostr_core_lib Changes
- [ ] Modify `random_past_timestamp()` to accept `max_delay_sec` parameter
- [ ] Update `nostr_nip59_create_seal()` signature and implementation
- [ ] Update `nostr_nip59_create_gift_wrap()` signature and implementation
- [ ] Update `nip059.h` function declarations and documentation
- [ ] Update `nostr_nip17_send_dm()` signature and implementation
- [ ] Update `nip017.h` function declaration and documentation
### c-relay-pg Changes
- [ ] Add `nip59_timestamp_max_delay_sec` to `default_config_event.h`
- [ ] Add validation in `config.c` for new parameter
- [ ] Update `src/api.c` call site to pass `max_delay_sec`
- [ ] Update `src/dm_admin.c` call site to pass `max_delay_sec`
### Testing
- [ ] Test with `max_delay_sec = 0` (no randomization)
- [ ] Test with `max_delay_sec = 1000` (custom delay)
- [ ] Test with `max_delay_sec = 172800` (default behavior)
- [ ] Test configuration validation (invalid values)
- [ ] Test interoperability with other Nostr clients
### Documentation
- [ ] Update `docs/configuration_guide.md`
- [ ] Add this implementation plan to docs
- [ ] Update README if needed
---
## Rollback Plan
If issues arise:
1. Revert nostr_core_lib changes (git revert in submodule)
2. Revert c-relay-pg changes
3. Configuration parameter will be ignored if not used
4. Default behavior (0) provides maximum compatibility
---
## Notes
- The configuration is read on each DM send, allowing runtime changes
- No restart required when changing `nip59_timestamp_max_delay_sec`
- Thread-safe by design (no global state)
- Default value of 0 provides maximum compatibility with other Nostr clients
- Can be changed to 172800 or other values for NIP-59 privacy features
---
## References
- [NIP-59: Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md)
- [NIP-17: Private Direct Messages](https://github.com/nostr-protocol/nips/blob/master/17.md)
- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md)

Some files were not shown because too many files have changed in this diff Show More