Compare commits

..

28 Commits

Author SHA1 Message Date
laan tungir
5f08956605 removed some files 2025-11-03 07:33:12 -05:00
Your Name
643d89ed7b v1.0.0 - First major release 2025-11-01 07:04:56 -04:00
Your Name
8ca459593c Reset version back to v0.8.6 2025-11-01 07:03:39 -04:00
Your Name
ee4208cc19 v7.0.0 - Version 1.0.0 2025-11-01 06:56:02 -04:00
Your Name
f6330e4bb8 v7.0.0 - Version 1.0.0 2025-10-31 11:17:34 -04:00
Your Name
4f3cf10a5c v6.0.0 - Test fixed output redirection 2025-10-31 11:17:19 -04:00
Your Name
aa1954e81e v6.0.0 - Version 1.0.0 2025-10-31 11:15:32 -04:00
Your Name
482597bd0e v5.0.0 - Test fixed release ID extraction 2025-10-31 11:15:16 -04:00
Your Name
d4b90e681c v5.0.0 - Version 1.0.0 2025-10-31 11:13:41 -04:00
Your Name
fcf9e43c4c v4.0.0 - Debug upload with existing binary 2025-10-31 11:13:21 -04:00
Your Name
b8d8cd19d3 v4.0.0 - Debug upload issue 2025-10-31 11:12:58 -04:00
Your Name
536c2d966c v4.0.0 - Version 1.0.0 2025-10-31 11:11:31 -04:00
Your Name
f49cb0a5ac v3.0.0 - Test release upload fix 2025-10-31 11:10:53 -04:00
Your Name
cef6bb2340 v3.0.0 - Version 1.0.0 2025-10-31 11:08:18 -04:00
Your Name
4c03253b30 v2.0.0 - Version 1.0.0 2025-10-31 11:06:27 -04:00
Your Name
ed09bb7370 v1.0.0 - Version 1.0.0) 2025-10-31 10:39:06 -04:00
Your Name
5c46a25173 v0.8.5 - cleanup 2025-10-30 07:06:48 -04:00
Your Name
d1538f00df v0.8.4 - Updated increment_and_push.sh 2025-10-30 07:03:22 -04:00
Your Name
afa4acbbfb v0.8.3 - --dry-run 2025-10-30 06:52:05 -04:00
Your Name
d9a530485f v0.8.2 - markdown intro 2025-10-29 07:53:56 -04:00
Your Name
b2ad70b028 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
46 changed files with 3408 additions and 2274 deletions

65
NOSTR_RELEASE.md Normal file
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/raw/branch/master/screenshots/main.png)
Main page with real time updates.
![](https://git.laantungir.net/laantungir/c-relay/raw/branch/master/screenshots/config.png)
Set your configuration preferences.
![](https://git.laantungir.net/laantungir/c-relay/raw/branch/master/screenshots/subscriptions.png)
View current subscriptions
![](https://git.laantungir.net/laantungir/c-relay/raw/branch/master/screenshots/white-blacklists.png)
Add npubs to white or black lists.
![](https://git.laantungir.net/laantungir/c-relay/raw/branch/master/screenshots/sqlQuery.png)
Run sql queries on the database.
![](https://git.laantungir.net/laantungir/c-relay/raw/branch/master/screenshots/main-light.png)
Light mode.

View File

@@ -195,6 +195,9 @@ All commands are sent as NIP-44 encrypted JSON arrays in the event content. The
- `pow_min_difficulty`: Minimum proof-of-work difficulty - `pow_min_difficulty`: Minimum proof-of-work difficulty
- `nip40_expiration_enabled`: Enable event expiration (`true`/`false`) - `nip40_expiration_enabled`: Enable event expiration (`true`/`false`)
**Monitoring Settings:**
- `kind_24567_reporting_throttle_sec`: Minimum seconds between monitoring events (default: 5)
### Dynamic Configuration Updates ### Dynamic Configuration Updates
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). 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).
@@ -391,6 +394,68 @@ SELECT
## Real-time Monitoring System
C-Relay includes a subscription-based monitoring system that broadcasts real-time relay statistics using ephemeral events (kind 24567).
### Activation
The monitoring system activates automatically when clients subscribe to kind 24567 events:
```json
["REQ", "monitoring-sub", {"kinds": [24567]}]
```
For specific monitoring types, use d-tag filters:
```json
["REQ", "event-kinds-sub", {"kinds": [24567], "#d": ["event_kinds"]}]
["REQ", "time-stats-sub", {"kinds": [24567], "#d": ["time_stats"]}]
["REQ", "top-pubkeys-sub", {"kinds": [24567], "#d": ["top_pubkeys"]}]
```
When no subscriptions exist, monitoring is dormant to conserve resources.
### Monitoring Event Types
| Type | d Tag | Description |
|------|-------|-------------|
| Event Distribution | `event_kinds` | Event count by kind with percentages |
| Time Statistics | `time_stats` | Events in last 24h, 7d, 30d |
| Top Publishers | `top_pubkeys` | Top 10 pubkeys by event count |
| Active Subscriptions | `active_subscriptions` | Current subscription details (admin only) |
| Subscription Details | `subscription_details` | Detailed subscription info (admin only) |
| CPU Metrics | `cpu_metrics` | Process CPU and memory usage |
### Event Structure
```json
{
"kind": 24567,
"pubkey": "<relay_pubkey>",
"created_at": <timestamp>,
"content": "{\"data_type\":\"event_kinds\",\"timestamp\":1234567890,...}",
"tags": [
["d", "event_kinds"]
]
}
```
### Configuration
- `kind_24567_reporting_throttle_sec`: Minimum seconds between monitoring events (default: 5)
### Web Dashboard Integration
The built-in web dashboard (`/api/`) automatically subscribes to monitoring events and displays real-time statistics.
### Performance Considerations
- Monitoring events are ephemeral (not stored in database)
- Throttling prevents excessive event generation
- Automatic activation/deactivation based on subscriptions
- Minimal overhead when no clients are monitoring
## Direct Messaging Admin System ## 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. 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.

View File

@@ -285,7 +285,7 @@ h1 {
border-bottom: var(--border-width) solid var(--border-color); border-bottom: var(--border-width) solid var(--border-color);
padding-bottom: 10px; padding-bottom: 10px;
margin-bottom: 30px; margin-bottom: 30px;
font-weight: normal; font-weight: bold;
font-size: 24px; font-size: 24px;
font-family: var(--font-family); font-family: var(--font-family);
color: var(--primary-color); color: var(--primary-color);
@@ -293,12 +293,32 @@ h1 {
h2 { h2 {
font-weight: normal; font-weight: normal;
padding-left: 10px; text-align: center;
font-size: 16px; font-size: 16px;
font-family: var(--font-family); font-family: var(--font-family);
color: var(--primary-color); color: var(--primary-color);
} }
h3 {
font-weight: normal;
font-size: 12px;
font-family: var(--font-family);
color: var(--primary-color);
padding-bottom: 10px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: lighter;
font-size: 10px;
font-family: var(--font-family);
color: var(--primary-color);
}
.section { .section {
background: var(--secondary-color); background: var(--secondary-color);
border: var(--border-width) solid var(--border-color); border: var(--border-width) solid var(--border-color);
@@ -309,18 +329,21 @@ h2 {
margin-right:5px; margin-right:5px;
} }
.section-header {
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 15px;
}
.input-group { .input-group {
margin-bottom: 15px; margin-bottom: 15px;
} }
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
font-size: 14px;
font-family: var(--font-family);
color: var(--primary-color);
}
input, input,
textarea, textarea,
@@ -493,6 +516,24 @@ button:disabled {
border-radius: 0; border-radius: 0;
} }
/* Relay Events Styles */
.status-message {
margin-top: 10px;
padding: 8px;
border-radius: var(--border-radius);
font-size: 14px;
font-family: var(--font-family);
text-align: center;
}
.relay-entry {
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
padding: 10px;
margin-bottom: 10px;
background: var(--secondary-color);
}
.config-value-input:focus { .config-value-input:focus {
border: 1px solid var(--accent-color); border: 1px solid var(--accent-color);
background: var(--secondary-color); background: var(--secondary-color);
@@ -662,14 +703,7 @@ button:disabled {
display: none; display: none;
} }
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
/* margin-bottom: 15px; */
/* border-bottom: var(--border-width) solid var(--border-color); */
/* padding-bottom: 10px; */
}
.countdown-btn { .countdown-btn {
width: auto; width: auto;
@@ -1227,3 +1261,50 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
.header-title.clickable:hover { .header-title.clickable:hover {
opacity: 0.8; opacity: 0.8;
} }
/* ================================
SUBSCRIPTION TABLE COLLAPSIBLE GROUPS
================================ */
/* Subscription group header styles */
.subscription-group-header {
font-weight: 500;
cursor: pointer;
user-select: none;
}
.subscription-group-header:hover {
background-color: var(--secondary-color);
}
.expand-icon {
display: inline-block;
width: 20px;
transition: transform 0.2s ease;
font-size: 12px;
}
/* Detail row styles */
.subscription-detail-row {
/* background-color: var(--secondary-color); */
}
.subscription-detail-row:hover {
background-color: var(--muted-color);
}
/* Detail row cell styles */
.subscription-detail-prefix {
padding-left: 30px;
font-family: 'Courier New', monospace;
font-size: 11px;
color: var(--muted-color);
}
.subscription-detail-id {
font-family: 'Courier New', monospace;
font-size: 12px;
}

View File

@@ -16,6 +16,7 @@
<li><button class="nav-item" data-page="subscriptions">Subscriptions</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="configuration">Configuration</button></li>
<li><button class="nav-item" data-page="authorization">Authorization</button></li> <li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="relay-events">Relay Events</button></li>
<li><button class="nav-item" data-page="dm">DM</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> <li><button class="nav-item" data-page="database">Database Query</button></li>
</ul> </ul>
@@ -67,11 +68,10 @@
</div> </div>
<!-- DATABASE STATISTICS Section --> <!-- 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 flex-section" id="databaseStatisticsSection" style="display: none;">
<div class="section-header"> <div class="section-header">
<h2>DATABASE STATISTICS</h2> DATABASE STATISTICS
<!-- Monitoring is now subscription-based - no toggle button needed -->
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
</div> </div>
<!-- Event Rate Graph Container --> <!-- Event Rate Graph Container -->
@@ -206,24 +206,15 @@
<!-- SUBSCRIPTION DETAILS Section (Admin Only) --> <!-- SUBSCRIPTION DETAILS Section (Admin Only) -->
<div class="section flex-section" id="subscriptionDetailsSection" style="display: none;"> <div class="section flex-section" id="subscriptionDetailsSection" style="display: none;">
<div class="section-header"> <div class="section-header">
<h2>ACTIVE SUBSCRIPTION DETAILS</h2> ACTIVE SUBSCRIPTION DETAILS
</div> </div>
<div class="input-group"> <div class="input-group">
<div class="config-table-container"> <div class="config-table-container">
<table class="config-table" id="subscription-details-table"> <table class="config-table" id="subscription-details-table">
<thead>
<tr>
<th>Subscription ID</th>
<th>Client IP</th>
<th>WSI Pointer</th>
<th>Duration</th>
<th>Filters</th>
</tr>
</thead>
<tbody id="subscription-details-table-body"> <tbody id="subscription-details-table-body">
<tr> <tr>
<td colspan="5" style="text-align: center; font-style: italic;">No subscriptions active</td> <td colspan="4" style="text-align: center; font-style: italic;">No subscriptions active</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -233,7 +224,9 @@
<!-- Testing Section --> <!-- Testing Section -->
<div id="div_config" class="section flex-section" style="display: none;"> <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 id="config-display" class="hidden">
<div class="config-table-container"> <div class="config-table-container">
<table class="config-table" id="config-table"> <table class="config-table" id="config-table">
@@ -260,7 +253,7 @@
<!-- Auth Rules Management - Moved after configuration --> <!-- Auth Rules Management - Moved after configuration -->
<div class="section flex-section" id="authRulesSection" style="display: none;"> <div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header"> <div class="section-header">
<h2>AUTH RULES MANAGEMENT</h2> AUTH RULES MANAGEMENT
</div> </div>
<!-- Auth Rules Table --> <!-- Auth Rules Table -->
@@ -322,7 +315,7 @@
</div> </div>
<!-- Outbox --> <!-- Outbox -->
<div class="input-group"> <div>
<label for="dm-outbox">Send Message to Relay:</label> <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> <textarea id="dm-outbox" rows="4" placeholder="Enter your message to send to the relay..."></textarea>
</div> </div>
@@ -341,6 +334,72 @@
</div> </div>
</div> </div>
<!-- RELAY EVENTS Section -->
<div class="section" id="relayEventsSection" style="display: none;">
<div class="section-header">
RELAY EVENTS MANAGEMENT
</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>
<!-- SQL QUERY Section --> <!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;"> <div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header"> <div class="section-header">

View File

@@ -46,6 +46,8 @@ let pendingSqlQueries = new Map();
let eventRateChart = null; let eventRateChart = null;
let previousTotalEvents = 0; // Track previous total for rate calculation let previousTotalEvents = 0; // Track previous total for rate calculation
// Relay Events state - now handled by main subscription
// DOM elements // DOM elements
const loginModal = document.getElementById('login-modal'); const loginModal = document.getElementById('login-modal');
const loginModalContainer = document.getElementById('login-modal-container'); const loginModalContainer = document.getElementById('login-modal-container');
@@ -91,10 +93,11 @@ function log(message, type = 'INFO') {
// NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days) // NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days)
// TEMPORARILY DISABLED: Using current timestamp for debugging
function randomNow() { function randomNow() {
const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds // const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
const now = Math.round(Date.now() / 1000); const now = Math.round(Date.now() / 1000);
return Math.round(now - Math.random() * TWO_DAYS); return now; // Math.round(now - Math.random() * TWO_DAYS);
} }
// Safe JSON parse with error handling // Safe JSON parse with error handling
@@ -1049,8 +1052,8 @@ async function subscribeToConfiguration() {
// Mark as subscribed BEFORE calling subscribeMany to prevent race conditions // Mark as subscribed BEFORE calling subscribeMany to prevent race conditions
isSubscribed = true; isSubscribed = true;
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), and kind 24567 (ephemeral monitoring events) // Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), kind 24567 (ephemeral monitoring events), and relay events (kinds 0, 10050, 10002)
console.log('🔔 Calling relayPool.subscribeMany...'); console.log('🔔 Calling relayPool.subscribeMany with all filters...');
const subscription = relayPool.subscribeMany([url], [{ const subscription = relayPool.subscribeMany([url], [{
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
kinds: [23457], kinds: [23457],
@@ -1072,8 +1075,13 @@ async function subscribeToConfiguration() {
since: Math.floor(Date.now() / 1000), // Start from current time since: Math.floor(Date.now() / 1000), // Start from current time
kinds: [24567], // Real-time ephemeral monitoring events kinds: [24567], // Real-time ephemeral monitoring events
authors: [getRelayPubkey()], // Only listen to monitoring events from the relay authors: [getRelayPubkey()], // Only listen to monitoring events from the relay
"#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "subscription_details", "cpu_metrics"] : ["event_kinds", "time_stats", "top_pubkeys", "active_subscriptions", "cpu_metrics"], // Include subscription_details only when authenticated, cpu_metrics available to all "#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "subscription_details", "cpu_metrics"] : ["event_kinds", "time_stats", "top_pubkeys", "cpu_metrics"], // Include subscription_details only when authenticated, cpu_metrics available to all
limit: 50 limit: 50
}, {
since: Math.floor(Date.now() / 1000) - (24 * 60 * 60), // Look back 24 hours for relay events
kinds: [0, 10050, 10002], // Relay events: metadata, DM relays, relay list
authors: [getRelayPubkey()], // Only listen to relay's own events
limit: 10
}], { }], {
async onevent(event) { async onevent(event) {
// Simplified logging - one line per event // Simplified logging - one line per event
@@ -1174,6 +1182,11 @@ async function subscribeToConfiguration() {
// Process monitoring event (logging done above) // Process monitoring event (logging done above)
processMonitoringEvent(event); processMonitoringEvent(event);
} }
// Handle relay events (kinds 0, 10050, 10002)
if ([0, 10050, 10002].includes(event.kind)) {
handleRelayEventReceived(event);
}
}, },
oneose() { oneose() {
console.log('EOSE received - End of stored events'); console.log('EOSE received - End of stored events');
@@ -1238,11 +1251,21 @@ async function processAdminResponse(event) {
throw new Error('Failed to decrypt admin response content'); throw new Error('Failed to decrypt admin response content');
} }
console.log('Decrypted admin response:', decryptedContent); // console.log('Decrypted admin response:', decryptedContent);
// Parse the decrypted JSON response // Try to parse as JSON first, if it fails treat as plain text
const responseData = JSON.parse(decryptedContent); let responseData;
try {
responseData = JSON.parse(decryptedContent);
console.log('Parsed response data:', responseData); console.log('Parsed response data:', responseData);
} catch (parseError) {
// Not JSON - treat as plain text response
console.log('Response is plain text, not JSON');
responseData = {
plain_text: true,
message: decryptedContent
};
}
// Log the response for testing // Log the response for testing
if (typeof logTestEvent === 'function') { if (typeof logTestEvent === 'function') {
@@ -1400,14 +1423,15 @@ async function processMonitoringEvent(event) {
updateStatsFromTopPubkeysMonitoringEvent(monitoringData); updateStatsFromTopPubkeysMonitoringEvent(monitoringData);
break; break;
case 'active_subscriptions':
updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData);
break;
case 'subscription_details': case 'subscription_details':
// Only process subscription details if user is authenticated // Only process subscription details if user is authenticated
if (isLoggedIn) { if (isLoggedIn) {
updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData); updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData);
// Also update the active subscriptions count from this data
if (monitoringData.data && monitoringData.data.subscriptions) {
updateStatsCell('active-subscriptions', monitoringData.data.subscriptions.length.toString());
}
} }
break; break;
@@ -1432,6 +1456,25 @@ function handleAdminResponseData(responseData) {
console.log('Response data:', responseData); console.log('Response data:', responseData);
console.log('Response query_type:', responseData.query_type); console.log('Response query_type:', responseData.query_type);
// Handle plain text responses (from create_relay_event and other commands)
if (responseData.plain_text) {
console.log('Handling plain text response');
log(responseData.message, 'INFO');
// Show the message in relay events status if we're on that page
if (currentPage === 'relay-events') {
// Try to determine which kind based on message content
if (responseData.message.includes('Kind: 0')) {
showStatus('kind0-status', responseData.message, 'success');
} else if (responseData.message.includes('Kind: 10050')) {
showStatus('kind10050-status', responseData.message, 'success');
} else if (responseData.message.includes('Kind: 10002')) {
showStatus('kind10002-status', responseData.message, 'success');
}
}
return;
}
// Handle auth query responses - updated to match backend response types // Handle auth query responses - updated to match backend response types
if (responseData.query_type && if (responseData.query_type &&
(responseData.query_type.includes('auth_rules') || (responseData.query_type.includes('auth_rules') ||
@@ -3991,9 +4034,9 @@ function updateStatsFromTimeMonitoringEvent(monitoringData) {
// Extract values from periods array // Extract values from periods array
monitoringData.periods.forEach(period => { monitoringData.periods.forEach(period => {
if (period.period === '24h') timeStats.last_24h = period.count; if (period.period === 'last_24h') timeStats.last_24h = period.count;
else if (period.period === '7d') timeStats.last_7d = period.count; else if (period.period === 'last_7d') timeStats.last_7d = period.count;
else if (period.period === '30d') timeStats.last_30d = period.count; else if (period.period === 'last_30d') timeStats.last_30d = period.count;
}); });
populateStatsTime({ time_stats: timeStats }); populateStatsTime({ time_stats: timeStats });
@@ -4022,27 +4065,14 @@ function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
} }
} }
// Update statistics display from active_subscriptions monitoring event
function updateStatsFromActiveSubscriptionsMonitoringEvent(monitoringData) {
try {
if (monitoringData.data_type !== 'active_subscriptions') {
return;
}
// Update active subscriptions cell with real-time data
// The data is nested under monitoringData.data.total_subscriptions
if (monitoringData.data && monitoringData.data.total_subscriptions !== undefined) {
updateStatsCell('active-subscriptions', monitoringData.data.total_subscriptions.toString());
}
} catch (error) {
log(`Error updating active subscriptions from monitoring event: ${error.message}`, 'ERROR');
}
}
// Update statistics display from subscription_details monitoring event // Update statistics display from subscription_details monitoring event
function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) { function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) {
try { try {
// DEBUG: Log every subscription_details event that arrives at the webpage
// console.log('subscription_details', JSON.stringify(monitoringData, null, 2));
console.log('subscription_details decoded:', monitoringData);
if (monitoringData.data_type !== 'subscription_details') { if (monitoringData.data_type !== 'subscription_details') {
return; return;
} }
@@ -4249,42 +4279,95 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body'); const tableBody = document.getElementById('subscription-details-table-body');
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return; if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
// Store current expand/collapse state before rebuilding
const expandedGroups = new Set();
const headerRows = tableBody.querySelectorAll('.subscription-group-header');
headerRows.forEach(header => {
const wsiPointer = header.getAttribute('data-wsi-pointer');
const isExpanded = header.getAttribute('data-expanded') === 'true';
if (isExpanded) {
expandedGroups.add(wsiPointer);
}
});
tableBody.innerHTML = ''; tableBody.innerHTML = '';
if (subscriptionsData.length === 0) { if (subscriptionsData.length === 0) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = '<td colspan="5" style="text-align: center; font-style: italic;">No active subscriptions</td>'; row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
tableBody.appendChild(row); tableBody.appendChild(row);
return; return;
} }
subscriptionsData.forEach((subscription, index) => { // Sort subscriptions by wsi_pointer to group them together
const row = document.createElement('tr'); subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
// Create rows for each group
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
// Calculate group summary
const subCount = subscriptions.length;
const now = Math.floor(Date.now() / 1000);
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
const wasExpanded = expandedGroups.has(wsiPointer);
headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false');
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">Websocket: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
</span>
</td>
`;
// Add click handler to toggle expansion
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
tableBody.appendChild(headerRow);
// Create detail rows (initially hidden)
subscriptions.forEach((subscription, index) => {
const detailRow = document.createElement('tr');
detailRow.className = 'subscription-detail-row';
detailRow.setAttribute('data-wsi-group', wsiPointer);
detailRow.style.display = 'none';
// Calculate duration // Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at; const duration = now - subscription.created_at;
const durationStr = formatDuration(duration); const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view) // Format filters
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer (show full pointer)
const wsiPointer = subscription.wsi_pointer || 'N/A';
// Format filters (show actual filter details)
let filtersDisplay = 'None'; let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) { if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = []; const filterDetails = [];
subscription.filters.forEach((filter, index) => { subscription.filters.forEach((filter) => {
const parts = []; const parts = [];
// Add kinds if present
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) { if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`); parts.push(`kinds:[${filter.kinds.join(',')}]`);
} }
// Add authors if present (truncate for display)
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) { if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length; const authorCount = filter.authors.length;
if (authorCount === 1) { if (authorCount === 1) {
@@ -4295,13 +4378,11 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
} }
} }
// Add ids if present
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) { if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length; const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`); parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
} }
// Add time range if present
const timeParts = []; const timeParts = [];
if (filter.since && filter.since > 0) { if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString(); const sinceDate = new Date(filter.since * 1000).toLocaleString();
@@ -4315,12 +4396,10 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
parts.push(timeParts.join(', ')); parts.push(timeParts.join(', '));
} }
// Add limit if present
if (filter.limit && filter.limit > 0) { if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`); parts.push(`limit:${filter.limit}`);
} }
// Add tag filters if present
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) { if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`); parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
} }
@@ -4335,15 +4414,50 @@ function populateSubscriptionDetailsTable(subscriptionsData) {
filtersDisplay = filterDetails.join(' | '); filtersDisplay = filterDetails.join(' | ');
} }
row.innerHTML = ` detailRow.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td> <td class="subscription-detail-prefix">└─</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> <td class="subscription-detail-id">${subscription.id || 'N/A'}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td> <td class="subscription-detail-duration">${durationStr}</td>
<td>${durationStr}</td> <td class="subscription-detail-filters">${filtersDisplay}</td>
<td>${filtersDisplay}</td>
`; `;
tableBody.appendChild(row);
tableBody.appendChild(detailRow);
// Restore expand/collapse state after adding all rows
if (wasExpanded) {
const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
detailRows.forEach(row => row.style.display = 'table-row');
const expandIcon = headerRow.querySelector('.expand-icon');
if (expandIcon) {
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
}
}
}); });
});
}
// Toggle function for expanding/collapsing groups
function toggleSubscriptionGroup(wsiPointer) {
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
const expandIcon = headerRow.querySelector('.expand-icon');
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
if (isExpanded) {
// Collapse
detailRows.forEach(row => row.style.display = 'none');
expandIcon.textContent = '▶';
expandIcon.style.transform = 'rotate(0deg)';
headerRow.setAttribute('data-expanded', 'false');
} else {
// Expand
detailRows.forEach(row => row.style.display = 'table-row');
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
headerRow.setAttribute('data-expanded', 'true');
}
} }
// Helper function to format duration in human-readable format // Helper function to format duration in human-readable format
@@ -4603,6 +4717,7 @@ function switchPage(pageName) {
'subscriptionDetailsSection', 'subscriptionDetailsSection',
'div_config', 'div_config',
'authRulesSection', 'authRulesSection',
'relayEventsSection',
'nip17DMSection', 'nip17DMSection',
'sqlQuerySection' 'sqlQuerySection'
]; ];
@@ -4620,6 +4735,7 @@ function switchPage(pageName) {
'subscriptions': 'subscriptionDetailsSection', 'subscriptions': 'subscriptionDetailsSection',
'configuration': 'div_config', 'configuration': 'div_config',
'authorization': 'authRulesSection', 'authorization': 'authRulesSection',
'relay-events': 'relayEventsSection',
'dm': 'nip17DMSection', 'dm': 'nip17DMSection',
'database': 'sqlQuerySection' 'database': 'sqlQuerySection'
}; };
@@ -4748,7 +4864,7 @@ const SQL_QUERY_TEMPLATES = {
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC", subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view", top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC", event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT * FROM time_stats_view" time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)"
}; };
// Query history management (localStorage) // Query history management (localStorage)
@@ -5015,13 +5131,15 @@ function displaySqlQueryResults(response) {
// Handle SQL query response (called by event listener) // Handle SQL query response (called by event listener)
function handleSqlQueryResponse(response) { function handleSqlQueryResponse(response) {
// Check if this is a response to one of our queries console.log('=== HANDLING SQL QUERY RESPONSE ===');
if (response.request_id && pendingSqlQueries.has(response.request_id)) { console.log('Response:', response);
const queryInfo = pendingSqlQueries.get(response.request_id);
pendingSqlQueries.delete(response.request_id);
// Display results // Always display SQL query results when received
displaySqlQueryResults(response); displaySqlQueryResults(response);
// Clean up any pending queries
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
pendingSqlQueries.delete(response.request_id);
} }
} }
@@ -5240,6 +5358,355 @@ function initializeToggleButtonsFromConfig(configData) {
log('Monitoring system initialized - subscription-based activation ready', 'INFO'); log('Monitoring system initialized - subscription-based activation ready', 'INFO');
} }
// ================================
// RELAY EVENTS FUNCTIONS
// ================================
// Handle received relay events
function handleRelayEventReceived(event) {
console.log('Handling relay event:', event.kind, event);
switch (event.kind) {
case 0:
populateKind0Form(event);
break;
case 10050:
populateKind10050Form(event);
break;
case 10002:
populateKind10002Form(event);
break;
default:
console.log('Unknown relay event kind:', event.kind);
}
}
// Populate Kind 0 form (User Metadata)
function populateKind0Form(event) {
try {
const metadata = JSON.parse(event.content);
console.log('Populating Kind 0 form with:', metadata);
// Update form fields
const nameField = document.getElementById('kind0-name');
const aboutField = document.getElementById('kind0-about');
const pictureField = document.getElementById('kind0-picture');
const bannerField = document.getElementById('kind0-banner');
const nip05Field = document.getElementById('kind0-nip05');
const websiteField = document.getElementById('kind0-website');
if (nameField) nameField.value = metadata.name || '';
if (aboutField) aboutField.value = metadata.about || '';
if (pictureField) pictureField.value = metadata.picture || '';
if (bannerField) bannerField.value = metadata.banner || '';
if (nip05Field) nip05Field.value = metadata.nip05 || '';
if (websiteField) websiteField.value = metadata.website || '';
showStatus('kind0-status', 'Metadata loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 0 form:', error);
showStatus('kind0-status', 'Error loading metadata', 'error');
}
}
// Populate Kind 10050 form (DM Relay List)
function populateKind10050Form(event) {
try {
console.log('Populating Kind 10050 form with tags:', event.tags);
// Extract relay URLs from "relay" tags
const relayUrls = event.tags
.filter(tag => tag[0] === 'relay' && tag[1])
.map(tag => tag[1]);
const relaysField = document.getElementById('kind10050-relays');
if (relaysField) {
relaysField.value = relayUrls.join('\n');
}
showStatus('kind10050-status', 'DM relay list loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 10050 form:', error);
showStatus('kind10050-status', 'Error loading DM relay list', 'error');
}
}
// Populate Kind 10002 form (Relay List)
function populateKind10002Form(event) {
try {
console.log('Populating Kind 10002 form with tags:', event.tags);
// Clear existing entries
const container = document.getElementById('kind10002-relay-entries');
if (container) {
container.innerHTML = '';
}
// Extract relay entries from "r" tags
event.tags.forEach(tag => {
if (tag[0] === 'r' && tag[1]) {
const url = tag[1];
const marker = tag[2] || 'read'; // Default to read if no marker
const read = marker.includes('read');
const write = marker.includes('write');
addRelayEntry(url, read, write);
}
});
showStatus('kind10002-status', 'Relay list loaded from relay', 'success');
} catch (error) {
console.error('Error populating Kind 10002 form:', error);
showStatus('kind10002-status', 'Error loading relay list', 'error');
}
}
// Submit Kind 0 event
async function submitKind0Event() {
try {
showStatus('kind0-status', 'Submitting metadata...', 'info');
// Collect form data
const metadata = {
name: document.getElementById('kind0-name').value.trim(),
about: document.getElementById('kind0-about').value.trim(),
picture: document.getElementById('kind0-picture').value.trim(),
banner: document.getElementById('kind0-banner').value.trim(),
nip05: document.getElementById('kind0-nip05').value.trim(),
website: document.getElementById('kind0-website').value.trim()
};
// Remove empty fields
Object.keys(metadata).forEach(key => {
if (!metadata[key]) delete metadata[key];
});
// Validate required fields
if (!metadata.name) {
showStatus('kind0-status', 'Name is required', 'error');
return;
}
await sendCreateRelayEventCommand(0, metadata);
showStatus('kind0-status', 'Metadata updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 0 event:', error);
showStatus('kind0-status', 'Error updating metadata: ' + error.message, 'error');
}
}
// Submit Kind 10050 event
async function submitKind10050Event() {
try {
showStatus('kind10050-status', 'Submitting DM relay list...', 'info');
// Parse textarea content
const relaysText = document.getElementById('kind10050-relays').value.trim();
const relays = relaysText.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0)
.filter(url => isValidRelayUrl(url));
if (relays.length === 0) {
showStatus('kind10050-status', 'At least one valid relay URL is required', 'error');
return;
}
await sendCreateRelayEventCommand(10050, { relays });
showStatus('kind10050-status', 'DM relay list updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 10050 event:', error);
showStatus('kind10050-status', 'Error updating DM relay list: ' + error.message, 'error');
}
}
// Submit Kind 10002 event
async function submitKind10002Event() {
try {
showStatus('kind10002-status', 'Submitting relay list...', 'info');
// Collect relay entries
const relays = [];
const entries = document.querySelectorAll('.relay-entry');
entries.forEach(entry => {
const url = entry.querySelector('.relay-url').value.trim();
const read = entry.querySelector('.relay-read').checked;
const write = entry.querySelector('.relay-write').checked;
if (url && isValidRelayUrl(url)) {
relays.push({
url: url,
read: read,
write: write
});
}
});
if (relays.length === 0) {
showStatus('kind10002-status', 'At least one valid relay entry is required', 'error');
return;
}
await sendCreateRelayEventCommand(10002, { relays });
showStatus('kind10002-status', 'Relay list updated successfully', 'success');
} catch (error) {
console.error('Error submitting Kind 10002 event:', error);
showStatus('kind10002-status', 'Error updating relay list: ' + error.message, 'error');
}
}
// Send create_relay_event command
async function sendCreateRelayEventCommand(kind, eventData) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to create relay events');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
try {
console.log(`Sending create_relay_event command for kind ${kind}...`);
// Create command array
const command_array = ["create_relay_event", kind, eventData];
// Encrypt the command array
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create kind 23456 admin event
const adminEvent = {
kind: 23456,
pubkey: userPubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [["p", getRelayPubkey()]],
content: encrypted_content
};
// Sign the event
const signedEvent = await window.nostr.signEvent(adminEvent);
if (!signedEvent || !signedEvent.sig) {
throw new Error('Event signing failed');
}
// Publish via SimplePool
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Wait for publish results
const results = await Promise.allSettled(publishPromises);
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
console.log(`✅ Relay event published successfully to relay ${index}`);
} else {
console.error(`❌ Relay event failed on relay ${index}:`, result.reason);
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected relay event. Details: ${errorDetails}`);
}
console.log(`Relay event command sent successfully for kind ${kind}`);
} catch (error) {
console.error(`Failed to send create_relay_event command for kind ${kind}:`, error);
throw error;
}
}
// Validation helpers
function isValidUrl(url) {
try {
new URL(url);
return true;
} catch {
return false;
}
}
function isValidRelayUrl(url) {
if (!isValidUrl(url)) return false;
return url.startsWith('ws://') || url.startsWith('wss://');
}
// UI helpers
function showStatus(elementId, message, type = 'info') {
const element = document.getElementById(elementId);
if (!element) return;
// Remove emojis from message
const cleanMessage = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
element.textContent = cleanMessage;
element.className = 'status-message';
element.style.display = 'block'; // Ensure it's visible
// Add type-specific styling
switch (type) {
case 'success':
element.style.color = 'var(--accent-color)';
break;
case 'error':
element.style.color = '#ff0000';
break;
case 'info':
default:
element.style.color = 'var(--primary-color)';
break;
}
// Auto-hide after 5 seconds
setTimeout(() => {
element.style.display = 'none';
}, 5000);
}
function addRelayEntry(url = '', read = true, write = true) {
const container = document.getElementById('kind10002-relay-entries');
if (!container) return;
const entryDiv = document.createElement('div');
entryDiv.className = 'relay-entry';
entryDiv.innerHTML = `
<div class="form-group" style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
<input type="url" class="relay-url" placeholder="wss://relay.example.com" value="${url}" style="flex: 1; min-width: 300px; pointer-events: auto; cursor: text;">
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
<input type="checkbox" class="relay-read" ${read ? 'checked' : ''}>
Read
</label>
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
<input type="checkbox" class="relay-write" ${write ? 'checked' : ''}>
Write
</label>
<button type="button" onclick="removeRelayEntry(this)" style="padding: 4px 8px; font-size: 12px; white-space: nowrap;">Remove</button>
</div>
`;
container.appendChild(entryDiv);
}
function removeRelayEntry(button) {
const entry = button.closest('.relay-entry');
if (entry) {
entry.remove();
}
}
// Initialize toggle button after DOM is ready // Initialize toggle button after DOM is ready
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ==='); console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ===');
@@ -5249,4 +5716,43 @@ document.addEventListener('DOMContentLoaded', function() {
console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ==='); console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ===');
initializeMonitoringToggleButton(); initializeMonitoringToggleButton();
}, 500); // Small delay to ensure DOM is fully ready }, 500); // Small delay to ensure DOM is fully ready
// Initialize relay events functionality
initializeRelayEvents();
}); });
// Initialize relay events functionality
function initializeRelayEvents() {
console.log('Initializing relay events functionality...');
// Set up event handlers for relay events page
const submitKind0Btn = document.getElementById('submit-kind0-btn');
const submitKind10050Btn = document.getElementById('submit-kind10050-btn');
const submitKind10002Btn = document.getElementById('submit-kind10002-btn');
const addRelayEntryBtn = document.getElementById('add-relay-entry-btn');
if (submitKind0Btn) {
submitKind0Btn.addEventListener('click', submitKind0Event);
}
if (submitKind10050Btn) {
submitKind10050Btn.addEventListener('click', submitKind10050Event);
}
if (submitKind10002Btn) {
submitKind10002Btn.addEventListener('click', submitKind10002Event);
}
if (addRelayEntryBtn) {
addRelayEntryBtn.addEventListener('click', () => addRelayEntry());
}
// Add one empty relay entry by default for Kind 10002
const kind10002Container = document.getElementById('kind10002-relay-entries');
if (kind10002Container && kind10002Container.children.length === 0) {
addRelayEntry(); // Add one empty entry to start
console.log('Added initial empty relay entry for Kind 10002');
}
console.log('Relay events functionality initialized');
}

1445
debug.txt

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,16 @@
cp build/c_relay_x86 ~/Storage/c_relay/crelay cp build/c_relay_x86 ~/Storage/c_relay/crelay
# Copy the local service file to systemd # Copy the local service file to systemd
sudo cp systemd/c-relay-local.service /etc/systemd/system/ # sudo cp systemd/c-relay-local.service /etc/systemd/system/
# Reload systemd daemon to pick up the new service # Reload systemd daemon to pick up the new service
sudo systemctl daemon-reload # sudo systemctl daemon-reload
# Enable the service (if not already enabled) # Enable the service (if not already enabled)
sudo systemctl enable c-relay-local.service # sudo systemctl enable c-relay-local.service
# Restart the service # Restart the service
sudo systemctl restart c-relay-local.service # sudo systemctl restart c-relay-local.service
# Show service status # Show service status
sudo systemctl status c-relay-local.service --no-pager -l # sudo systemctl status c-relay-local.service --no-pager -l

View File

@@ -175,6 +175,18 @@ Configuration events follow the standard Nostr event format with kind 33334:
- **Impact**: Allows some flexibility in expiration timing - **Impact**: Allows some flexibility in expiration timing
- **Example**: `"600"` (10 minute grace period) - **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 ## Configuration Examples
### Basic Relay Setup ### Basic Relay Setup

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
#### 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 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 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 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)

View File

@@ -0,0 +1,358 @@
# Subscription Table Collapsible Groups Implementation Plan
## Objective
Add collapsible/expandable functionality to subscription groups, where:
1. Each WSI Pointer group starts collapsed, showing only a summary row
2. Clicking the summary row expands to show all subscription details for that WSI Pointer
3. Clicking again collapses the group back to the summary row
## Current Behavior
- All subscriptions are displayed in a flat list
- WSI Pointer value shown only on first row of each group
- No interaction or collapse functionality
## Desired Behavior
- **Collapsed state**: One row per WSI Pointer showing summary information
- **Expanded state**: Header row + detail rows for each subscription in that group
- **Toggle**: Click on header row to expand/collapse
- **Visual indicator**: Arrow or icon showing expand/collapse state
## Design Approach
### Option 1: Summary Row + Detail Rows (Recommended)
```
[▶] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
(detail rows hidden)
When clicked:
[▼] WSI Pointer: 0x12345678 | Subscriptions: 3 | Total Duration: 15m
| sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
| sub-003 | 6m 15s | kinds:[1,3]
```
### Option 2: First Row as Header (Alternative)
```
[▶] 0x12345678 | sub-001 | 5m 30s | kinds:[1] (+ 2 more)
When clicked:
[▼] 0x12345678 | sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
| sub-003 | 6m 15s | kinds:[1,3]
```
**Recommendation**: Option 1 provides clearer UX and better summary information.
## Implementation Details
### Files to Modify
1. `api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
2. `api/index.css` - Add styles for collapsible rows
### Data Structure Changes
Need to group subscriptions by WSI Pointer first:
```javascript
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
```
### HTML Structure
#### Summary Row (Header)
```html
<tr class="subscription-group-header" data-wsi-pointer="0x12345678" data-expanded="false">
<td colspan="4" style="cursor: pointer; user-select: none;">
<span class="expand-icon"></span>
<strong>WSI Pointer:</strong> 0x12345678
<span class="group-summary">
| Subscriptions: 3 | Oldest: 15m 30s
</span>
</td>
</tr>
```
#### Detail Rows (Initially Hidden)
```html
<tr class="subscription-detail-row" data-wsi-group="0x12345678" style="display: none;">
<td style="padding-left: 30px;"><!-- Empty for WSI Pointer --></td>
<td>sub-001</td>
<td>5m 30s</td>
<td>kinds:[1]</td>
</tr>
```
### JavaScript Implementation
#### Modified populateSubscriptionDetailsTable Function
```javascript
function populateSubscriptionDetailsTable(subscriptionsData) {
const tableBody = document.getElementById('subscription-details-table-body');
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
tableBody.innerHTML = '';
if (subscriptionsData.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
tableBody.appendChild(row);
return;
}
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Group subscriptions by wsi_pointer
const groupedSubscriptions = {};
subscriptionsData.forEach(sub => {
const wsiKey = sub.wsi_pointer || 'N/A';
if (!groupedSubscriptions[wsiKey]) {
groupedSubscriptions[wsiKey] = [];
}
groupedSubscriptions[wsiKey].push(sub);
});
// Create rows for each group
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
// Calculate group summary
const subCount = subscriptions.length;
const now = Math.floor(Date.now() / 1000);
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
const oldestDurationStr = formatDuration(oldestDuration);
// Create header row (summary)
const headerRow = document.createElement('tr');
headerRow.className = 'subscription-group-header';
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
headerRow.setAttribute('data-expanded', 'false');
headerRow.style.cursor = 'pointer';
headerRow.style.userSelect = 'none';
headerRow.style.backgroundColor = 'var(--hover-color, #f5f5f5)';
headerRow.innerHTML = `
<td colspan="4" style="padding: 8px;">
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">WSI: ${wsiPointer}</strong>
<span style="color: #666; margin-left: 15px;">
Subscriptions: ${subCount} | Oldest: ${oldestDurationStr}
</span>
</td>
`;
// Add click handler to toggle expansion
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
tableBody.appendChild(headerRow);
// Create detail rows (initially hidden)
subscriptions.forEach((subscription, index) => {
const detailRow = document.createElement('tr');
detailRow.className = 'subscription-detail-row';
detailRow.setAttribute('data-wsi-group', wsiPointer);
detailRow.style.display = 'none';
// Calculate duration
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format filters
let filtersDisplay = 'None';
if (subscription.filters && subscription.filters.length > 0) {
const filterDetails = [];
subscription.filters.forEach((filter) => {
const parts = [];
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
parts.push(`kinds:[${filter.kinds.join(',')}]`);
}
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
const authorCount = filter.authors.length;
if (authorCount === 1) {
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
parts.push(`authors:[${shortPubkey}]`);
} else {
parts.push(`authors:[${authorCount} pubkeys]`);
}
}
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
const idCount = filter.ids.length;
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
}
const timeParts = [];
if (filter.since && filter.since > 0) {
const sinceDate = new Date(filter.since * 1000).toLocaleString();
timeParts.push(`since:${sinceDate}`);
}
if (filter.until && filter.until > 0) {
const untilDate = new Date(filter.until * 1000).toLocaleString();
timeParts.push(`until:${untilDate}`);
}
if (timeParts.length > 0) {
parts.push(timeParts.join(', '));
}
if (filter.limit && filter.limit > 0) {
parts.push(`limit:${filter.limit}`);
}
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
}
if (parts.length > 0) {
filterDetails.push(parts.join(', '));
} else {
filterDetails.push('empty filter');
}
});
filtersDisplay = filterDetails.join(' | ');
}
detailRow.innerHTML = `
<td style="padding-left: 30px; font-family: 'Courier New', monospace; font-size: 11px; color: #999;">└─</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(detailRow);
});
});
}
// Toggle function for expanding/collapsing groups
function toggleSubscriptionGroup(wsiPointer) {
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
const expandIcon = headerRow.querySelector('.expand-icon');
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
if (isExpanded) {
// Collapse
detailRows.forEach(row => row.style.display = 'none');
expandIcon.textContent = '▶';
expandIcon.style.transform = 'rotate(0deg)';
headerRow.setAttribute('data-expanded', 'false');
} else {
// Expand
detailRows.forEach(row => row.style.display = 'table-row');
expandIcon.textContent = '▼';
expandIcon.style.transform = 'rotate(90deg)';
headerRow.setAttribute('data-expanded', 'true');
}
}
```
### CSS Additions (api/index.css)
```css
/* Subscription group header styles */
.subscription-group-header {
background-color: var(--hover-color, #f5f5f5);
font-weight: 500;
transition: background-color 0.2s;
}
.subscription-group-header:hover {
background-color: var(--primary-color-light, #e8e8e8);
}
.expand-icon {
display: inline-block;
width: 20px;
transition: transform 0.2s ease;
font-size: 12px;
}
/* Detail row styles */
.subscription-detail-row {
background-color: var(--background-color, #ffffff);
}
.subscription-detail-row:hover {
background-color: var(--hover-color-light, #fafafa);
}
/* Dark mode support */
.dark-mode .subscription-group-header {
background-color: var(--hover-color-dark, #2a2a2a);
}
.dark-mode .subscription-group-header:hover {
background-color: var(--primary-color-dark, #333333);
}
.dark-mode .subscription-detail-row {
background-color: var(--background-color-dark, #1a1a1a);
}
.dark-mode .subscription-detail-row:hover {
background-color: var(--hover-color-dark, #252525);
}
```
## Features Included
1.**Collapsible groups**: Each WSI Pointer group can be collapsed/expanded
2.**Visual indicator**: Arrow icon (▶/▼) shows current state
3.**Summary information**: Shows subscription count and oldest duration
4.**Smooth transitions**: Icon rotation animation
5.**Hover effects**: Visual feedback on header rows
6.**Tree structure**: Detail rows indented with └─ character
7.**Dark mode support**: Proper styling for both themes
8.**Keyboard accessible**: Can be enhanced with keyboard navigation
## User Experience Flow
1. **Initial load**: All groups collapsed, showing summary rows only
2. **Click header**: Group expands, showing all subscription details
3. **Click again**: Group collapses back to summary
4. **Multiple groups**: Each group can be independently expanded/collapsed
5. **Visual feedback**: Hover effects and smooth animations
## Testing Checklist
1. ✅ Verify groups start collapsed by default
2. ✅ Verify clicking header expands group
3. ✅ Verify clicking again collapses group
4. ✅ Verify multiple groups can be expanded simultaneously
5. ✅ Verify summary information is accurate
6. ✅ Verify detail rows display correctly when expanded
7. ✅ Verify styling works in both light and dark modes
8. ✅ Verify no console errors
9. ✅ Verify performance with many subscriptions
## Optional Enhancements
1. **Expand/Collapse All**: Add buttons to expand or collapse all groups at once
2. **Remember State**: Store expansion state in localStorage
3. **Keyboard Navigation**: Add keyboard shortcuts (Space/Enter to toggle)
4. **Animation**: Add slide-down/up animation for detail rows
5. **Search/Filter**: Add ability to search within subscriptions
6. **Export**: Add ability to export subscription data
## Next Steps
1. Review this plan
2. Switch to Code mode
3. Implement the changes in `api/index.js`
4. Add CSS styles in `api/index.css`
5. Test the collapsible functionality
6. Verify all edge cases work correctly

View File

@@ -0,0 +1,155 @@
# Subscription Table WSI Pointer Grouping Implementation Plan
## Objective
Modify the subscription details table to show the WSI Pointer value only once per group - on the first row of each WSI Pointer group, leaving it blank for subsequent rows with the same WSI Pointer.
## Current Behavior
- All rows show the WSI Pointer value
- Rows are sorted by WSI Pointer (grouping is working)
- Visual grouping is not clear
## Desired Behavior
- First row of each WSI Pointer group shows the full WSI Pointer value
- Subsequent rows in the same group have an empty cell for WSI Pointer
- This creates a clear visual grouping effect
## Implementation Details
### File to Modify
`api/index.js` - Function `populateSubscriptionDetailsTable()` (lines 4277-4384)
### Code Changes Required
#### Current Code (lines 4291-4383):
```javascript
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
subscriptionsData.forEach((subscription, index) => {
const row = document.createElement('tr');
// Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view)
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer (show full pointer)
const wsiPointer = subscription.wsi_pointer || 'N/A';
// Format filters (show actual filter details)
let filtersDisplay = 'None';
// ... filter formatting code ...
row.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointer}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(row);
});
```
#### Modified Code:
```javascript
// Sort subscriptions by wsi_pointer to group them together
subscriptionsData.sort((a, b) => {
const wsiA = a.wsi_pointer || '';
const wsiB = b.wsi_pointer || '';
return wsiA.localeCompare(wsiB);
});
// Track previous WSI Pointer to detect group changes
let previousWsiPointer = null;
subscriptionsData.forEach((subscription, index) => {
const row = document.createElement('tr');
// Calculate duration
const now = Math.floor(Date.now() / 1000);
const duration = now - subscription.created_at;
const durationStr = formatDuration(duration);
// Format client IP (show full IP for admin view)
const clientIP = subscription.client_ip || 'unknown';
// Format wsi_pointer - only show if it's different from previous row
const currentWsiPointer = subscription.wsi_pointer || 'N/A';
let wsiPointerDisplay = '';
if (currentWsiPointer !== previousWsiPointer) {
// This is the first row of a new group - show the WSI Pointer
wsiPointerDisplay = currentWsiPointer;
previousWsiPointer = currentWsiPointer;
} else {
// This is a continuation of the same group - leave blank
wsiPointerDisplay = '';
}
// Format filters (show actual filter details)
let filtersDisplay = 'None';
// ... filter formatting code remains the same ...
row.innerHTML = `
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${wsiPointerDisplay}</td>
<td style="font-family: 'Courier New', monospace; font-size: 12px;">${subscription.id || 'N/A'}</td>
<!-- <td style="font-family: 'Courier New', monospace; font-size: 12px;">${clientIP}</td> -->
<td>${durationStr}</td>
<td>${filtersDisplay}</td>
`;
tableBody.appendChild(row);
});
```
### Key Changes Explained
1. **Add tracking variable**: `let previousWsiPointer = null;` before the forEach loop
2. **Store current WSI Pointer**: `const currentWsiPointer = subscription.wsi_pointer || 'N/A';`
3. **Compare with previous**: Check if `currentWsiPointer !== previousWsiPointer`
4. **Conditional display**:
- If different: Show the WSI Pointer value and update `previousWsiPointer`
- If same: Show empty string (blank cell)
5. **Use display variable**: Replace `${wsiPointer}` with `${wsiPointerDisplay}` in the row HTML
### Visual Result
**Before:**
```
WSI Pointer | Subscription ID | Duration | Filters
0x12345678 | sub-001 | 5m 30s | kinds:[1]
0x12345678 | sub-002 | 3m 15s | kinds:[3]
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
```
**After:**
```
WSI Pointer | Subscription ID | Duration | Filters
0x12345678 | sub-001 | 5m 30s | kinds:[1]
| sub-002 | 3m 15s | kinds:[3]
0x87654321 | sub-003 | 1m 45s | kinds:[1,3]
```
## Testing Checklist
1. ✅ Verify first row of each group shows WSI Pointer
2. ✅ Verify subsequent rows in same group are blank
3. ✅ Verify grouping works with multiple subscriptions per WSI Pointer
4. ✅ Verify single subscription per WSI Pointer still shows the value
5. ✅ Verify empty/null WSI Pointers are handled correctly
6. ✅ Verify table still displays correctly when no subscriptions exist
## Next Steps
1. Review this plan
2. Switch to Code mode
3. Implement the changes in `api/index.js`
4. Test the implementation
5. Verify the visual grouping effect

View File

@@ -8,36 +8,59 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' NC='\033[0m'
print_status() { echo -e "${BLUE}[INFO]${NC} $1"; } print_status() { echo -e "${BLUE}[INFO]${NC} $1" >&2; }
print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } print_success() { echo -e "${GREEN}[SUCCESS]${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } print_warning() { echo -e "${YELLOW}[WARNING]${NC} $1" >&2; }
print_error() { echo -e "${RED}[ERROR]${NC} $1"; } print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# Global variables # Global variables
COMMIT_MESSAGE="" COMMIT_MESSAGE=""
RELEASE_MODE=false RELEASE_MODE=false
VERSION_INCREMENT_TYPE="patch" # "patch", "minor", or "major"
show_usage() { show_usage() {
echo "C-Relay Increment and Push Script" echo "C-Relay Increment and Push Script"
echo "" echo ""
echo "Usage:" echo "USAGE:"
echo " $0 \"commit message\" - Default: increment patch, commit & push" echo " $0 [OPTIONS] \"commit message\""
echo " $0 -r \"commit message\" - Release: increment minor, create release"
echo "" echo ""
echo "Examples:" echo "COMMANDS:"
echo " $0 \"commit message\" Default: increment patch, commit & push"
echo " $0 -p \"commit message\" Increment patch version"
echo " $0 -m \"commit message\" Increment minor version"
echo " $0 -M \"commit message\" Increment major version"
echo " $0 -r \"commit message\" Create release with assets (no version increment)"
echo " $0 -r -m \"commit message\" Create release with minor version increment"
echo " $0 -h Show this help message"
echo ""
echo "OPTIONS:"
echo " -p, --patch Increment patch version (default)"
echo " -m, --minor Increment minor version"
echo " -M, --major Increment major version"
echo " -r, --release Create release with assets"
echo " -h, --help Show this help message"
echo ""
echo "EXAMPLES:"
echo " $0 \"Fixed event validation bug\"" echo " $0 \"Fixed event validation bug\""
echo " $0 --release \"Major release with new features\"" echo " $0 -m \"Added new features\""
echo " $0 -M \"Breaking API changes\""
echo " $0 -r \"Release current version\""
echo " $0 -r -m \"Release with minor increment\""
echo "" echo ""
echo "Default Mode (patch increment):" echo "VERSION INCREMENT MODES:"
echo " - Increment patch version (v1.2.3 → v1.2.4)" echo " -p, --patch (default): Increment patch version (v1.2.3 → v1.2.4)"
echo " - Git add, commit with message, and push" echo " -m, --minor: Increment minor version, zero patch (v1.2.3 → v1.3.0)"
echo " -M, --major: Increment major version, zero minor+patch (v1.2.3 → v2.0.0)"
echo "" echo ""
echo "Release Mode (-r flag):" echo "RELEASE MODE (-r flag):"
echo " - Increment minor version, zero patch (v1.2.3 → v1.3.0)" echo " - Build static binary using build_static.sh"
echo " - Git add, commit, push, and create Gitea release" echo " - Create source tarball"
echo " - Git add, commit, push, and create Gitea release with assets"
echo " - Can be combined with version increment flags"
echo "" echo ""
echo "Requirements for Release Mode:" echo "REQUIREMENTS FOR RELEASE MODE:"
echo " - Gitea token in ~/.gitea_token for release uploads" echo " - Gitea token in ~/.gitea_token for release uploads"
echo " - Docker installed for static binary builds"
} }
# Parse command line arguments # Parse command line arguments
@@ -47,6 +70,18 @@ while [[ $# -gt 0 ]]; do
RELEASE_MODE=true RELEASE_MODE=true
shift shift
;; ;;
-p|--patch)
VERSION_INCREMENT_TYPE="patch"
shift
;;
-m|--minor)
VERSION_INCREMENT_TYPE="minor"
shift
;;
-M|--major)
VERSION_INCREMENT_TYPE="major"
shift
;;
-h|--help) -h|--help)
show_usage show_usage
exit 0 exit 0
@@ -79,7 +114,7 @@ check_git_repo() {
# Function to get current version and increment appropriately # Function to get current version and increment appropriately
increment_version() { increment_version() {
local increment_type="$1" # "patch" or "minor" local increment_type="$1" # "patch", "minor", or "major"
print_status "Getting current version..." print_status "Getting current version..."
@@ -105,24 +140,34 @@ increment_version() {
fi fi
# Increment version based on type # Increment version based on type
if [[ "$increment_type" == "minor" ]]; then if [[ "$increment_type" == "major" ]]; then
# Major release: increment major, zero minor and patch
NEW_MAJOR=$((MAJOR + 1))
NEW_MINOR=0
NEW_PATCH=0
NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
print_status "Major version increment: incrementing major version"
elif [[ "$increment_type" == "minor" ]]; then
# Minor release: increment minor, zero patch # Minor release: increment minor, zero patch
NEW_MAJOR=$MAJOR
NEW_MINOR=$((MINOR + 1)) NEW_MINOR=$((MINOR + 1))
NEW_PATCH=0 NEW_PATCH=0
NEW_VERSION="v${MAJOR}.${NEW_MINOR}.${NEW_PATCH}" NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
print_status "Release mode: incrementing minor version" print_status "Minor version increment: incrementing minor version"
else else
# Default: increment patch # Default: increment patch
NEW_MAJOR=$MAJOR
NEW_MINOR=$MINOR
NEW_PATCH=$((PATCH + 1)) NEW_PATCH=$((PATCH + 1))
NEW_VERSION="v${MAJOR}.${MINOR}.${NEW_PATCH}" NEW_VERSION="v${NEW_MAJOR}.${NEW_MINOR}.${NEW_PATCH}"
print_status "Default mode: incrementing patch version" print_status "Patch version increment: incrementing patch version"
fi fi
print_status "Current version: $LATEST_TAG" print_status "Current version: $LATEST_TAG"
print_status "New version: $NEW_VERSION" print_status "New version: $NEW_VERSION"
# Update version in src/main.h # Update version in src/main.h
update_version_in_header "$NEW_VERSION" "$MAJOR" "${NEW_MINOR:-$MINOR}" "${NEW_PATCH:-$PATCH}" update_version_in_header "$NEW_VERSION" "$NEW_MAJOR" "$NEW_MINOR" "$NEW_PATCH"
# Export for use in other functions # Export for use in other functions
export NEW_VERSION export NEW_VERSION
@@ -261,6 +306,115 @@ git_commit_and_push_no_tag() {
fi fi
} }
# Function to build release binary
build_release_binary() {
print_status "Building release binary..."
# Check if build_static.sh exists
if [[ ! -f "build_static.sh" ]]; then
print_error "build_static.sh not found"
return 1
fi
# Run the static build script
if ./build_static.sh > /dev/null 2>&1; then
print_success "Built static binary successfully"
return 0
else
print_error "Failed to build static binary"
return 1
fi
}
# Function to create source tarball
create_source_tarball() {
print_status "Creating source tarball..."
local tarball_name="c-relay-${NEW_VERSION#v}.tar.gz"
# Create tarball excluding build artifacts and git files
if tar -czf "$tarball_name" \
--exclude='build/*' \
--exclude='.git*' \
--exclude='*.db' \
--exclude='*.db-*' \
--exclude='*.log' \
--exclude='*.tar.gz' \
. > /dev/null 2>&1; then
print_success "Created source tarball: $tarball_name"
echo "$tarball_name"
return 0
else
print_error "Failed to create source tarball"
return 1
fi
}
# Function to upload release assets to Gitea
upload_release_assets() {
local release_id="$1"
local binary_path="$2"
local tarball_path="$3"
print_status "Uploading release assets..."
# Check for Gitea token
if [[ ! -f "$HOME/.gitea_token" ]]; then
print_warning "No ~/.gitea_token found. Skipping asset uploads."
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"
local assets_url="$api_url/releases/$release_id/assets"
print_status "Assets URL: $assets_url"
# Upload binary
if [[ -f "$binary_path" ]]; then
print_status "Uploading binary: $(basename "$binary_path")"
# Retry loop for eventual consistency
local max_attempts=3
local attempt=1
while [[ $attempt -le $max_attempts ]]; do
print_status "Upload attempt $attempt/$max_attempts"
local binary_response=$(curl -fS -X POST "$assets_url" \
-H "Authorization: token $token" \
-F "attachment=@$binary_path;filename=$(basename "$binary_path")" \
-F "name=$(basename "$binary_path")")
if echo "$binary_response" | grep -q '"id"'; then
print_success "Uploaded binary successfully"
break
else
print_warning "Upload attempt $attempt failed"
if [[ $attempt -lt $max_attempts ]]; then
print_status "Retrying in 2 seconds..."
sleep 2
else
print_error "Failed to upload binary after $max_attempts attempts"
print_error "Response: $binary_response"
fi
fi
((attempt++))
done
fi
# Upload source tarball
if [[ -f "$tarball_path" ]]; then
print_status "Uploading source tarball: $(basename "$tarball_path")"
local tarball_response=$(curl -s -X POST "$api_url/releases/$release_id/assets" \
-H "Authorization: token $token" \
-F "attachment=@$tarball_path;filename=$(basename "$tarball_path")")
if echo "$tarball_response" | grep -q '"id"'; then
print_success "Uploaded source tarball successfully"
else
print_warning "Failed to upload source tarball: $tarball_response"
fi
fi
}
# Function to create Gitea release # Function to create Gitea release
create_gitea_release() { create_gitea_release() {
print_status "Creating Gitea release..." print_status "Creating Gitea release..."
@@ -284,10 +438,21 @@ create_gitea_release() {
if echo "$response" | grep -q '"id"'; then if echo "$response" | grep -q '"id"'; then
print_success "Created release $NEW_VERSION" print_success "Created release $NEW_VERSION"
return 0 # Extract release ID for asset uploads
local release_id=$(echo "$response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo $release_id
elif echo "$response" | grep -q "already exists"; then elif echo "$response" | grep -q "already exists"; then
print_warning "Release $NEW_VERSION already exists" print_warning "Release $NEW_VERSION already exists"
return 0 # Try to get existing release ID
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then
local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
print_status "Using existing release ID: $release_id"
echo $release_id
else
print_error "Could not find existing release ID"
return 1
fi
else else
print_error "Failed to create release $NEW_VERSION" print_error "Failed to create release $NEW_VERSION"
print_error "Response: $response" print_error "Response: $response"
@@ -297,7 +462,8 @@ create_gitea_release() {
local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION") local check_response=$(curl -s -H "Authorization: token $token" "$api_url/releases/tags/$NEW_VERSION")
if echo "$check_response" | grep -q '"id"'; then if echo "$check_response" | grep -q '"id"'; then
print_warning "Release exists but creation response was unexpected" print_warning "Release exists but creation response was unexpected"
return 0 local release_id=$(echo "$check_response" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
echo $release_id
else else
print_error "Release does not exist and creation failed" print_error "Release does not exist and creation failed"
return 1 return 1
@@ -315,8 +481,15 @@ main() {
if [[ "$RELEASE_MODE" == true ]]; then if [[ "$RELEASE_MODE" == true ]]; then
print_status "=== RELEASE MODE ===" print_status "=== RELEASE MODE ==="
# Increment minor version for releases # Only increment version if explicitly requested (not just because of -r flag)
increment_version "minor" if [[ "$VERSION_INCREMENT_TYPE" != "patch" ]]; then
increment_version "$VERSION_INCREMENT_TYPE"
else
# In release mode without version increment, get current version
LATEST_TAG=$(git tag -l 'v*.*.*' | sort -V | tail -n 1 || echo "v0.0.0")
NEW_VERSION="$LATEST_TAG"
export NEW_VERSION
fi
# Create new git tag BEFORE compilation so version.h picks it up # Create new git tag BEFORE compilation so version.h picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then if git tag "$NEW_VERSION" > /dev/null 2>&1; then
@@ -330,9 +503,42 @@ main() {
# Commit and push (but skip tag creation since we already did it) # Commit and push (but skip tag creation since we already did it)
git_commit_and_push_no_tag git_commit_and_push_no_tag
# Build release binary
if build_release_binary; then
local binary_path="build/c_relay_static_x86_64"
else
print_warning "Binary build failed, continuing with release creation"
# Check if binary exists from previous build
if [[ -f "build/c_relay_static_x86_64" ]]; then
print_status "Using existing binary from previous build"
binary_path="build/c_relay_static_x86_64"
else
binary_path=""
fi
fi
# Create source tarball
local tarball_path=""
if tarball_path=$(create_source_tarball); then
: # tarball_path is set by the function
else
print_warning "Source tarball creation failed, continuing with release creation"
fi
# Create Gitea release # Create Gitea release
if create_gitea_release; then local release_id=""
if release_id=$(create_gitea_release); then
# Validate release_id is numeric
if [[ "$release_id" =~ ^[0-9]+$ ]]; then
# Upload assets if we have a release ID and assets
if [[ -n "$release_id" && (-n "$binary_path" || -n "$tarball_path") ]]; then
upload_release_assets "$release_id" "$binary_path" "$tarball_path"
fi
print_success "Release $NEW_VERSION completed successfully!" print_success "Release $NEW_VERSION completed successfully!"
else
print_error "Invalid release_id: $release_id"
exit 1
fi
else else
print_error "Release creation failed" print_error "Release creation failed"
fi fi
@@ -340,8 +546,8 @@ main() {
else else
print_status "=== DEFAULT MODE ===" print_status "=== DEFAULT MODE ==="
# Increment patch version for regular commits # Increment version based on type (default to patch)
increment_version "patch" increment_version "$VERSION_INCREMENT_TYPE"
# Create new git tag BEFORE compilation so version.h picks it up # Create new git tag BEFORE compilation so version.h picks it up
if git tag "$NEW_VERSION" > /dev/null 2>&1; then if git tag "$NEW_VERSION" > /dev/null 2>&1; then

View File

@@ -1 +1 @@
1438499 3580654

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
screenshots/DM.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

BIN
screenshots/config.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
screenshots/light-mode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

BIN
screenshots/main-light.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
screenshots/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

BIN
screenshots/sqlQuery.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

662
src/api.c
View File

@@ -13,6 +13,7 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include <strings.h> #include <strings.h>
#include <stdbool.h>
#include "api.h" #include "api.h"
#include "embedded_web_content.h" #include "embedded_web_content.h"
#include "config.h" #include "config.h"
@@ -222,79 +223,6 @@ cJSON* query_top_pubkeys(void) {
return top_pubkeys; return top_pubkeys;
} }
// Query active subscriptions summary from database
cJSON* query_active_subscriptions(void) {
extern sqlite3* g_db;
if (!g_db) {
DEBUG_ERROR("Database not available for active subscriptions query");
return NULL;
}
// Get configuration limits
int max_subs = g_subscription_manager.max_total_subscriptions;
int max_per_client = g_subscription_manager.max_subscriptions_per_client;
// Query total active subscriptions from database
sqlite3_stmt* stmt;
const char* sql =
"SELECT COUNT(*) as total_subs, "
"COUNT(DISTINCT client_ip) as client_count "
"FROM subscriptions "
"WHERE event_type = 'created' AND ended_at IS NULL";
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare active subscriptions query");
return NULL;
}
int total_subs = 0;
int client_count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
total_subs = sqlite3_column_int(stmt, 0);
client_count = sqlite3_column_int(stmt, 1);
}
sqlite3_finalize(stmt);
// Query max subscriptions per client
int most_subs_per_client = 0;
const char* max_sql =
"SELECT MAX(sub_count) FROM ("
" SELECT COUNT(*) as sub_count "
" FROM subscriptions "
" WHERE event_type = 'created' AND ended_at IS NULL "
" GROUP BY client_ip"
")";
if (sqlite3_prepare_v2(g_db, max_sql, -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
most_subs_per_client = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
// Calculate statistics
double utilization_percentage = max_subs > 0 ? (total_subs * 100.0 / max_subs) : 0.0;
double avg_subs_per_client = client_count > 0 ? (total_subs * 1.0 / client_count) : 0.0;
// Build JSON response matching the design spec
cJSON* subscriptions = cJSON_CreateObject();
cJSON_AddStringToObject(subscriptions, "data_type", "active_subscriptions");
cJSON_AddNumberToObject(subscriptions, "timestamp", (double)time(NULL));
cJSON* data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "total_subscriptions", total_subs);
cJSON_AddNumberToObject(data, "max_subscriptions", max_subs);
cJSON_AddNumberToObject(data, "utilization_percentage", utilization_percentage);
cJSON_AddNumberToObject(data, "subscriptions_per_client_avg", avg_subs_per_client);
cJSON_AddNumberToObject(data, "most_subscriptions_per_client", most_subs_per_client);
cJSON_AddNumberToObject(data, "max_subscriptions_per_client", max_per_client);
cJSON_AddNumberToObject(data, "active_clients", client_count);
cJSON_AddItemToObject(subscriptions, "data", data);
return subscriptions;
}
// Query detailed subscription information from database log (ADMIN ONLY) // Query detailed subscription information from database log (ADMIN ONLY)
// Uses subscriptions table instead of in-memory iteration to avoid mutex contention // Uses subscriptions table instead of in-memory iteration to avoid mutex contention
@@ -305,16 +233,18 @@ cJSON* query_subscription_details(void) {
return NULL; return NULL;
} }
// Query active subscriptions directly from subscriptions table // Query active subscriptions from the active_subscriptions_log view
// Get subscriptions that were created but not yet closed/expired/disconnected // This view properly handles deduplication of closed/expired subscriptions
sqlite3_stmt* stmt; sqlite3_stmt* stmt;
const char* sql = const char* sql =
"SELECT subscription_id, client_ip, wsi_pointer, filter_json, events_sent, " "SELECT * "
"created_at, (strftime('%s', 'now') - created_at) as duration_seconds " "FROM active_subscriptions_log "
"FROM subscriptions "
"WHERE event_type = 'created' AND ended_at IS NULL "
"ORDER BY created_at DESC LIMIT 100"; "ORDER BY created_at DESC LIMIT 100";
// DEBUG: Log the query results for debugging subscription_details
DEBUG_LOG("=== SUBSCRIPTION_DETAILS QUERY DEBUG ===");
DEBUG_LOG("Query: %s", sql);
if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) { if (sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL) != SQLITE_OK) {
DEBUG_ERROR("Failed to prepare subscription details query"); DEBUG_ERROR("Failed to prepare subscription details query");
return NULL; return NULL;
@@ -329,26 +259,33 @@ cJSON* query_subscription_details(void) {
cJSON* subscriptions_array = cJSON_CreateArray(); cJSON* subscriptions_array = cJSON_CreateArray();
// Iterate through query results // Iterate through query results
int row_count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) { while (sqlite3_step(stmt) == SQLITE_ROW) {
row_count++;
cJSON* sub_obj = cJSON_CreateObject(); cJSON* sub_obj = cJSON_CreateObject();
// Extract subscription data from database // Extract subscription data from database
const char* sub_id = (const char*)sqlite3_column_text(stmt, 0); const char* sub_id = (const char*)sqlite3_column_text(stmt, 0);
const char* client_ip = (const char*)sqlite3_column_text(stmt, 1); const char* client_ip = (const char*)sqlite3_column_text(stmt, 1);
const char* wsi_pointer = (const char*)sqlite3_column_text(stmt, 2); const char* filter_json = (const char*)sqlite3_column_text(stmt, 2);
const char* filter_json = (const char*)sqlite3_column_text(stmt, 3); long long events_sent = sqlite3_column_int64(stmt, 3);
long long events_sent = sqlite3_column_int64(stmt, 4); long long created_at = sqlite3_column_int64(stmt, 4);
long long created_at = sqlite3_column_int64(stmt, 5); long long duration_seconds = sqlite3_column_int64(stmt, 5);
long long duration_seconds = sqlite3_column_int64(stmt, 6); const char* wsi_pointer = (const char*)sqlite3_column_text(stmt, 6);
// DEBUG: Log each subscription found
DEBUG_LOG("Row %d: sub_id=%s, client_ip=%s, events_sent=%lld, created_at=%lld",
row_count, sub_id ? sub_id : "NULL", client_ip ? client_ip : "NULL",
events_sent, created_at);
// Add basic subscription info // Add basic subscription info
cJSON_AddStringToObject(sub_obj, "id", sub_id ? sub_id : ""); cJSON_AddStringToObject(sub_obj, "id", sub_id ? sub_id : "");
cJSON_AddStringToObject(sub_obj, "client_ip", client_ip ? client_ip : ""); cJSON_AddStringToObject(sub_obj, "client_ip", client_ip ? client_ip : "");
cJSON_AddStringToObject(sub_obj, "wsi_pointer", wsi_pointer ? wsi_pointer : "");
cJSON_AddNumberToObject(sub_obj, "created_at", (double)created_at); cJSON_AddNumberToObject(sub_obj, "created_at", (double)created_at);
cJSON_AddNumberToObject(sub_obj, "duration_seconds", (double)duration_seconds); cJSON_AddNumberToObject(sub_obj, "duration_seconds", (double)duration_seconds);
cJSON_AddNumberToObject(sub_obj, "events_sent", events_sent); cJSON_AddNumberToObject(sub_obj, "events_sent", events_sent);
cJSON_AddBoolToObject(sub_obj, "active", 1); // All from this view are active cJSON_AddBoolToObject(sub_obj, "active", 1); // All from this view are active
cJSON_AddStringToObject(sub_obj, "wsi_pointer", wsi_pointer ? wsi_pointer : "N/A");
// Parse and add filter JSON if available // Parse and add filter JSON if available
if (filter_json) { if (filter_json) {
@@ -374,6 +311,10 @@ cJSON* query_subscription_details(void) {
cJSON_AddItemToObject(subscriptions_data, "data", data); cJSON_AddItemToObject(subscriptions_data, "data", data);
// DEBUG: Log final summary
DEBUG_LOG("Total subscriptions found: %d", cJSON_GetArraySize(subscriptions_array));
DEBUG_LOG("=== END SUBSCRIPTION_DETAILS QUERY DEBUG ===");
return subscriptions_data; return subscriptions_data;
} }
@@ -397,11 +338,6 @@ int generate_event_driven_monitoring(void) {
return -1; return -1;
} }
// Generate active_subscriptions monitoring event
if (generate_monitoring_event_for_type("active_subscriptions", query_active_subscriptions) != 0) {
DEBUG_ERROR("Failed to generate active_subscriptions monitoring event");
return -1;
}
// Generate CPU metrics monitoring event (also triggered by event storage) // Generate CPU metrics monitoring event (also triggered by event storage)
if (generate_monitoring_event_for_type("cpu_metrics", query_cpu_metrics) != 0) { if (generate_monitoring_event_for_type("cpu_metrics", query_cpu_metrics) != 0) {
@@ -409,17 +345,11 @@ int generate_event_driven_monitoring(void) {
return -1; return -1;
} }
DEBUG_INFO("Generated and broadcast event-driven monitoring events");
return 0; return 0;
} }
// Generate subscription-driven monitoring events (triggered by subscription changes) // Generate subscription-driven monitoring events (triggered by subscription changes)
int generate_subscription_driven_monitoring(void) { int generate_subscription_driven_monitoring(void) {
// Generate active_subscriptions monitoring event (subscription changes affect this)
if (generate_monitoring_event_for_type("active_subscriptions", query_active_subscriptions) != 0) {
DEBUG_ERROR("Failed to generate active_subscriptions monitoring event");
return -1;
}
// Generate subscription_details monitoring event (admin-only) // Generate subscription_details monitoring event (admin-only)
if (generate_monitoring_event_for_type("subscription_details", query_subscription_details) != 0) { if (generate_monitoring_event_for_type("subscription_details", query_subscription_details) != 0) {
@@ -433,7 +363,6 @@ int generate_subscription_driven_monitoring(void) {
return -1; return -1;
} }
DEBUG_INFO("Generated and broadcast subscription-driven monitoring events");
return 0; return 0;
} }
@@ -572,6 +501,76 @@ void monitoring_on_subscription_change(void) {
generate_subscription_driven_monitoring(); generate_subscription_driven_monitoring();
} }
/**
* Generate and post a kind 1 status event with relay statistics
* Called periodically from main event loop based on configuration
* Returns: 0 on success, -1 on error
*/
int generate_and_post_status_event(void) {
// Check if feature is enabled
int hours_interval = get_config_int("kind_1_status_posts_hours", 0);
if (hours_interval <= 0) {
return 0; // Feature disabled
}
// Generate statistics text content using existing function
char* stats_text = generate_stats_text();
if (!stats_text) {
DEBUG_ERROR("Failed to generate statistics text for status post");
return -1;
}
// Get relay private key
char* relay_privkey_hex = get_relay_private_key();
if (!relay_privkey_hex) {
DEBUG_ERROR("Failed to get relay private key for status post");
free(stats_text);
return -1;
}
// Convert hex private key to bytes
unsigned char relay_privkey_bytes[32];
if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey_bytes, 32) != 0) {
DEBUG_ERROR("Failed to convert relay private key to bytes");
free(stats_text);
return -1;
}
// Create empty tags array (kind 1 doesn't require special tags)
cJSON* tags = cJSON_CreateArray();
// Create and sign the kind 1 event
cJSON* signed_event = nostr_create_and_sign_event(
1, // kind 1 = text note
stats_text, // content = statistics
tags, // empty tags
relay_privkey_bytes, // relay's private key
time(NULL) // current timestamp
);
free(stats_text);
if (!signed_event) {
DEBUG_ERROR("Failed to create and sign status event");
return -1;
}
// Store event in database (kind 1 events MUST be stored)
if (store_event(signed_event) != 0) {
DEBUG_ERROR("Failed to store status event in database");
cJSON_Delete(signed_event);
return -1;
}
// Broadcast event to subscriptions
broadcast_event_to_subscriptions(signed_event);
DEBUG_LOG("Successfully posted kind 1 status event");
cJSON_Delete(signed_event);
return 0;
}
// Forward declaration for known_configs (defined in config.c) // Forward declaration for known_configs (defined in config.c)
typedef struct { typedef struct {
const char* key; const char* key;
@@ -638,6 +637,9 @@ static const config_definition_t known_configs[] = {
{"relay_privkey", "string", 0, 65}, {"relay_privkey", "string", 0, 65},
{"admin_pubkey", "string", 0, 65}, {"admin_pubkey", "string", 0, 65},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "int", 0, 8760},
// Sentinel // Sentinel
{NULL, NULL, 0, 0} {NULL, NULL, 0, 0}
}; };
@@ -799,7 +801,7 @@ int send_admin_response(const char* sender_pubkey, const char* response_content,
} }
// Encrypt response content using NIP-44 // Encrypt response content using NIP-44
char encrypted_content[16384]; // Buffer for encrypted content (increased size) char encrypted_content[131072]; // Buffer for encrypted content (128KB to handle large SQL responses)
int encrypt_result = nostr_nip44_encrypt( int encrypt_result = nostr_nip44_encrypt(
relay_privkey, // sender private key (bytes) relay_privkey, // sender private key (bytes)
sender_pubkey_bytes, // recipient public key (bytes) sender_pubkey_bytes, // recipient public key (bytes)
@@ -1387,6 +1389,9 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
return -1; return -1;
} }
// Get timestamp delay configuration
long max_delay_sec = get_config_int("nip59_timestamp_max_delay_sec", 0);
// Create and sign gift wrap using library function // Create and sign gift wrap using library function
cJSON* gift_wraps[1]; cJSON* gift_wraps[1];
int send_result = nostr_nip17_send_dm( int send_result = nostr_nip17_send_dm(
@@ -1395,7 +1400,8 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
1, // num_recipients 1, // num_recipients
relay_privkey, // sender_private_key relay_privkey, // sender_private_key
gift_wraps, // gift_wraps_out gift_wraps, // gift_wraps_out
1 // max_gift_wraps 1, // max_gift_wraps
max_delay_sec // max_delay_sec
); );
cJSON_Delete(dm_response); cJSON_Delete(dm_response);
@@ -1492,12 +1498,37 @@ char* generate_config_text(void) {
} }
// Footer // Footer
offset += snprintf(config_text + offset, 8192 - offset, offset += snprintf(config_text + offset, 8192 - offset, "\n");
"\n✅ Configuration retrieved successfully");
return config_text; return config_text;
} }
// Helper function to format numbers with commas
void format_number_with_commas(long long num, char* buffer, size_t buffer_size) {
char temp[32];
int len = snprintf(temp, sizeof(temp), "%lld", num);
int comma_count = (len - 1) / 3;
int result_len = len + comma_count;
if (result_len >= (int)buffer_size) {
buffer[0] = '\0';
return;
}
int j = result_len - 1;
int k = len - 1;
int comma_pos = 0;
while (k >= 0) {
buffer[j--] = temp[k--];
comma_pos++;
if (comma_pos % 3 == 0 && k >= 0) {
buffer[j--] = ',';
}
}
buffer[result_len] = '\0';
}
// Generate human-readable stats text // Generate human-readable stats text
char* generate_stats_text(void) { char* generate_stats_text(void) {
char* stats_json = generate_stats_json(); char* stats_json = generate_stats_json();
@@ -1523,7 +1554,7 @@ char* generate_stats_text(void) {
cJSON* newest_event = cJSON_GetObjectItem(stats_obj, "latest_event_at"); cJSON* newest_event = cJSON_GetObjectItem(stats_obj, "latest_event_at");
cJSON* time_stats = cJSON_GetObjectItem(stats_obj, "time_stats"); cJSON* time_stats = cJSON_GetObjectItem(stats_obj, "time_stats");
cJSON* event_kinds = cJSON_GetObjectItem(stats_obj, "event_kinds"); cJSON* event_kinds = cJSON_GetObjectItem(stats_obj, "event_kinds");
cJSON* top_pubkeys = cJSON_GetObjectItem(stats_obj, "top_pubkeys"); // cJSON* top_pubkeys = cJSON_GetObjectItem(stats_obj, "top_pubkeys");
long long total = total_events ? (long long)cJSON_GetNumberValue(total_events) : 0; long long total = total_events ? (long long)cJSON_GetNumberValue(total_events) : 0;
long long db_bytes = db_size ? (long long)cJSON_GetNumberValue(db_size) : 0; long long db_bytes = db_size ? (long long)cJSON_GetNumberValue(db_size) : 0;
@@ -1564,25 +1595,23 @@ char* generate_stats_text(void) {
// Header // Header
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset,
"📊 Relay Statistics\n" "\nRelay Statistics\n"
"━━━━━━━━━━━━━━━━━━━━\n"); "━━━━━━━━━━━━━━━━━━━━\n");
// Database Overview section // Database Overview section
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset,
"Database Overview:\n"
"Metric\tValue\tDescription\n" "Database Size %.2f MB (%lld bytes)\n"
"Database Size\t%.2f MB (%lld bytes)\tCurrent database file size\n" "Total Events %lld\n"
"Total Events\t%lld\tTotal number of events stored\n" "Active Subscriptions %d\n"
"Active Subscriptions\t%d\tCurrent active WebSocket subscriptions\n" "Oldest Event %s\n"
"Oldest Event\t%s\tTimestamp of oldest event\n" "Newest Event %s\n"
"Newest Event\t%s\tTimestamp of newest event\n"
"\n", "\n",
db_mb, db_bytes, total, active_subs, oldest_str, newest_str); db_mb, db_bytes, total, active_subs, oldest_str, newest_str);
// Event Kind Distribution section // Event Kind Distribution section
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset,
"Event Kind Distribution:\n" "Event_Kind_Distribution:\n");
"Event Kind\tCount\tPercentage\n");
if (event_kinds && cJSON_IsArray(event_kinds)) { if (event_kinds && cJSON_IsArray(event_kinds)) {
cJSON* kind_item = NULL; cJSON* kind_item = NULL;
@@ -1592,11 +1621,45 @@ char* generate_stats_text(void) {
cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage"); cJSON* percentage = cJSON_GetObjectItem(kind_item, "percentage");
if (kind && count && percentage) { if (kind && count && percentage) {
// Format event kind (right-justified, minimum 5 chars wide with underscores)
char kind_str[16];
int kind_val = (int)cJSON_GetNumberValue(kind);
char temp_kind[16];
snprintf(temp_kind, sizeof(temp_kind), "%d", kind_val);
// Calculate padding needed (minimum 5 chars, but more if kind is longer)
int kind_len = strlen(temp_kind);
int min_width = 5;
int total_width = (kind_len > min_width) ? kind_len : min_width;
int padding = total_width - kind_len;
// Create padded string with underscores
memset(kind_str, '_', padding);
strcpy(kind_str + padding, temp_kind);
// Format count with commas (right-justified, 11 chars wide with underscores)
char count_str[16];
format_number_with_commas((long long)cJSON_GetNumberValue(count), count_str, sizeof(count_str));
char count_formatted[16];
snprintf(count_formatted, sizeof(count_formatted), "%11s", count_str);
// Replace spaces with underscores for alignment
for (int i = 0; count_formatted[i]; i++) {
if (count_formatted[i] == ' ') count_formatted[i] = '_';
}
// Format percentage (right-justified, 7 chars wide including %, with underscores)
double pct = cJSON_GetNumberValue(percentage);
char pct_str[8];
snprintf(pct_str, sizeof(pct_str), "%.1f%%", pct);
// Pad with underscores to make 7 chars total (right-justified)
char pct_formatted[8];
int pct_len = strlen(pct_str);
int underscores_needed = 7 - pct_len;
memset(pct_formatted, '_', underscores_needed);
strcpy(pct_formatted + underscores_needed, pct_str);
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset,
"%lld\t%lld\t%.1f%%\n", "%s%s_%s\n", kind_str, count_formatted, pct_formatted);
(long long)cJSON_GetNumberValue(kind),
(long long)cJSON_GetNumberValue(count),
cJSON_GetNumberValue(percentage));
} }
} }
} else { } else {
@@ -1609,49 +1672,48 @@ char* generate_stats_text(void) {
// Time-based Statistics section // Time-based Statistics section
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset,
"Time-based Statistics:\n" "Time-based Statistics:\n"
"Period\tEvents\tDescription\n"
"Last 24 Hours\t%lld\tEvents in the last day\n" "%lld\tEvents in the last day\n"
"Last 7 Days\t%lld\tEvents in the last week\n" "%lld\tEvents in the last week\n"
"Last 30 Days\t%lld\tEvents in the last month\n" "%lld\tEvents in the last month\n"
"\n", "\n",
last_24h, last_7d, last_30d); last_24h, last_7d, last_30d);
// Top Pubkeys section // Top Pubkeys section
offset += snprintf(stats_text + offset, 16384 - offset, // offset += snprintf(stats_text + offset, 16384 - offset,
"Top Pubkeys by Event Count:\n" // "Top Pubkeys by Event Count:\n"
"Rank\tPubkey\tEvent Count\tPercentage\n"); // "Rank\tPubkey\tEvent Count\tPercentage\n");
if (top_pubkeys && cJSON_IsArray(top_pubkeys)) { // if (top_pubkeys && cJSON_IsArray(top_pubkeys)) {
int rank = 1; // int rank = 1;
cJSON* pubkey_item = NULL; // cJSON* pubkey_item = NULL;
cJSON_ArrayForEach(pubkey_item, top_pubkeys) { // cJSON_ArrayForEach(pubkey_item, top_pubkeys) {
cJSON* pubkey = cJSON_GetObjectItem(pubkey_item, "pubkey"); // cJSON* pubkey = cJSON_GetObjectItem(pubkey_item, "pubkey");
cJSON* event_count = cJSON_GetObjectItem(pubkey_item, "event_count"); // cJSON* event_count = cJSON_GetObjectItem(pubkey_item, "event_count");
cJSON* percentage = cJSON_GetObjectItem(pubkey_item, "percentage"); // cJSON* percentage = cJSON_GetObjectItem(pubkey_item, "percentage");
if (pubkey && event_count && percentage) { // if (pubkey && event_count && percentage) {
const char* pubkey_str = cJSON_GetStringValue(pubkey); // const char* pubkey_str = cJSON_GetStringValue(pubkey);
char short_pubkey[20] = "..."; // char short_pubkey[20] = "...";
if (pubkey_str && strlen(pubkey_str) >= 16) { // if (pubkey_str && strlen(pubkey_str) >= 16) {
snprintf(short_pubkey, sizeof(short_pubkey), "%.16s...", pubkey_str); // snprintf(short_pubkey, sizeof(short_pubkey), "%.16s...", pubkey_str);
} // }
offset += snprintf(stats_text + offset, 16384 - offset, // offset += snprintf(stats_text + offset, 16384 - offset,
"%d\t%s\t%lld\t%.1f%%\n", // "%d\t%s\t%lld\t%.1f%%\n",
rank++, // rank++,
short_pubkey, // short_pubkey,
(long long)cJSON_GetNumberValue(event_count), // (long long)cJSON_GetNumberValue(event_count),
cJSON_GetNumberValue(percentage)); // cJSON_GetNumberValue(percentage));
} // }
} // }
} else { // } else {
offset += snprintf(stats_text + offset, 16384 - offset, // offset += snprintf(stats_text + offset, 16384 - offset,
"No pubkey data available\n"); // "No pubkey data available\n");
} // }
// Footer // Footer
offset += snprintf(stats_text + offset, 16384 - offset, offset += snprintf(stats_text + offset, 16384 - offset, "\n");
"\n✅ Statistics retrieved successfully");
cJSON_Delete(stats_obj); cJSON_Delete(stats_obj);
} else { } else {
@@ -2304,6 +2366,306 @@ int process_config_change_request(const char* admin_pubkey, const char* message)
return 1; // Confirmation sent return 1; // Confirmation sent
} }
// Forward declarations for relay event creation functions
cJSON* create_relay_metadata_event(cJSON* metadata);
cJSON* create_relay_dm_list_event(cJSON* dm_relays);
cJSON* create_relay_list_event(cJSON* relays);
// Handle create_relay_event admin commands
int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data, char* error_message, size_t error_size, struct lws* wsi) {
if (!event || !event_data || !error_message) {
return -1;
}
// Get request event ID for response correlation
cJSON* request_id_obj = cJSON_GetObjectItem(event, "id");
if (!request_id_obj || !cJSON_IsString(request_id_obj)) {
snprintf(error_message, error_size, "Missing request event ID");
return -1;
}
const char* request_id = cJSON_GetStringValue(request_id_obj);
// Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
}
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
// Create the relay event based on kind
cJSON* relay_event = NULL;
switch (kind) {
case 0: // User metadata
relay_event = create_relay_metadata_event(event_data);
break;
case 10050: // DM relay list
relay_event = create_relay_dm_list_event(event_data);
break;
case 10002: // Relay list
relay_event = create_relay_list_event(event_data);
break;
default: {
char response_content[256];
snprintf(response_content, sizeof(response_content),
"❌ Unsupported event kind: %d\n\nSupported kinds: 0 (metadata), 10050 (DM relays), 10002 (relays)",
kind);
return send_admin_response(sender_pubkey, response_content, request_id, error_message, error_size, wsi);
}
}
if (!relay_event) {
char response_content[128];
snprintf(response_content, sizeof(response_content),
"❌ Failed to create relay event (kind %d)\n\nCheck relay logs for details.", kind);
return send_admin_response(sender_pubkey, response_content, request_id, error_message, error_size, wsi);
}
// Store the event in database
int store_result = store_event(relay_event);
if (store_result != 0) {
cJSON_Delete(relay_event);
char response_content[128];
snprintf(response_content, sizeof(response_content),
"❌ Failed to store relay event (kind %d) in database", kind);
return send_admin_response(sender_pubkey, response_content, request_id, error_message, error_size, wsi);
}
// Broadcast the event to connected clients
broadcast_event_to_subscriptions(relay_event);
// Clean up
cJSON_Delete(relay_event);
// Send success response (plain text like other admin commands)
char response_content[256];
const char* kind_name = (kind == 0) ? "metadata" : (kind == 10050) ? "DM relay list" : "relay list";
snprintf(response_content, sizeof(response_content),
"✅ Relay event created successfully\n\nKind: %d (%s)\n\nEvent has been stored and broadcast to subscribers.",
kind, kind_name);
return send_admin_response(sender_pubkey, response_content, request_id, error_message, error_size, wsi);
}
// Create a relay metadata event (kind 0)
cJSON* create_relay_metadata_event(cJSON* metadata) {
if (!metadata || !cJSON_IsObject(metadata)) {
DEBUG_ERROR("Invalid metadata object for kind 0 event");
return NULL;
}
// 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) {
DEBUG_ERROR("Could not get relay keys for metadata event");
if (relay_privkey_hex) free(relay_privkey_hex);
return NULL;
}
// 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 for metadata event");
return NULL;
}
free(relay_privkey_hex);
// Create metadata content
char* content = cJSON_Print(metadata);
if (!content) {
DEBUG_ERROR("Failed to serialize metadata for kind 0 event");
return NULL;
}
// Create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
0, // kind (metadata)
content, // content
NULL, // tags (none for kind 0)
relay_privkey, // private key
(time_t)time(NULL) // timestamp
);
free(content);
if (!signed_event) {
DEBUG_ERROR("Failed to create and sign metadata event");
return NULL;
}
DEBUG_LOG("Created relay metadata event (kind 0)");
return signed_event;
}
// Create a relay DM list event (kind 10050)
cJSON* create_relay_dm_list_event(cJSON* dm_relays) {
if (!dm_relays || !cJSON_IsObject(dm_relays)) {
DEBUG_ERROR("Invalid DM relays object for kind 10050 event");
return NULL;
}
// 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) {
DEBUG_ERROR("Could not get relay keys for DM list event");
if (relay_privkey_hex) free(relay_privkey_hex);
return NULL;
}
// 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 for DM list event");
return NULL;
}
free(relay_privkey_hex);
// Create empty content for kind 10050
const char* content = "";
// Create tags from relay list
cJSON* tags = cJSON_CreateArray();
if (!tags) {
DEBUG_ERROR("Failed to create tags array for DM list event");
return NULL;
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(dm_relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
if (cJSON_IsString(relay_item)) {
const char* relay_url = cJSON_GetStringValue(relay_item);
if (relay_url && strlen(relay_url) > 0) {
cJSON* tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("relay"));
cJSON_AddItemToArray(tag, cJSON_CreateString(relay_url));
cJSON_AddItemToArray(tags, tag);
}
}
}
}
// Create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
10050, // kind (DM relay list)
content, // content (empty)
tags, // tags
relay_privkey, // private key
(time_t)time(NULL) // timestamp
);
cJSON_Delete(tags);
if (!signed_event) {
DEBUG_ERROR("Failed to create and sign DM list event");
return NULL;
}
DEBUG_LOG("Created relay DM list event (kind 10050)");
return signed_event;
}
// Create a relay list event (kind 10002)
cJSON* create_relay_list_event(cJSON* relays) {
if (!relays || !cJSON_IsObject(relays)) {
DEBUG_ERROR("Invalid relays object for kind 10002 event");
return NULL;
}
// 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) {
DEBUG_ERROR("Could not get relay keys for relay list event");
if (relay_privkey_hex) free(relay_privkey_hex);
return NULL;
}
// 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 for relay list event");
return NULL;
}
free(relay_privkey_hex);
// Create empty content for kind 10002
const char* content = "";
// Create tags from relay list
cJSON* tags = cJSON_CreateArray();
if (!tags) {
DEBUG_ERROR("Failed to create tags array for relay list event");
return NULL;
}
// Extract relays array
cJSON* relays_array = cJSON_GetObjectItem(relays, "relays");
if (relays_array && cJSON_IsArray(relays_array)) {
cJSON* relay_item = NULL;
cJSON_ArrayForEach(relay_item, relays_array) {
if (cJSON_IsObject(relay_item)) {
cJSON* url = cJSON_GetObjectItem(relay_item, "url");
cJSON* read = cJSON_GetObjectItem(relay_item, "read");
cJSON* write = cJSON_GetObjectItem(relay_item, "write");
if (url && cJSON_IsString(url)) {
const char* relay_url = cJSON_GetStringValue(url);
int read_flag = read && cJSON_IsBool(read) ? cJSON_IsTrue(read) : true;
int write_flag = write && cJSON_IsBool(write) ? cJSON_IsTrue(write) : true;
// Create marker string
const char* marker = NULL;
if (read_flag && write_flag) {
marker = ""; // No marker means both read and write
} else if (read_flag) {
marker = "read";
} else if (write_flag) {
marker = "write";
} else {
// Skip invalid entries
continue;
}
cJSON* tag = cJSON_CreateArray();
cJSON_AddItemToArray(tag, cJSON_CreateString("r"));
cJSON_AddItemToArray(tag, cJSON_CreateString(relay_url));
if (marker[0] != '\0') {
cJSON_AddItemToArray(tag, cJSON_CreateString(marker));
}
cJSON_AddItemToArray(tags, tag);
}
}
}
}
// Create and sign the event
cJSON* signed_event = nostr_create_and_sign_event(
10002, // kind (relay list)
content, // content (empty)
tags, // tags
relay_privkey, // private key
(time_t)time(NULL) // timestamp
);
cJSON_Delete(tags);
if (!signed_event) {
DEBUG_ERROR("Failed to create and sign relay list event");
return NULL;
}
DEBUG_LOG("Created relay list event (kind 10002)");
return signed_event;
}
// Handle monitoring system admin commands // Handle monitoring system admin commands
int handle_monitoring_command(cJSON* event, const char* command, char* error_message, size_t error_size, struct lws* wsi) { int handle_monitoring_command(cJSON* event, const char* command, char* error_message, size_t error_size, struct lws* wsi) {
if (!event || !command || !error_message) { if (!event || !command || !error_message) {

View File

@@ -64,4 +64,7 @@ void monitoring_on_event_stored(void);
void monitoring_on_subscription_change(void); void monitoring_on_subscription_change(void);
int get_monitoring_throttle_seconds(void); int get_monitoring_throttle_seconds(void);
// Kind 1 status posts
int generate_and_post_status_event(void);
#endif // API_H #endif // API_H

View File

@@ -85,6 +85,7 @@ int migrate_config_from_events_to_table(void);
int populate_config_table_from_event(const cJSON* event); int populate_config_table_from_event(const cJSON* event);
int handle_config_query_unified(cJSON* event, const char* query_type, char* error_message, size_t error_size, struct lws* wsi); int handle_config_query_unified(cJSON* event, const char* query_type, char* error_message, size_t error_size, struct lws* wsi);
int handle_config_set_unified(cJSON* event, const char* config_key, const char* config_value, char* error_message, size_t error_size, struct lws* wsi); int handle_config_set_unified(cJSON* event, const char* config_key, const char* config_value, char* error_message, size_t error_size, struct lws* wsi);
int handle_create_relay_event_unified(cJSON* event, const char* kind_str, const char* event_data_json, char* error_message, size_t error_size, struct lws* wsi);
// Forward declarations for tag parsing utilities // Forward declarations for tag parsing utilities
const char* get_first_tag_name(cJSON* event); const char* get_first_tag_name(cJSON* event);
@@ -92,6 +93,7 @@ const char* get_tag_value(cJSON* event, const char* tag_name, int value_index);
int parse_auth_query_parameters(cJSON* event, char** query_type, char** pattern_value); int parse_auth_query_parameters(cJSON* event, char** query_type, char** pattern_value);
int handle_config_update_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi); int handle_config_update_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi); int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi);
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
// Current configuration cache // Current configuration cache
@@ -1143,6 +1145,20 @@ static int validate_config_field(const char* key, const char* value, char* error
return 0; return 0;
} }
// NIP-59 Gift Wrap Timestamp Configuration
if (strcmp(key, "nip59_timestamp_max_delay_sec") == 0) {
if (!is_valid_non_negative_integer(value)) {
snprintf(error_msg, error_size, "invalid nip59_timestamp_max_delay_sec '%s' (must be non-negative integer)", value);
return -1;
}
long val = strtol(value, NULL, 10);
if (val > 604800) { // Max 7 days
snprintf(error_msg, error_size, "nip59_timestamp_max_delay_sec '%s' too large (max 604800 seconds = 7 days)", value);
return -1;
}
return 0;
}
if (strcmp(key, "nip42_auth_required_kinds") == 0) { if (strcmp(key, "nip42_auth_required_kinds") == 0) {
// Validate comma-separated list of kind numbers // Validate comma-separated list of kind numbers
if (!value || strlen(value) == 0) { if (!value || strlen(value) == 0) {
@@ -2536,7 +2552,7 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
} }
// Perform NIP-44 decryption (relay as recipient, admin as sender) // Perform NIP-44 decryption (relay as recipient, admin as sender)
char decrypted_text[4096]; // Buffer for decrypted content char decrypted_text[16384]; // Buffer for decrypted content (16KB)
int decrypt_result = nostr_nip44_decrypt(relay_privkey_bytes, sender_pubkey_bytes, content, decrypted_text, sizeof(decrypted_text)); int decrypt_result = nostr_nip44_decrypt(relay_privkey_bytes, sender_pubkey_bytes, content, decrypted_text, sizeof(decrypted_text));
// Clean up private key immediately after use // Clean up private key immediately after use
@@ -2549,51 +2565,17 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
return -1; return -1;
} }
// Check if decrypted content is a direct command array (DM control system) // Parse decrypted content as command array directly (NOT as NIP-17 inner event)
cJSON* potential_command_array = cJSON_Parse(decrypted_text); // Kind 23456 events contain direct command arrays: ["command_name", arg1, arg2, ...]
decrypted_content = cJSON_Parse(decrypted_text);
if (potential_command_array && cJSON_IsArray(potential_command_array)) {
// Route to DM admin system
int dm_result = process_dm_admin_command(potential_command_array, event, error_message, error_size, wsi);
cJSON_Delete(potential_command_array);
memset(decrypted_text, 0, sizeof(decrypted_text)); // Clear sensitive data
return dm_result;
}
// If not a direct command array, try parsing as inner event JSON (NIP-17)
cJSON* inner_event = potential_command_array; // Reuse the parsed JSON
if (!inner_event || !cJSON_IsObject(inner_event)) {
DEBUG_ERROR("error: decrypted content is not valid inner event JSON");
cJSON_Delete(inner_event);
snprintf(error_message, error_size, "error: decrypted content is not valid inner event JSON");
return -1;
}
// Extract content from inner event
cJSON* inner_content_obj = cJSON_GetObjectItem(inner_event, "content");
if (!inner_content_obj || !cJSON_IsString(inner_content_obj)) {
DEBUG_ERROR("error: inner event missing content field");
cJSON_Delete(inner_event);
snprintf(error_message, error_size, "error: inner event missing content field");
return -1;
}
const char* inner_content = cJSON_GetStringValue(inner_content_obj);
// Parse inner content as JSON array (the command array)
decrypted_content = cJSON_Parse(inner_content);
if (!decrypted_content || !cJSON_IsArray(decrypted_content)) { if (!decrypted_content || !cJSON_IsArray(decrypted_content)) {
DEBUG_ERROR("error: inner content is not valid JSON array"); DEBUG_ERROR("error: decrypted content is not valid command array");
cJSON_Delete(inner_event); cJSON_Delete(decrypted_content);
snprintf(error_message, error_size, "error: inner content is not valid JSON array"); snprintf(error_message, error_size, "error: decrypted content is not valid command array");
return -1; return -1;
} }
// Clean up inner event
cJSON_Delete(inner_event);
// Replace event content with decrypted command array for processing // Replace event content with decrypted command array for processing
cJSON_DeleteItemFromObject(event, "content"); cJSON_DeleteItemFromObject(event, "content");
cJSON_AddStringToObject(event, "content", "decrypted"); cJSON_AddStringToObject(event, "content", "decrypted");
@@ -2610,10 +2592,26 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
cJSON_AddItemToArray(command_tag, cJSON_Duplicate(first_item, 1)); cJSON_AddItemToArray(command_tag, cJSON_Duplicate(first_item, 1));
// Add remaining items as tag values // Add remaining items as tag values
// Convert non-string items (objects, arrays, numbers) to JSON strings
for (int i = 1; i < cJSON_GetArraySize(decrypted_content); i++) { for (int i = 1; i < cJSON_GetArraySize(decrypted_content); i++) {
cJSON* item = cJSON_GetArrayItem(decrypted_content, i); cJSON* item = cJSON_GetArrayItem(decrypted_content, i);
if (item) { if (item) {
if (cJSON_IsString(item)) {
// Keep strings as-is
cJSON_AddItemToArray(command_tag, cJSON_Duplicate(item, 1)); cJSON_AddItemToArray(command_tag, cJSON_Duplicate(item, 1));
} else if (cJSON_IsNumber(item)) {
// Convert numbers to strings
char num_str[32];
snprintf(num_str, sizeof(num_str), "%.0f", cJSON_GetNumberValue(item));
cJSON_AddItemToArray(command_tag, cJSON_CreateString(num_str));
} else if (cJSON_IsObject(item) || cJSON_IsArray(item)) {
// Convert objects/arrays to JSON strings
char* json_str = cJSON_PrintUnformatted(item);
if (json_str) {
cJSON_AddItemToArray(command_tag, cJSON_CreateString(json_str));
free(json_str);
}
}
} }
} }
@@ -2690,6 +2688,25 @@ int handle_kind_23456_unified(cJSON* event, char* error_message, size_t error_si
else if (strcmp(action_type, "stats_query") == 0) { else if (strcmp(action_type, "stats_query") == 0) {
return handle_stats_query_unified(event, error_message, error_size, wsi); return handle_stats_query_unified(event, error_message, error_size, wsi);
} }
else if (strcmp(action_type, "create_relay_event") == 0) {
const char* kind_str = get_tag_value(event, action_type, 1);
const char* event_data_json = get_tag_value(event, action_type, 2);
if (!kind_str || !event_data_json) {
DEBUG_ERROR("invalid: missing kind or event data");
snprintf(error_message, error_size, "invalid: missing kind or event data");
return -1;
}
return handle_create_relay_event_unified(event, kind_str, event_data_json, error_message, error_size, wsi);
}
else if (strcmp(action_type, "sql_query") == 0) {
const char* query = get_tag_value(event, action_type, 1);
if (!query) {
DEBUG_ERROR("invalid: missing sql_query parameter");
snprintf(error_message, error_size, "invalid: missing sql_query parameter");
return -1;
}
return handle_sql_query_unified(event, query, error_message, error_size, wsi);
}
else if (strcmp(action_type, "whitelist") == 0 || strcmp(action_type, "blacklist") == 0) { else if (strcmp(action_type, "whitelist") == 0 || strcmp(action_type, "blacklist") == 0) {
// Handle auth rule modifications (existing logic from process_admin_auth_event) // Handle auth rule modifications (existing logic from process_admin_auth_event)
return handle_auth_rule_modification_unified(event, error_message, error_size, wsi); return handle_auth_rule_modification_unified(event, error_message, error_size, wsi);
@@ -3489,6 +3506,41 @@ int handle_stats_query_unified(cJSON* event, char* error_message, size_t error_s
return -1; return -1;
} }
// Unified create relay event handler
int handle_create_relay_event_unified(cJSON* event, const char* kind_str, const char* event_data_json, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning
(void)wsi;
if (!event || !kind_str || !event_data_json) {
snprintf(error_message, error_size, "invalid: missing parameters for create_relay_event");
return -1;
}
// Parse kind string to integer
char* endptr;
int kind = (int)strtol(kind_str, &endptr, 10);
if (endptr == kind_str || *endptr != '\0') {
snprintf(error_message, error_size, "invalid: kind must be a valid integer");
return -1;
}
// Parse event data JSON
cJSON* event_data = cJSON_Parse(event_data_json);
if (!event_data) {
snprintf(error_message, error_size, "invalid: event_data must be valid JSON");
return -1;
}
// Call the existing implementation from api.c
extern int handle_create_relay_event_command(cJSON* event, int kind, cJSON* event_data, char* error_message, size_t error_size, struct lws* wsi);
int result = handle_create_relay_event_command(event, kind, event_data, error_message, error_size, wsi);
// Clean up
cJSON_Delete(event_data);
return result;
}
// Unified config update handler - handles multiple config objects in single atomic command // Unified config update handler - handles multiple config objects in single atomic command
int handle_config_update_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) { int handle_config_update_unified(cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
// Suppress unused parameter warning // Suppress unused parameter warning

View File

@@ -78,7 +78,13 @@ static const struct {
// Trust proxy headers (X-Forwarded-For, X-Real-IP) for accurate client IP detection // Trust proxy headers (X-Forwarded-For, X-Real-IP) for accurate client IP detection
// Safe for informational/debugging use. Only becomes a security concern if you implement // Safe for informational/debugging use. Only becomes a security concern if you implement
// IP-based rate limiting or access control (which would require firewall protection anyway) // IP-based rate limiting or access control (which would require firewall protection anyway)
{"trust_proxy_headers", "true"} {"trust_proxy_headers", "true"},
// NIP-59 Gift Wrap Timestamp Configuration
{"nip59_timestamp_max_delay_sec", "0"},
// Kind 1 Status Posts
{"kind_1_status_posts_hours", "1"}
}; };
// Number of default configuration values // Number of default configuration values

Binary file not shown.

View File

@@ -80,6 +80,7 @@ extern int handle_sql_query_unified(cJSON* event, const char* query, char* error
// Process direct command arrays (DM control system) // Process direct command arrays (DM control system)
// This handles commands sent as direct JSON arrays, not wrapped in inner events // This handles commands sent as direct JSON arrays, not wrapped in inner events
// Note: create_relay_event is NOT supported via DMs - use Kind 23456 events only
int process_dm_admin_command(cJSON* command_array, cJSON* event, char* error_message, size_t error_size, struct lws* wsi) { int process_dm_admin_command(cJSON* command_array, cJSON* event, char* error_message, size_t error_size, struct lws* wsi) {
if (!command_array || !cJSON_IsArray(command_array) || !event) { if (!command_array || !cJSON_IsArray(command_array) || !event) {
DEBUG_ERROR("DM Admin: Invalid command array or event"); DEBUG_ERROR("DM Admin: Invalid command array or event");
@@ -231,19 +232,27 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
return NULL; return NULL;
} }
DEBUG_INFO("DM_ADMIN: Received potential NIP-17 gift wrap event for processing");
// Step 1: Validate it's addressed to us // Step 1: Validate it's addressed to us
if (!is_nip17_gift_wrap_for_relay(gift_wrap_event)) { if (!is_nip17_gift_wrap_for_relay(gift_wrap_event)) {
DEBUG_INFO("DM_ADMIN: Event is not a valid gift wrap for this relay - rejecting");
strncpy(error_message, "NIP-17: Event is not a valid gift wrap for this relay", error_size - 1); strncpy(error_message, "NIP-17: Event is not a valid gift wrap for this relay", error_size - 1);
return NULL; return NULL;
} }
DEBUG_INFO("DM_ADMIN: Valid NIP-17 gift wrap confirmed for this relay");
// Step 2: Get relay private key for decryption // Step 2: Get relay private key for decryption
char* relay_privkey_hex = get_relay_private_key(); char* relay_privkey_hex = get_relay_private_key();
if (!relay_privkey_hex) { if (!relay_privkey_hex) {
DEBUG_INFO("DM_ADMIN: Could not get relay private key for decryption");
strncpy(error_message, "NIP-17: Could not get relay private key for decryption", error_size - 1); strncpy(error_message, "NIP-17: Could not get relay private key for decryption", error_size - 1);
return NULL; return NULL;
} }
DEBUG_INFO("DM_ADMIN: Retrieved relay private key for decryption");
// Convert hex private key to bytes // Convert hex private key to bytes
unsigned char relay_privkey[32]; unsigned char relay_privkey[32];
if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey, sizeof(relay_privkey)) != 0) { if (nostr_hex_to_bytes(relay_privkey_hex, relay_privkey, sizeof(relay_privkey)) != 0) {
@@ -254,10 +263,13 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
} }
free(relay_privkey_hex); free(relay_privkey_hex);
DEBUG_INFO("DM_ADMIN: Converted relay private key to bytes successfully");
// Step 3: Decrypt and parse inner event using library function // Step 3: Decrypt and parse inner event using library function
DEBUG_INFO("DM_ADMIN: Attempting to decrypt NIP-17 gift wrap using nostr_nip17_receive_dm");
cJSON* inner_dm = nostr_nip17_receive_dm(gift_wrap_event, relay_privkey); cJSON* inner_dm = nostr_nip17_receive_dm(gift_wrap_event, relay_privkey);
if (!inner_dm) { if (!inner_dm) {
DEBUG_ERROR("NIP-17: nostr_nip17_receive_dm returned NULL"); DEBUG_INFO("DM_ADMIN: nostr_nip17_receive_dm returned NULL - decryption failed");
// Debug: Print the gift wrap event // Debug: Print the gift wrap event
char* gift_wrap_debug = cJSON_Print(gift_wrap_event); char* gift_wrap_debug = cJSON_Print(gift_wrap_event);
if (gift_wrap_debug) { if (gift_wrap_debug) {
@@ -273,12 +285,17 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
} }
privkey_hex[64] = '\0'; privkey_hex[64] = '\0';
DEBUG_INFO("DM_ADMIN: NIP-17 decryption failed - returning error");
strncpy(error_message, "NIP-17: Failed to decrypt and parse inner DM event", error_size - 1); strncpy(error_message, "NIP-17: Failed to decrypt and parse inner DM event", error_size - 1);
return NULL; return NULL;
} }
DEBUG_INFO("DM_ADMIN: Successfully decrypted NIP-17 gift wrap, processing inner DM");
// Step 4: Process admin command // Step 4: Process admin command
DEBUG_INFO("DM_ADMIN: Processing decrypted admin command");
int result = process_nip17_admin_command(inner_dm, error_message, error_size, wsi); int result = process_nip17_admin_command(inner_dm, error_message, error_size, wsi);
DEBUG_INFO("DM_ADMIN: Admin command processing completed with result: %d", result);
// Step 5: For plain text commands (stats/config), the response is already handled // Step 5: For plain text commands (stats/config), the response is already handled
// Only create a generic response for other command types that don't handle their own responses // Only create a generic response for other command types that don't handle their own responses
@@ -351,13 +368,17 @@ cJSON* process_nip17_admin_message(cJSON* gift_wrap_event, char* error_message,
if (success_dm) { if (success_dm) {
cJSON* success_gift_wraps[1]; cJSON* success_gift_wraps[1];
// Get timestamp delay configuration
long max_delay_sec = get_config_int("nip59_timestamp_max_delay_sec", 0);
int send_result = nostr_nip17_send_dm( int send_result = nostr_nip17_send_dm(
success_dm, // dm_event success_dm, // dm_event
(const char**)&sender_pubkey, // recipient_pubkeys (const char**)&sender_pubkey, // recipient_pubkeys
1, // num_recipients 1, // num_recipients
relay_privkey, // sender_private_key relay_privkey, // sender_private_key
success_gift_wraps, // gift_wraps_out success_gift_wraps, // gift_wraps_out
1 // max_gift_wraps 1, // max_gift_wraps
max_delay_sec // max_delay_sec
); );
cJSON_Delete(success_dm); cJSON_Delete(success_dm);
@@ -457,18 +478,23 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
return -1; return -1;
} }
DEBUG_INFO("DM_ADMIN: Processing NIP-17 admin command from decrypted DM");
// Extract content from DM // Extract content from DM
cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content"); cJSON* content_obj = cJSON_GetObjectItem(dm_event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) { if (!content_obj || !cJSON_IsString(content_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing content field");
strncpy(error_message, "NIP-17: DM missing content", error_size - 1); strncpy(error_message, "NIP-17: DM missing content", error_size - 1);
return -1; return -1;
} }
const char* dm_content = cJSON_GetStringValue(content_obj); const char* dm_content = cJSON_GetStringValue(content_obj);
DEBUG_INFO("DM_ADMIN: Extracted DM content: %.100s%s", dm_content, strlen(dm_content) > 100 ? "..." : "");
// Check if sender is admin before processing any commands // Check if sender is admin before processing any commands
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey"); cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) { if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey - treating as user DM");
return 0; // Not an error, just treat as user DM return 0; // Not an error, just treat as user DM
} }
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj); const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
@@ -477,11 +503,16 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
const char* admin_pubkey = get_config_value("admin_pubkey"); const char* admin_pubkey = get_config_value("admin_pubkey");
int is_admin = admin_pubkey && strlen(admin_pubkey) > 0 && strcmp(sender_pubkey, admin_pubkey) == 0; int is_admin = admin_pubkey && strlen(admin_pubkey) > 0 && strcmp(sender_pubkey, admin_pubkey) == 0;
DEBUG_INFO("DM_ADMIN: Sender pubkey: %.16s... (admin: %s)", sender_pubkey, is_admin ? "YES" : "NO");
// Parse DM content as JSON array of commands // Parse DM content as JSON array of commands
DEBUG_INFO("DM_ADMIN: Attempting to parse DM content as JSON command array");
cJSON* command_array = cJSON_Parse(dm_content); cJSON* command_array = cJSON_Parse(dm_content);
if (!command_array || !cJSON_IsArray(command_array)) { if (!command_array || !cJSON_IsArray(command_array)) {
DEBUG_INFO("DM_ADMIN: Content is not a JSON array, checking for plain text commands");
// If content is not a JSON array, check for plain text commands // If content is not a JSON array, check for plain text commands
if (is_admin) { if (is_admin) {
DEBUG_INFO("DM_ADMIN: Processing plain text admin command");
// Convert content to lowercase for case-insensitive matching // Convert content to lowercase for case-insensitive matching
char content_lower[256]; char content_lower[256];
size_t content_len = strlen(dm_content); size_t content_len = strlen(dm_content);
@@ -498,8 +529,10 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
// Check for stats commands // Check for stats commands
if (strstr(content_lower, "stats") != NULL || strstr(content_lower, "statistics") != NULL) { if (strstr(content_lower, "stats") != NULL || strstr(content_lower, "statistics") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing stats command");
char* stats_text = generate_stats_text(); char* stats_text = generate_stats_text();
if (!stats_text) { if (!stats_text) {
DEBUG_INFO("DM_ADMIN: Failed to generate stats text");
return -1; return -1;
} }
@@ -512,12 +545,15 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
return -1; return -1;
} }
DEBUG_INFO("DM_ADMIN: Stats command processed successfully");
return 0; return 0;
} }
// Check for config commands // Check for config commands
else if (strstr(content_lower, "config") != NULL || strstr(content_lower, "configuration") != NULL) { else if (strstr(content_lower, "config") != NULL || strstr(content_lower, "configuration") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing config command");
char* config_text = generate_config_text(); char* config_text = generate_config_text();
if (!config_text) { if (!config_text) {
DEBUG_INFO("DM_ADMIN: Failed to generate config text");
return -1; return -1;
} }
@@ -530,15 +566,47 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
return -1; return -1;
} }
DEBUG_INFO("DM_ADMIN: Config command processed successfully");
return 0;
}
// Check for status commands
else if (strstr(content_lower, "status") != NULL) {
DEBUG_INFO("DM_ADMIN: Processing status command");
// Create synthetic event for system_command handler
cJSON* synthetic_event = cJSON_CreateObject();
cJSON_AddNumberToObject(synthetic_event, "kind", 23456);
cJSON_AddStringToObject(synthetic_event, "pubkey", sender_pubkey);
// Create tags array with system_command
cJSON* tags = cJSON_CreateArray();
cJSON* cmd_tag = cJSON_CreateArray();
cJSON_AddItemToArray(cmd_tag, cJSON_CreateString("system_command"));
cJSON_AddItemToArray(cmd_tag, cJSON_CreateString("system_status"));
cJSON_AddItemToArray(tags, cmd_tag);
cJSON_AddItemToObject(synthetic_event, "tags", tags);
char error_msg[256];
int result = handle_system_command_unified(synthetic_event, "system_status", error_msg, sizeof(error_msg), wsi);
cJSON_Delete(synthetic_event);
if (result != 0) {
DEBUG_ERROR(error_msg);
return -1;
}
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
return 0; return 0;
} }
else { else {
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
// Check if it's a confirmation response (yes/no) // Check if it's a confirmation response (yes/no)
int confirmation_result = handle_config_confirmation(sender_pubkey, dm_content); int confirmation_result = handle_config_confirmation(sender_pubkey, dm_content);
if (confirmation_result != 0) { if (confirmation_result != 0) {
if (confirmation_result > 0) { if (confirmation_result > 0) {
// Configuration confirmation processed successfully DEBUG_INFO("DM_ADMIN: Configuration confirmation processed successfully");
} else if (confirmation_result == -2) { } else if (confirmation_result == -2) {
DEBUG_INFO("DM_ADMIN: No pending changes to confirm");
// No pending changes // No pending changes
char no_pending_msg[256]; char no_pending_msg[256];
snprintf(no_pending_msg, sizeof(no_pending_msg), snprintf(no_pending_msg, sizeof(no_pending_msg),
@@ -558,6 +626,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
int config_result = process_config_change_request(sender_pubkey, dm_content); int config_result = process_config_change_request(sender_pubkey, dm_content);
if (config_result != 0) { if (config_result != 0) {
if (config_result > 0) { if (config_result > 0) {
DEBUG_INFO("DM_ADMIN: Configuration change request processed successfully");
return 1; // Return positive value to indicate response was handled return 1; // Return positive value to indicate response was handled
} else { } else {
DEBUG_ERROR("NIP-17: Configuration change request failed"); DEBUG_ERROR("NIP-17: Configuration change request failed");
@@ -565,22 +634,28 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
} }
} }
DEBUG_INFO("DM_ADMIN: Unrecognized plain text admin command");
return 0; // Admin sent unrecognized plain text, treat as user DM return 0; // Admin sent unrecognized plain text, treat as user DM
} }
} else { } else {
DEBUG_INFO("DM_ADMIN: Non-admin user sent plain text - treating as user DM");
// Not admin, treat as user DM // Not admin, treat as user DM
return 0; return 0;
} }
} }
DEBUG_INFO("DM_ADMIN: Successfully parsed JSON command array");
// Check if this is a "stats" command // Check if this is a "stats" command
if (cJSON_GetArraySize(command_array) > 0) { if (cJSON_GetArraySize(command_array) > 0) {
cJSON* first_item = cJSON_GetArrayItem(command_array, 0); cJSON* first_item = cJSON_GetArrayItem(command_array, 0);
if (cJSON_IsString(first_item) && strcmp(cJSON_GetStringValue(first_item), "stats") == 0) { if (cJSON_IsString(first_item) && strcmp(cJSON_GetStringValue(first_item), "stats") == 0) {
DEBUG_INFO("DM_ADMIN: Processing JSON stats command");
// Get sender pubkey for response // Get sender pubkey for response
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey"); cJSON* sender_pubkey_obj = cJSON_GetObjectItem(dm_event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) { if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
cJSON_Delete(command_array); cJSON_Delete(command_array);
DEBUG_INFO("DM_ADMIN: DM missing sender pubkey for stats command");
strncpy(error_message, "NIP-17: DM missing sender pubkey", error_size - 1); strncpy(error_message, "NIP-17: DM missing sender pubkey", error_size - 1);
return -1; return -1;
} }
@@ -590,6 +665,7 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
char* stats_json = generate_stats_json(); char* stats_json = generate_stats_json();
if (!stats_json) { if (!stats_json) {
cJSON_Delete(command_array); cJSON_Delete(command_array);
DEBUG_INFO("DM_ADMIN: Failed to generate stats JSON");
strncpy(error_message, "NIP-17: Failed to generate stats", error_size - 1); strncpy(error_message, "NIP-17: Failed to generate stats", error_size - 1);
return -1; return -1;
} }
@@ -605,10 +681,12 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
return -1; return -1;
} }
DEBUG_INFO("DM_ADMIN: JSON stats command processed successfully");
return 0; return 0;
} }
} }
DEBUG_INFO("DM_ADMIN: Delegating to unified admin processing for command array");
// For other commands, delegate to existing admin processing // For other commands, delegate to existing admin processing
// Create a synthetic kind 23456 event with the DM content // Create a synthetic kind 23456 event with the DM content
cJSON* synthetic_event = cJSON_CreateObject(); cJSON* synthetic_event = cJSON_CreateObject();
@@ -628,10 +706,12 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
} }
// Process as regular admin event // Process as regular admin event
DEBUG_INFO("DM_ADMIN: Processing synthetic admin event");
int result = process_admin_event_in_config(synthetic_event, error_message, error_size, wsi); int result = process_admin_event_in_config(synthetic_event, error_message, error_size, wsi);
cJSON_Delete(synthetic_event); cJSON_Delete(synthetic_event);
cJSON_Delete(command_array); cJSON_Delete(command_array);
DEBUG_INFO("DM_ADMIN: Unified admin processing completed with result: %d", result);
return result; return result;
} }

File diff suppressed because one or more lines are too long

View File

@@ -95,7 +95,6 @@ void update_subscription_manager_config(void);
void log_subscription_created(const subscription_t* sub); void log_subscription_created(const subscription_t* sub);
void log_subscription_closed(const char* sub_id, const char* client_ip, const char* reason); void log_subscription_closed(const char* sub_id, const char* client_ip, const char* reason);
void log_subscription_disconnected(const char* client_ip); void log_subscription_disconnected(const char* client_ip);
void log_event_broadcast(const char* event_id, const char* sub_id, const char* client_ip);
void update_subscription_events_sent(const char* sub_id, int events_sent); void update_subscription_events_sent(const char* sub_id, int events_sent);
// Forward declarations for NIP-01 event handling // Forward declarations for NIP-01 event handling
@@ -148,6 +147,7 @@ int mark_event_as_deleted(const char* event_id, const char* deletion_event_id, c
// Forward declaration for database functions // Forward declaration for database functions
int store_event(cJSON* event); int store_event(cJSON* event);
cJSON* retrieve_event(const char* event_id);
// Forward declaration for monitoring system // Forward declaration for monitoring system
void monitoring_on_event_stored(void); void monitoring_on_event_stored(void);
@@ -588,93 +588,6 @@ const char* extract_d_tag_value(cJSON* tags) {
return NULL; return NULL;
} }
// Check and handle replaceable events according to NIP-01
int check_and_handle_replaceable_event(int kind, const char* pubkey, long created_at) {
if (!g_db || !pubkey) return 0;
const char* sql =
"SELECT created_at FROM events WHERE kind = ? AND pubkey = ? ORDER BY created_at DESC LIMIT 1";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
return 0; // Allow storage on DB error
}
sqlite3_bind_int(stmt, 1, kind);
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_STATIC);
int result = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
long existing_created_at = sqlite3_column_int64(stmt, 0);
if (created_at <= existing_created_at) {
result = -1; // Older or same timestamp, reject
} else {
// Delete older versions
const char* delete_sql = "DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at < ?";
sqlite3_stmt* delete_stmt;
if (sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int(delete_stmt, 1, kind);
sqlite3_bind_text(delete_stmt, 2, pubkey, -1, SQLITE_STATIC);
sqlite3_bind_int64(delete_stmt, 3, created_at);
sqlite3_step(delete_stmt);
sqlite3_finalize(delete_stmt);
}
}
}
sqlite3_finalize(stmt);
return result;
}
// Check and handle addressable events according to NIP-01
int check_and_handle_addressable_event(int kind, const char* pubkey, const char* d_tag_value, long created_at) {
if (!g_db || !pubkey) return 0;
// If no d tag, treat as regular replaceable
if (!d_tag_value) {
return check_and_handle_replaceable_event(kind, pubkey, created_at);
}
const char* sql =
"SELECT created_at FROM events WHERE kind = ? AND pubkey = ? AND json_extract(tags, '$[*][1]') = ? "
"AND json_extract(tags, '$[*][0]') = 'd' ORDER BY created_at DESC LIMIT 1";
sqlite3_stmt* stmt;
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
return 0; // Allow storage on DB error
}
sqlite3_bind_int(stmt, 1, kind);
sqlite3_bind_text(stmt, 2, pubkey, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, d_tag_value, -1, SQLITE_STATIC);
int result = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
long existing_created_at = sqlite3_column_int64(stmt, 0);
if (created_at <= existing_created_at) {
result = -1; // Older or same timestamp, reject
} else {
// Delete older versions with same kind, pubkey, and d tag
const char* delete_sql =
"DELETE FROM events WHERE kind = ? AND pubkey = ? AND created_at < ? "
"AND json_extract(tags, '$[*][1]') = ? AND json_extract(tags, '$[*][0]') = 'd'";
sqlite3_stmt* delete_stmt;
if (sqlite3_prepare_v2(g_db, delete_sql, -1, &delete_stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int(delete_stmt, 1, kind);
sqlite3_bind_text(delete_stmt, 2, pubkey, -1, SQLITE_STATIC);
sqlite3_bind_int64(delete_stmt, 3, created_at);
sqlite3_bind_text(delete_stmt, 4, d_tag_value, -1, SQLITE_STATIC);
sqlite3_step(delete_stmt);
sqlite3_finalize(delete_stmt);
}
}
}
sqlite3_finalize(stmt);
return result;
}
// Store event in database // Store event in database
int store_event(cJSON* event) { int store_event(cJSON* event) {
@@ -699,6 +612,13 @@ int store_event(cJSON* event) {
// Classify event type // Classify event type
event_type_t type = classify_event_kind((int)cJSON_GetNumberValue(kind)); event_type_t type = classify_event_kind((int)cJSON_GetNumberValue(kind));
// EPHEMERAL EVENTS (kinds 20000-29999) should NOT be stored
if (type == EVENT_TYPE_EPHEMERAL) {
DEBUG_LOG("Ephemeral event (kind %d) - broadcasting only, not storing",
(int)cJSON_GetNumberValue(kind));
return 0; // Success - event was handled but not stored
}
// Serialize tags to JSON (use empty array if no tags) // Serialize tags to JSON (use empty array if no tags)
char* tags_json = NULL; char* tags_json = NULL;
if (tags && cJSON_IsArray(tags)) { if (tags && cJSON_IsArray(tags)) {
@@ -737,11 +657,36 @@ int store_event(cJSON* event) {
// Execute statement // Execute statement
rc = sqlite3_step(stmt); rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
const char* err_msg = sqlite3_errmsg(g_db);
int extended_errcode = sqlite3_extended_errcode(g_db);
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
}
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) { if (rc != SQLITE_DONE) {
if (rc == SQLITE_CONSTRAINT) { if (rc == SQLITE_CONSTRAINT) {
DEBUG_WARN("Event already exists in database"); DEBUG_WARN("Event already exists in database");
// Add TRACE level debug to show both events
if (g_debug_level >= DEBUG_LEVEL_TRACE) {
// Get the existing event from database
cJSON* existing_event = retrieve_event(cJSON_GetStringValue(id));
if (existing_event) {
char* existing_json = cJSON_Print(existing_event);
DEBUG_TRACE("EXISTING EVENT: %s", existing_json ? existing_json : "NULL");
free(existing_json);
cJSON_Delete(existing_event);
} else {
DEBUG_TRACE("EXISTING EVENT: Could not retrieve existing event");
}
// Show the event we're trying to insert
char* new_json = cJSON_Print(event);
DEBUG_TRACE("NEW EVENT: %s", new_json ? new_json : "NULL");
free(new_json);
}
free(tags_json); free(tags_json);
return 0; // Not an error, just duplicate return 0; // Not an error, just duplicate
} }
@@ -1715,70 +1660,7 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
// COMMENTED OUT: Old incremental config building code replaced by unified startup sequence
// The new first_time_startup_sequence() function handles all config creation atomically
/*
// Handle configuration setup after database is initialized
// Always populate defaults directly in config table (abandoning legacy event signing)
// Populate default config values in table
if (populate_default_config_values() != 0) {
DEBUG_ERROR("Failed to populate default config values");
cleanup_configuration_system();
nostr_cleanup();
close_database();
return 1;
}
// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM config", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
DEBUG_LOG("Config table row count after populate_default_config_values(): %d", row_count);
}
sqlite3_finalize(stmt);
}
}
// DEBUG_GUARD_END
// Apply CLI overrides now that database is available
if (cli_options.port_override > 0) {
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", cli_options.port_override);
if (update_config_in_table("relay_port", port_str) != 0) {
DEBUG_ERROR("Failed to update relay port override in config table");
cleanup_configuration_system();
nostr_cleanup();
close_database();
return 1;
}
printf(" Port: %d (overriding default)\n", cli_options.port_override);
}
// Add pubkeys to config table (single authoritative call)
if (add_pubkeys_to_config_table() != 0) {
DEBUG_ERROR("Failed to add pubkeys to config table");
cleanup_configuration_system();
nostr_cleanup();
close_database();
return 1;
}
// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM config", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
DEBUG_LOG("Config table row count after add_pubkeys_to_config_table() (first-time): %d", row_count);
}
sqlite3_finalize(stmt);
}
}
// DEBUG_GUARD_END
*/
} else { } else {
// Find existing database file // Find existing database file
char** existing_files = find_existing_db_files(); char** existing_files = find_existing_db_files();
@@ -1866,102 +1748,6 @@ int main(int argc, char* argv[]) {
sqlite3_finalize(stmt); sqlite3_finalize(stmt);
} }
} }
// DEBUG_GUARD_END
// COMMENTED OUT: Old incremental config building code replaced by unified startup sequence
// The new startup_existing_relay() function handles all config loading atomically
/*
// Ensure default configuration values are populated (for any missing keys)
// This must be done AFTER database initialization
// COMMENTED OUT: Don't modify existing database config on restart
// if (populate_default_config_values() != 0) {
// DEBUG_WARN("Failed to populate default config values for existing relay - continuing");
// }
// Load configuration from database
cJSON* config_event = load_config_event_from_database(relay_pubkey);
if (config_event) {
if (apply_configuration_from_event(config_event) != 0) {
DEBUG_WARN("Failed to apply configuration from database");
}
cJSON_Delete(config_event);
} else {
// This is expected for relays using table-based configuration
// No longer a warning - just informational
}
// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM config", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
DEBUG_LOG("Config table row count before checking pubkeys: %d", row_count);
}
sqlite3_finalize(stmt);
}
}
// DEBUG_GUARD_END
// Ensure pubkeys are in config table for existing relay
// This handles migration from old event-based config to table-based config
const char* admin_pubkey_from_table = get_config_value_from_table("admin_pubkey");
const char* relay_pubkey_from_table = get_config_value_from_table("relay_pubkey");
int need_to_add_pubkeys = 0;
// Check if admin_pubkey is missing or invalid
if (!admin_pubkey_from_table || strlen(admin_pubkey_from_table) != 64) {
DEBUG_WARN("Admin pubkey missing or invalid in config table - will regenerate from cache");
need_to_add_pubkeys = 1;
}
if (admin_pubkey_from_table) free((char*)admin_pubkey_from_table);
// Check if relay_pubkey is missing or invalid
if (!relay_pubkey_from_table || strlen(relay_pubkey_from_table) != 64) {
DEBUG_WARN("Relay pubkey missing or invalid in config table - will regenerate from cache");
need_to_add_pubkeys = 1;
}
if (relay_pubkey_from_table) free((char*)relay_pubkey_from_table);
// If either pubkey is missing, call add_pubkeys_to_config_table to populate both
if (need_to_add_pubkeys) {
if (add_pubkeys_to_config_table() != 0) {
DEBUG_ERROR("Failed to add pubkeys to config table for existing relay");
cleanup_configuration_system();
nostr_cleanup();
close_database();
return 1;
}
// DEBUG_GUARD_START
if (g_debug_level >= DEBUG_LEVEL_DEBUG) {
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM config", -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
DEBUG_LOG("Config table row count after add_pubkeys_to_config_table(): %d", row_count);
}
sqlite3_finalize(stmt);
}
}
// DEBUG_GUARD_END
}
// Apply CLI overrides for existing relay (port override should work even for existing relays)
if (cli_options.port_override > 0) {
char port_str[16];
snprintf(port_str, sizeof(port_str), "%d", cli_options.port_override);
if (update_config_in_table("relay_port", port_str) != 0) {
DEBUG_ERROR("Failed to update relay port override in config table for existing relay");
cleanup_configuration_system();
nostr_cleanup();
close_database();
return 1;
}
printf(" Port: %d (overriding configured port)\n", cli_options.port_override);
}
*/
// Free memory // Free memory
free(relay_pubkey); free(relay_pubkey);

View File

@@ -10,10 +10,10 @@
#define MAIN_H #define MAIN_H
// Version information (auto-updated by build system) // Version information (auto-updated by build system)
#define VERSION "v0.7.39" #define VERSION_MAJOR 1
#define VERSION_MAJOR 0 #define VERSION_MINOR 0
#define VERSION_MINOR 7 #define VERSION_PATCH 2
#define VERSION_PATCH 39 #define VERSION "v1.0.2"
// Relay metadata (authoritative source for NIP-11 information) // Relay metadata (authoritative source for NIP-11 information)
#define RELAY_NAME "C-Relay" #define RELAY_NAME "C-Relay"

View File

@@ -12,6 +12,7 @@
#include <string.h> #include <string.h>
#include <stdlib.h> #include <stdlib.h>
#include <time.h> #include <time.h>
#include "websockets.h"
// Forward declaration for notice message function // Forward declaration for notice message function
@@ -22,23 +23,7 @@ int nostr_nip42_generate_challenge(char *challenge_buffer, size_t buffer_size);
int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id, int nostr_nip42_verify_auth_event(cJSON *event, const char *challenge_id,
const char *relay_url, int time_tolerance_seconds); const char *relay_url, int time_tolerance_seconds);
// Forward declaration for per_session_data struct (defined in main.c) // Forward declaration for per_session_data struct (defined in websockets.h)
struct per_session_data {
int authenticated;
void* subscriptions; // Head of this session's subscription list
pthread_mutex_t session_lock; // Per-session thread safety
char client_ip[41]; // Client IP for logging
int subscription_count; // Number of subscriptions for this session
// NIP-42 Authentication State
char authenticated_pubkey[65]; // Authenticated public key (64 hex + null)
char active_challenge[65]; // Current challenge for this session (64 hex + null)
time_t challenge_created; // When challenge was created
time_t challenge_expires; // Challenge expiration time
int nip42_auth_required_events; // Whether NIP-42 auth is required for EVENT submission
int nip42_auth_required_subscriptions; // Whether NIP-42 auth is required for REQ operations
int auth_challenge_sent; // Whether challenge has been sent (0/1)
};
// Send NIP-42 authentication challenge to client // Send NIP-42 authentication challenge to client

View File

@@ -1,5 +1,4 @@
/* Embedded SQL Schema for C Nostr Relay /* Embedded SQL Schema for C Nostr Relay
* Generated from db/schema.sql - Do not edit manually
* Schema Version: 8 * Schema Version: 8
*/ */
#ifndef SQL_SCHEMA_H #ifndef SQL_SCHEMA_H
@@ -93,16 +92,6 @@ FROM events\n\
WHERE kind = 33334\n\ WHERE kind = 33334\n\
ORDER BY created_at DESC;\n\ ORDER BY created_at DESC;\n\
\n\ \n\
-- Optimization: Trigger for automatic cleanup of ephemeral events older than 1 hour\n\
CREATE TRIGGER cleanup_ephemeral_events\n\
AFTER INSERT ON events\n\
WHEN NEW.event_type = 'ephemeral'\n\
BEGIN\n\
DELETE FROM events \n\
WHERE event_type = 'ephemeral' \n\
AND first_seen < (strftime('%s', 'now') - 3600);\n\
END;\n\
\n\
-- Replaceable event handling trigger\n\ -- Replaceable event handling trigger\n\
CREATE TRIGGER handle_replaceable_events\n\ CREATE TRIGGER handle_replaceable_events\n\
AFTER INSERT ON events\n\ AFTER INSERT ON events\n\
@@ -209,15 +198,6 @@ CREATE TABLE subscription_metrics (\n\
UNIQUE(date)\n\ UNIQUE(date)\n\
);\n\ );\n\
\n\ \n\
-- Event broadcasting log (optional, for detailed analytics)\n\
CREATE TABLE event_broadcasts (\n\
id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
event_id TEXT NOT NULL, -- Event ID that was broadcast\n\
subscription_id TEXT NOT NULL, -- Subscription that received it\n\
client_ip TEXT NOT NULL, -- Client IP\n\
broadcast_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),\n\
FOREIGN KEY (event_id) REFERENCES events(id)\n\
);\n\
\n\ \n\
-- Indexes for subscription logging performance\n\ -- Indexes for subscription logging performance\n\
CREATE INDEX idx_subscriptions_id ON subscriptions(subscription_id);\n\ CREATE INDEX idx_subscriptions_id ON subscriptions(subscription_id);\n\
@@ -228,9 +208,6 @@ CREATE INDEX idx_subscriptions_wsi ON subscriptions(wsi_pointer);\n\
\n\ \n\
CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC);\n\ CREATE INDEX idx_subscription_metrics_date ON subscription_metrics(date DESC);\n\
\n\ \n\
CREATE INDEX idx_event_broadcasts_event ON event_broadcasts(event_id);\n\
CREATE INDEX idx_event_broadcasts_sub ON event_broadcasts(subscription_id);\n\
CREATE INDEX idx_event_broadcasts_time ON event_broadcasts(broadcast_at DESC);\n\
\n\ \n\
-- Trigger to update subscription duration when ended\n\ -- Trigger to update subscription duration when ended\n\
CREATE TRIGGER update_subscription_duration\n\ CREATE TRIGGER update_subscription_duration\n\
@@ -259,17 +236,20 @@ ORDER BY date DESC;\n\
-- View for current active subscriptions (from log perspective)\n\ -- View for current active subscriptions (from log perspective)\n\
CREATE VIEW active_subscriptions_log AS\n\ CREATE VIEW active_subscriptions_log AS\n\
SELECT\n\ SELECT\n\
subscription_id,\n\ s.subscription_id,\n\
client_ip,\n\ s.client_ip,\n\
filter_json,\n\ s.filter_json,\n\
events_sent,\n\ s.events_sent,\n\
created_at,\n\ s.created_at,\n\
(strftime('%s', 'now') - created_at) as duration_seconds\n\ (strftime('%s', 'now') - s.created_at) as duration_seconds,\n\
FROM subscriptions\n\ s.wsi_pointer\n\
WHERE event_type = 'created'\n\ FROM subscriptions s\n\
AND subscription_id NOT IN (\n\ WHERE s.event_type = 'created'\n\
SELECT subscription_id FROM subscriptions\n\ AND NOT EXISTS (\n\
WHERE event_type IN ('closed', 'expired', 'disconnected')\n\ SELECT 1 FROM subscriptions s2\n\
WHERE s2.subscription_id = s.subscription_id\n\
AND s2.wsi_pointer = s.wsi_pointer\n\
AND s2.event_type IN ('closed', 'expired', 'disconnected')\n\
);\n\ );\n\
\n\ \n\
-- Database Statistics Views for Admin API\n\ -- Database Statistics Views for Admin API\n\

View File

@@ -744,10 +744,11 @@ int broadcast_event_to_subscriptions(cJSON* event) {
pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock); pthread_mutex_unlock(&g_subscription_manager.subscriptions_lock);
// Log event broadcast to database (optional - can be disabled for performance) // Log event broadcast to database (optional - can be disabled for performance)
cJSON* event_id_obj = cJSON_GetObjectItem(event, "id"); // NOTE: event_broadcasts table removed due to FOREIGN KEY constraint issues
if (event_id_obj && cJSON_IsString(event_id_obj)) { // cJSON* event_id_obj = cJSON_GetObjectItem(event, "id");
log_event_broadcast(cJSON_GetStringValue(event_id_obj), current_temp->id, current_temp->client_ip); // if (event_id_obj && cJSON_IsString(event_id_obj)) {
} // log_event_broadcast(cJSON_GetStringValue(event_id_obj), current_temp->id, current_temp->client_ip);
// }
} else { } else {
DEBUG_ERROR("Failed to queue EVENT message for sub=%s", current_temp->id); DEBUG_ERROR("Failed to queue EVENT message for sub=%s", current_temp->id);
} }
@@ -958,24 +959,25 @@ void log_subscription_disconnected(const char* client_ip) {
} }
// Log event broadcast to database (optional, can be resource intensive) // Log event broadcast to database (optional, can be resource intensive)
void log_event_broadcast(const char* event_id, const char* sub_id, const char* client_ip) { // REMOVED: event_broadcasts table removed due to FOREIGN KEY constraint issues
if (!g_db || !event_id || !sub_id || !client_ip) return; // void log_event_broadcast(const char* event_id, const char* sub_id, const char* client_ip) {
// if (!g_db || !event_id || !sub_id || !client_ip) return;
const char* sql = //
"INSERT INTO event_broadcasts (event_id, subscription_id, client_ip) " // const char* sql =
"VALUES (?, ?, ?)"; // "INSERT INTO event_broadcasts (event_id, subscription_id, client_ip) "
// "VALUES (?, ?, ?)";
sqlite3_stmt* stmt; //
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL); // sqlite3_stmt* stmt;
if (rc == SQLITE_OK) { // int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC); // if (rc == SQLITE_OK) {
sqlite3_bind_text(stmt, 2, sub_id, -1, SQLITE_STATIC); // sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
sqlite3_bind_text(stmt, 3, client_ip, -1, SQLITE_STATIC); // sqlite3_bind_text(stmt, 2, sub_id, -1, SQLITE_STATIC);
// sqlite3_bind_text(stmt, 3, client_ip, -1, SQLITE_STATIC);
sqlite3_step(stmt); //
sqlite3_finalize(stmt); // sqlite3_step(stmt);
} // sqlite3_finalize(stmt);
} // }
// }
// Update events sent counter for a subscription // Update events sent counter for a subscription
void update_subscription_events_sent(const char* sub_id, int events_sent) { void update_subscription_events_sent(const char* sub_id, int events_sent) {

View File

@@ -115,7 +115,6 @@ int get_active_connections_for_ip(const char* client_ip);
void log_subscription_created(const subscription_t* sub); void log_subscription_created(const subscription_t* sub);
void log_subscription_closed(const char* sub_id, const char* client_ip, const char* reason); void log_subscription_closed(const char* sub_id, const char* client_ip, const char* reason);
void log_subscription_disconnected(const char* client_ip); void log_subscription_disconnected(const char* client_ip);
void log_event_broadcast(const char* event_id, const char* sub_id, const char* client_ip);
void update_subscription_events_sent(const char* sub_id, int events_sent); void update_subscription_events_sent(const char* sub_id, int events_sent);
// Subscription query functions // Subscription query functions

View File

@@ -44,6 +44,9 @@ void send_nip42_auth_challenge(struct lws* wsi, struct per_session_data* pss);
void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* pss, cJSON* auth_event); void handle_nip42_auth_signed_event(struct lws* wsi, struct per_session_data* pss, cJSON* auth_event);
void handle_nip42_auth_challenge_response(struct lws* wsi, struct per_session_data* pss, const char* challenge); void handle_nip42_auth_challenge_response(struct lws* wsi, struct per_session_data* pss, const char* challenge);
// Forward declaration for status posts
int generate_and_post_status_event(void);
// Forward declarations for NIP-11 relay information handling // Forward declarations for NIP-11 relay information handling
int handle_nip11_http_request(struct lws* wsi, const char* accept_header); int handle_nip11_http_request(struct lws* wsi, const char* accept_header);
@@ -282,7 +285,19 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
// Check if this is a GET request to the root path // Check if this is a GET request to the root path
if (strcmp(requested_uri, "/") == 0) { if (strcmp(requested_uri, "/") == 0) {
// Get Accept header // Check if this is a WebSocket upgrade request
char upgrade_header[64] = {0};
int upgrade_len = lws_hdr_copy(wsi, upgrade_header, sizeof(upgrade_header) - 1, WSI_TOKEN_UPGRADE);
if (upgrade_len > 0) {
upgrade_header[upgrade_len] = '\0';
if (strstr(upgrade_header, "websocket") != NULL) {
DEBUG_LOG("WebSocket upgrade request - allowing connection");
return 0;
}
}
// Not a WebSocket upgrade, check for NIP-11 request
char accept_header[256] = {0}; char accept_header[256] = {0};
int header_len = lws_hdr_copy(wsi, accept_header, sizeof(accept_header) - 1, WSI_TOKEN_HTTP_ACCEPT); int header_len = lws_hdr_copy(wsi, accept_header, sizeof(accept_header) - 1, WSI_TOKEN_HTTP_ACCEPT);
@@ -300,7 +315,8 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
} }
} }
// Root path without NIP-11 Accept header - return 404 // Root path without NIP-11 Accept header and not WebSocket - return 404
DEBUG_WARN("Rejecting root path request - not WebSocket upgrade and not NIP-11");
lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL); lws_return_http_status(wsi, HTTP_STATUS_NOT_FOUND, NULL);
return -1; return -1;
} }
@@ -1404,6 +1420,9 @@ int start_websocket_relay(int port_override, int strict_port) {
snprintf(startup_msg, sizeof(startup_msg), "WebSocket relay started on ws://127.0.0.1:%d", actual_port); snprintf(startup_msg, sizeof(startup_msg), "WebSocket relay started on ws://127.0.0.1:%d", actual_port);
} }
// Static variable for status post timing (initialize to 0 for immediate first post)
static time_t last_status_post_time = 0;
// Main event loop with proper signal handling // Main event loop with proper signal handling
while (g_server_running && !g_shutdown_flag) { while (g_server_running && !g_shutdown_flag) {
int result = lws_service(ws_context, 1000); int result = lws_service(ws_context, 1000);
@@ -1412,6 +1431,19 @@ int start_websocket_relay(int port_override, int strict_port) {
DEBUG_ERROR("libwebsockets service error"); DEBUG_ERROR("libwebsockets service error");
break; break;
} }
// Check if it's time to post status update
time_t current_time = time(NULL);
int status_post_hours = get_config_int("kind_1_status_posts_hours", 0);
if (status_post_hours > 0) {
int seconds_interval = status_post_hours * 3600; // Convert hours to seconds
if (current_time - last_status_post_time >= seconds_interval) {
last_status_post_time = current_time;
generate_and_post_status_event();
}
}
} }
lws_context_destroy(ws_context); lws_context_destroy(ws_context);
@@ -1512,7 +1544,7 @@ int process_dm_stats_command(cJSON* dm_event, char* error_message, size_t error_
const char* encrypted_content = cJSON_GetStringValue(content_obj); const char* encrypted_content = cJSON_GetStringValue(content_obj);
// Decrypt content // Decrypt content
char decrypted_content[4096]; char decrypted_content[16384];
int decrypt_result = nostr_nip44_decrypt(relay_privkey, sender_pubkey_bytes, int decrypt_result = nostr_nip44_decrypt(relay_privkey, sender_pubkey_bytes,
encrypted_content, decrypted_content, sizeof(decrypted_content)); encrypted_content, decrypted_content, sizeof(decrypted_content));

View File

@@ -1,40 +0,0 @@
[Unit]
Description=C Nostr Relay Server (Local Development)
Documentation=https://github.com/your-repo/c-relay
After=network.target
Wants=network-online.target
[Service]
Type=simple
User=teknari
WorkingDirectory=/home/teknari/Storage/c_relay
Environment=DEBUG_LEVEL=0
ExecStart=/home/teknari/Storage/c_relay/crelay --port 7777 --debug-level=$DEBUG_LEVEL
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=c-relay-local
# Security settings (relaxed for local development)
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/home/teknari/Storage/c_relay
PrivateTmp=true
# Network security
PrivateNetwork=false
RestrictAddressFamilies=AF_INET AF_INET6
# Resource limits
LimitNOFILE=65536
LimitNPROC=4096
# Event-based configuration system
# No environment variables needed - all configuration is stored as Nostr events
# Database files (<relay_pubkey>.db) are created automatically in WorkingDirectory
# Admin keys are generated and displayed only during first startup
[Install]
WantedBy=multi-user.target

12
tests/debug.log Normal file
View File

@@ -0,0 +1,12 @@
=== NOSTR WebSocket Debug Log Started ===
[14:13:42.079] SEND localhost:8888: ["EVENT", {
"pubkey": "e74e808f64b82fe4671b92cdf83f6dd5f5f44dbcb67fbd0e044f34a6193e0994",
"created_at": 1761499244,
"kind": 1059,
"tags": [["p", "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"]],
"content": "ApTb8y2oD3/TtVCV73Szhgfh5ODlluGd5zjsH44g5BBwaGB1NshOJ/5kF/XN0TfYJKQBe07UTpnOYMZ4l2ppU6SrR8Tor+ZEiAF/kpCpa/x6LDDIvf4mueQicDKjOf8Y6oEbsxYjtFrpuSC0LLMgLaVhcZjAgVD0YQTo+8nHOzHZD5RBr305vdnrxIe4ubEficAHCpnKq9L3A46AIyb+aHjjTbSYmB061cf6hzLSnmdh5xeACExjhxwsX9ivSvqGYcDNsH1JCM8EYQyRX9xAPDBYM1yuS8PpadqMluOcqOd/FFYyjYNpFrardblPsjUzZTz/TDSLyrYFDUKNa7pWIhW1asc1ZaY0ry0AoWnbl/QyMxqBjDFXd3mJfWccYsOI/Yrx3sxbZdL+ayRlQeQuDk/M9rQkH8GN/5+GE1aN5I6eVl0F37Axc/lLuIt/AIpoTwZYAEi9j/BYGLP6sYkjUp0foz91QximOTgu8evynu+nfAv330HVkipTIGOjEZea7QNSK0Fylxs8fanHlmiqWGyfyBeoWpxGslHZVu6K9k7GC8ABEIdNRa8vlqlphPfWPCS70Lnq3LgeKOj1C3sNF9ST8g7pth/0FEZgXruzhpx/EyjsasNbdLZg3iX1QwRS0P4L341Flrztovt8npyP9ytTiukkYIQzXCX8XuWjiaUuzXiLkVazjh0Nl03ikKKu2+7nuaBB92geBjbGT76zZ6HeXBgcmC7dWn7pHhzqu+QTonZK0oCl427Fs0eXiYsILjxFFQkmk7OHXgdZF9jquNXloz5lgwY9S3xj4JyRwLN/9xfh16awxLZNEFvX10X97bXsmNMRUDrJJPkKMTSxZpvuTbd+Lx2iB++4NyGZibNa6nOWOJG9d2LwEzIcIHS0uQpEIPl7Ccz6+rmkVh9kLbB2rda2fYp9GCOcn6XbfaXZZXJM+HAQwPJgrtDiuQex0tEIcQcB9CYCN4ze9HCt1kb23TUgEDAipz/RqYP4dOCYmRZ7vaYk/irJ+iRDfnvPK0Id1TrSeo5kaVc7py2zWZRVdndpTM8RvW0SLwdldXDIv+ym/mS0L7bchoaYjoNeuTNKQ6AOoc0E7f4ySr65FUKYd2FTvIsP2Avsa3S+D0za30ensxr733l80AQlVmUPrhsgOzzjEuOW1hGlGus38X+CDDEuMSJnq3hvz/CxVtAk71Zkbyr5lc1BPi758Y4rlZFQnhaKYKv5nSFJc7GtDykv+1cwxNGC6AxGKprnYMDVxuAIFYBztFitdO5BsjWvvKzAbleszewtGfjE2NgltIJk+gQlTpWvLNxd3gvb+qHarfEv7BPnPfsKktDpEfuNMKXdJPANyACq5gXj854o/X8iO2iLm7JSdMhEQgIIyHNyLCCQdLDnqDWIfcdyIzAfRilSCwImt3CVJBGD7HoXRbwGRR3vgEBcoVPmsYzaU9vr62I=",
"id": "75c178ee47aac3ab9e984ddb85bdf9d8c68ade0d97e9cd86bb39e3110218a589",
"sig": "aba8382cc8d6ba6bba467109d2ddc19718732fe803d71e73fd2db62c1cbbb1b4527447240906e01755139067a71c75d8c03271826ca5d0226c818cb7fb495fe2"
}]
[14:13:42.083] RECV localhost:8888: ["OK", "75c178ee47aac3ab9e984ddb85bdf9d8c68ade0d97e9cd86bb39e3110218a589", true, ""]

BIN
tests/sendDM Executable file

Binary file not shown.

296
tests/sendDM.c Normal file
View File

@@ -0,0 +1,296 @@
/*
* NIP-17 Private Direct Messages - Command Line Application
*
* This example demonstrates how to send NIP-17 private direct messages
* using the Nostr Core Library.
*
* Usage:
* ./send_nip17_dm -r <recipient> -s <sender> [-R <relay>]... <message>
*
* Options:
* -r <recipient>: The recipient's public key (npub or hex)
* -s <sender>: The sender's private key (nsec or hex)
* -R <relay>: Relay URL to send to (can be specified multiple times)
* <message>: The message to send (must be the last argument)
*
* If no relays are specified, uses default relay.
* If no sender key is provided, uses a default test key.
*
* Examples:
* ./send_nip17_dm -r npub1example... -s nsec1test... -R wss://relay1.com "Hello from NIP-17!"
* ./send_nip17_dm -r 4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa -s aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -R ws://localhost:8888 "config"
*/
#define _GNU_SOURCE
#define _POSIX_C_SOURCE 200809L
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <getopt.h>
// Default test private key (for demonstration - DO NOT USE IN PRODUCTION)
#define DEFAULT_SENDER_NSEC "nsec12kgt0dv2k2safv6s32w8f89z9uw27e68hjaa0d66c5xvk70ezpwqncd045"
// Default relay for sending DMs
#define DEFAULT_RELAY "wss://relay.laantungir.net"
// Progress callback for publishing
void publish_progress_callback(const char* relay_url, const char* status,
const char* message, int success_count,
int total_relays, int completed_relays, void* user_data) {
(void)user_data;
if (relay_url) {
printf("📡 [%s]: %s", relay_url, status);
if (message) {
printf(" - %s", message);
}
printf(" (%d/%d completed, %d successful)\n", completed_relays, total_relays, success_count);
} else {
printf("📡 PUBLISH COMPLETE: %d/%d successful\n", success_count, total_relays);
}
}
/**
* Convert npub or hex pubkey to hex format
*/
int convert_pubkey_to_hex(const char* input_pubkey, char* output_hex) {
// Check if it's already hex (64 characters)
if (strlen(input_pubkey) == 64) {
// Assume it's already hex
strcpy(output_hex, input_pubkey);
return 0;
}
// Check if it's an npub (starts with "npub1")
if (strncmp(input_pubkey, "npub1", 5) == 0) {
// Convert npub to hex
unsigned char pubkey_bytes[32];
if (nostr_decode_npub(input_pubkey, pubkey_bytes) != 0) {
fprintf(stderr, "Error: Invalid npub format\n");
return -1;
}
nostr_bytes_to_hex(pubkey_bytes, 32, output_hex);
return 0;
}
fprintf(stderr, "Error: Public key must be 64-character hex or valid npub\n");
return -1;
}
/**
* Convert nsec to private key bytes if needed
*/
int convert_nsec_to_private_key(const char* input_nsec, unsigned char* private_key) {
// Check if it's already hex (64 characters)
if (strlen(input_nsec) == 64) {
// Convert hex to bytes
if (nostr_hex_to_bytes(input_nsec, private_key, 32) != 0) {
fprintf(stderr, "Error: Invalid hex private key\n");
return -1;
}
return 0;
}
// Check if it's an nsec (starts with "nsec1")
if (strncmp(input_nsec, "nsec1", 5) == 0) {
// Convert nsec directly to private key bytes
if (nostr_decode_nsec(input_nsec, private_key) != 0) {
fprintf(stderr, "Error: Invalid nsec format\n");
return -1;
}
return 0;
}
fprintf(stderr, "Error: Private key must be 64-character hex or valid nsec\n");
return -1;
}
/**
* Main function
*/
int main(int argc, char* argv[]) {
char* recipient_key = NULL;
char* sender_key = NULL;
char** relays = NULL;
int relay_count = 0;
char* message = NULL;
// Parse command line options
int opt;
while ((opt = getopt(argc, argv, "r:s:R:")) != -1) {
switch (opt) {
case 'r':
recipient_key = optarg;
break;
case 's':
sender_key = optarg;
break;
case 'R':
relays = realloc(relays, (relay_count + 1) * sizeof(char*));
relays[relay_count] = optarg;
relay_count++;
break;
default:
fprintf(stderr, "Usage: %s -r <recipient> -s <sender> [-R <relay>]... <message>\n", argv[0]);
fprintf(stderr, "Options:\n");
fprintf(stderr, " -r <recipient>: The recipient's public key (npub or hex)\n");
fprintf(stderr, " -s <sender>: The sender's private key (nsec or hex)\n");
fprintf(stderr, " -R <relay>: Relay URL to send to (can be specified multiple times)\n");
fprintf(stderr, " <message>: The message to send (must be the last argument)\n");
return 1;
}
}
// Check for required arguments
if (!recipient_key) {
fprintf(stderr, "Error: Recipient key (-r) is required\n");
return 1;
}
// Get message from remaining arguments
if (optind >= argc) {
fprintf(stderr, "Error: Message is required\n");
return 1;
}
message = argv[optind];
// Use default values if not provided
if (!sender_key) {
sender_key = DEFAULT_SENDER_NSEC;
}
if (relay_count == 0) {
relays = malloc(sizeof(char*));
relays[0] = DEFAULT_RELAY;
relay_count = 1;
}
printf("🧪 NIP-17 Private Direct Message Sender\n");
printf("======================================\n\n");
// Initialize crypto
if (nostr_init() != NOSTR_SUCCESS) {
fprintf(stderr, "Failed to initialize crypto\n");
free(relays);
return 1;
}
// Convert recipient pubkey
char recipient_pubkey_hex[65];
if (convert_pubkey_to_hex(recipient_key, recipient_pubkey_hex) != 0) {
free(relays);
return 1;
}
// Convert sender private key
unsigned char sender_privkey[32];
if (convert_nsec_to_private_key(sender_key, sender_privkey) != 0) {
free(relays);
return 1;
}
// Derive sender public key for display
unsigned char sender_pubkey_bytes[32];
char sender_pubkey_hex[65];
if (nostr_ec_public_key_from_private_key(sender_privkey, sender_pubkey_bytes) != 0) {
fprintf(stderr, "Failed to derive sender public key\n");
return 1;
}
nostr_bytes_to_hex(sender_pubkey_bytes, 32, sender_pubkey_hex);
printf("📤 Sender: %s\n", sender_pubkey_hex);
printf("📥 Recipient: %s\n", recipient_pubkey_hex);
printf("💬 Message: %s\n", message);
printf("🌐 Relays: ");
for (int i = 0; i < relay_count; i++) {
printf("%s", relays[i]);
if (i < relay_count - 1) printf(", ");
}
printf("\n\n");
// Create DM event
printf("💬 Creating DM event...\n");
const char* recipient_pubkeys[] = {recipient_pubkey_hex};
cJSON* dm_event = nostr_nip17_create_chat_event(
message,
recipient_pubkeys,
1,
"NIP-17 CLI", // subject
NULL, // no reply
relays[0], // relay hint (use first relay)
sender_pubkey_hex
);
if (!dm_event) {
fprintf(stderr, "Failed to create DM event\n");
return 1;
}
printf("✅ Created DM event (kind 14)\n");
// Send DM (create gift wraps)
printf("🎁 Creating gift wraps...\n");
cJSON* gift_wraps[10]; // Max 10 gift wraps
int gift_wrap_count = nostr_nip17_send_dm(
dm_event,
recipient_pubkeys,
1,
sender_privkey,
gift_wraps,
10
);
cJSON_Delete(dm_event); // Original DM event no longer needed
if (gift_wrap_count <= 0) {
fprintf(stderr, "Failed to create gift wraps\n");
return 1;
}
printf("✅ Created %d gift wrap(s)\n", gift_wrap_count);
// Publish the gift wrap to relays
printf("\n📤 Publishing gift wrap to %d relay(s)...\n", relay_count);
int success_count = 0;
publish_result_t* publish_results = synchronous_publish_event_with_progress(
(const char**)relays,
relay_count,
gift_wraps[0], // Send the first gift wrap
&success_count,
10, // 10 second timeout
publish_progress_callback,
NULL, // no user data
0, // NIP-42 disabled
NULL // no private key for auth
);
if (!publish_results || success_count == 0) {
fprintf(stderr, "\n❌ Failed to publish gift wrap to any relay (success_count: %d/%d)\n", success_count, relay_count);
// Clean up gift wraps
for (int i = 0; i < gift_wrap_count; i++) {
cJSON_Delete(gift_wraps[i]);
}
if (publish_results) free(publish_results);
free(relays);
return 1;
}
printf("\n✅ Successfully published NIP-17 DM to %d/%d relay(s)!\n", success_count, relay_count);
// Clean up
free(publish_results);
for (int i = 0; i < gift_wrap_count; i++) {
cJSON_Delete(gift_wraps[i]);
}
free(relays);
nostr_cleanup();
printf("\n🎉 DM sent successfully! The recipient can now decrypt it using their private key.\n");
return 0;
}