Compare commits

..

4 Commits

Author SHA1 Message Date
Your Name
e312d7e18c v0.7.25 - Implement SQL Query Admin API
- Move non-NIP-17 admin functions from dm_admin.c to api.c for better architecture
- Add NIP-44 encryption to send_admin_response() for secure admin responses
- Implement SQL query validation and execution with safety limits
- Add unified SQL query handler for admin API
- Fix buffer size for encrypted content to handle larger responses
- Update function declarations and includes across files
- Successfully test frontend query execution through web interface
2025-10-16 15:41:21 -04:00
Your Name
6c38aaebf3 v0.7.24 - Fix admin API subscription issues: NIP-17 historical events and relay pubkey timing 2025-10-16 06:27:01 -04:00
Your Name
18b0ac44bf v0.7.23 - Remove sticky positioning from main header to prevent floating behavior 2025-10-15 19:22:42 -04:00
Your Name
b6749eff2f v0.7.22 - Fix compiler warnings in c_utils_lib version.c by adding proper includes for popen/pclose functions 2025-10-15 19:20:14 -04:00
19 changed files with 4054 additions and 1270 deletions

View File

@@ -1,6 +1,8 @@
# Alpine-based MUSL static binary builder for C-Relay
# Produces truly portable binaries with zero runtime dependencies
ARG DEBUG_BUILD=false
FROM alpine:3.19 AS builder
# Install build dependencies
@@ -98,9 +100,19 @@ RUN cd nostr_core_lib && \
COPY src/ /build/src/
COPY Makefile /build/Makefile
# Build c-relay with full static linking and debug symbols (only rebuilds when src/ changes)
# Build c-relay with full static linking (only rebuilds when src/ changes)
# Disable fortification to avoid __*_chk symbols that don't exist in MUSL
RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
# Use conditional compilation flags based on DEBUG_BUILD argument
RUN if [ "$DEBUG_BUILD" = "true" ]; then \
CFLAGS="-g -O0 -DDEBUG"; \
STRIP_CMD=""; \
echo "Building with DEBUG symbols enabled"; \
else \
CFLAGS="-O2"; \
STRIP_CMD="strip /build/c_relay_static"; \
echo "Building optimized production binary"; \
fi && \
gcc -static $CFLAGS -Wall -Wextra -std=c99 \
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
@@ -111,10 +123,8 @@ RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
c_utils_lib/libc_utils.a \
nostr_core_lib/libnostr_core_x64.a \
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
-lcurl -lz -lpthread -lm -ldl
# DO NOT strip - we need debug symbols for debugging
# RUN strip /build/c_relay_static
-lcurl -lz -lpthread -lm -ldl && \
eval "$STRIP_CMD"
# Verify it's truly static
RUN echo "=== Binary Information ===" && \

View File

@@ -164,6 +164,8 @@ All commands are sent as NIP-44 encrypted JSON arrays in the event content. The
| `system_clear_auth` | `["system_command", "clear_all_auth_rules"]` | Clear all auth rules |
| `system_status` | `["system_command", "system_status"]` | Get system status |
| `stats_query` | `["stats_query"]` | Get comprehensive database statistics |
| **Database Queries** |
| `sql_query` | `["sql_query", "SELECT * FROM events LIMIT 10"]` | Execute read-only SQL query against relay database |
### Available Configuration Keys
@@ -320,8 +322,68 @@ All admin commands return **signed EVENT responses** via WebSocket following sta
],
"sig": "response_event_signature"
}]
```
**SQL Query Response:**
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44 encrypted:{\"query_type\": \"sql_query\", \"request_id\": \"request_event_id\", \"timestamp\": 1234567890, \"query\": \"SELECT * FROM events LIMIT 10\", \"execution_time_ms\": 45, \"row_count\": 10, \"columns\": [\"id\", \"pubkey\", \"created_at\", \"kind\", \"content\"], \"rows\": [[\"abc123...\", \"def456...\", 1234567890, 1, \"Hello world\"], ...]}",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
### SQL Query Command
The `sql_query` command allows administrators to execute read-only SQL queries against the relay database. This provides powerful analytics and debugging capabilities through the admin API.
**Request/Response Correlation:**
- Each response includes the request event ID in both the `tags` array (`["e", "request_event_id"]`) and the decrypted content (`"request_id": "request_event_id"`)
- This allows proper correlation when multiple queries are submitted concurrently
- Frontend can track pending queries and match responses to requests
**Security Features:**
- Only SELECT statements allowed (INSERT, UPDATE, DELETE, DROP, etc. are blocked)
- Query timeout: 5 seconds (configurable)
- Result row limit: 1000 rows (configurable)
- All queries logged with execution time
**Available Tables and Views:**
- `events` - All Nostr events
- `config` - Configuration parameters
- `auth_rules` - Authentication rules
- `subscription_events` - Subscription lifecycle log
- `event_broadcasts` - Event broadcast log
- `recent_events` - Last 1000 events (view)
- `event_stats` - Event statistics by type (view)
- `subscription_analytics` - Subscription metrics (view)
- `active_subscriptions_log` - Currently active subscriptions (view)
- `event_kinds_view` - Event distribution by kind (view)
- `top_pubkeys_view` - Top 10 pubkeys by event count (view)
- `time_stats_view` - Time-based statistics (view)
**Example Queries:**
```sql
-- Recent events
SELECT id, pubkey, created_at, kind FROM events ORDER BY created_at DESC LIMIT 20
-- Event distribution by kind
SELECT * FROM event_kinds_view ORDER BY count DESC
-- Active subscriptions
SELECT * FROM active_subscriptions_log ORDER BY created_at DESC
-- Database statistics
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM subscription_events) as total_subscriptions
```

View File

@@ -22,6 +22,23 @@
--tab-border-opacity-logged-in: 0.1;
}
/* Dark Mode Overrides */
body.dark-mode {
--primary-color: #ffffff;
--secondary-color: #000000;
--accent-color: #ff0000;
--muted-color: #222222;
--border-color: var(--muted-color);
--tab-bg-logged-out: #000000;
--tab-color-logged-out: #ffffff;
--tab-border-logged-out: #ffffff;
--tab-bg-logged-in: #000000;
--tab-color-logged-in: #ffffff;
--tab-border-logged-in: #00ffff;
}
* {
margin: 0;
padding: 0;
@@ -41,10 +58,8 @@ body {
/* Header Styles */
.main-header {
background-color: var(--secondary-color);
border-bottom: var(--border-width) solid var(--border-color);
padding: 15px 20px;
position: sticky;
top: 0;
z-index: 100;
max-width: 1200px;
margin: 0 auto;
@@ -67,6 +82,86 @@ body {
text-align: left;
}
.relay-info {
text-align: center;
flex: 1;
}
.relay-name {
font-size: 14px;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 2px;
}
.relay-pubkey-container {
border: 1px solid transparent;
border-radius: var(--border-radius);
padding: 4px;
margin-top: 4px;
cursor: pointer;
transition: border-color 0.2s ease;
background-color: var(--secondary-color);
}
.relay-pubkey-container:hover {
border-color: var(--border-color);
}
.relay-pubkey-container.copied {
border-color: var(--accent-color);
animation: flash-accent 0.5s ease-in-out;
}
.relay-pubkey {
font-size: 8px;
color: var(--primary-color);
font-family: "Courier New", Courier, monospace;
line-height: 1.2;
white-space: pre-line;
text-align: center;
}
@keyframes flash-accent {
0% { border-color: var(--accent-color); }
50% { border-color: var(--accent-color); }
100% { border-color: transparent; }
}
.relay-description {
font-size: 10px;
color: var(--primary-color);
margin-bottom: 0;
}
.header-title {
margin: 0;
font-size: 24px;
font-weight: bolder;
color: var(--primary-color);
border: none;
padding: 0;
text-align: left;
display: flex;
gap: 2px;
}
.relay-letter {
position: relative;
display: inline-block;
transition: all 0.05s ease;
}
.relay-letter.underlined::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 2px;
background-color: var(--accent-color);
}
.header-user-name {
display: block;
font-weight: 500;
@@ -78,6 +173,7 @@ body {
.profile-area {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
cursor: pointer;
@@ -87,6 +183,14 @@ body {
margin-left: auto;
}
.admin-label {
font-size: 10px;
color: var(--primary-color);
font-weight: normal;
margin-bottom: 4px;
text-align: center;
}
.profile-container {
display: flex;
flex-direction: column;
@@ -129,13 +233,13 @@ body {
.logout-btn {
width: 100%;
padding: 10px 15px;
padding: 5px 10px;
background: none;
border: none;
color: var(--primary-color);
text-align: left;
cursor: pointer;
font-size: 14px;
font-size: 10px;
font-family: var(--font-family);
border-radius: var(--border-radius);
transition: background-color 0.2s ease;
@@ -255,10 +359,10 @@ button:active {
}
button:disabled {
background-color: #ccc;
color: var(--muted-color);
background-color: var(--muted-color);
color: var(--primary-color);
cursor: not-allowed;
border-color: #ccc;
border-color: var(--muted-color);
}
/* Flash animation for refresh button */
@@ -269,7 +373,7 @@ button:disabled {
}
.flash-red {
animation: flash-red 0.5s ease-in-out;
animation: flash-red 1s ease-in-out;
}
/* Flash animation for updated statistics values */
@@ -280,7 +384,7 @@ button:disabled {
}
.flash-value {
animation: flash-value 0.5s ease-in-out;
animation: flash-value 1s ease-in-out;
}
/* Npub links styling */
@@ -346,6 +450,10 @@ button:disabled {
font-size: 10px;
}
.config-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.config-table-container {
overflow-x: auto;
max-width: 100%;
@@ -353,12 +461,13 @@ button:disabled {
.config-table th {
font-weight: bold;
height: 40px; /* Double the default height */
line-height: 40px; /* Center text vertically */
height: 24px; /* Base height for tbody rows */
line-height: 24px; /* Center text vertically */
}
.config-table tr:hover {
background-color: var(--muted-color);
.config-table td {
height: 16px; /* 50% taller than tbody rows would be */
line-height: 16px; /* Center text vertically */
}
/* Inline config value inputs - remove borders and padding to fit seamlessly in table cells */
@@ -696,10 +805,201 @@ button:disabled {
transition: all 0.2s ease;
}
/* SQL Query Interface Styles */
.query-selector {
margin-bottom: 15px;
}
.flex-section {
.query-selector select {
width: 100%;
padding: 8px;
background: var(--secondary-color);
color: var(--primary-color);
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
font-family: var(--font-family);
font-size: 14px;
cursor: pointer;
}
.query-selector select:focus {
border-color: var(--accent-color);
outline: none;
}
.query-selector optgroup {
font-weight: bold;
color: var(--primary-color);
}
.query-selector option {
padding: 4px;
background: var(--secondary-color);
color: var(--primary-color);
}
.query-editor textarea {
width: 100%;
min-height: 120px;
resize: vertical;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
line-height: 1.4;
tab-size: 4;
white-space: pre;
}
.query-actions {
display: flex;
gap: 10px;
margin-top: 10px;
}
.query-actions button {
flex: 1;
min-width: 300px;
min-width: 120px;
}
.primary-button {
background: var(--primary-color);
color: var(--secondary-color);
border-color: var(--primary-color);
}
.primary-button:hover {
background: var(--secondary-color);
color: var(--primary-color);
border-color: var(--accent-color);
}
.danger-button {
background: var(--accent-color);
color: var(--secondary-color);
border-color: var(--accent-color);
}
.danger-button:hover {
background: var(--secondary-color);
color: var(--primary-color);
border-color: var(--accent-color);
}
.query-info {
padding: 10px;
border: var(--border-width) solid var(--border-color);
border-radius: var(--border-radius);
margin: 10px 0;
font-family: var(--font-family);
font-size: 12px;
background-color: var(--secondary-color);
}
.query-info-success {
border-color: #4CAF50;
background-color: #E8F5E8;
color: #2E7D32;
}
.query-info-success span {
display: inline-block;
margin-right: 15px;
}
.request-id {
font-family: "Courier New", Courier, monospace;
font-size: 10px;
opacity: 0.7;
}
.error-message {
border-color: var(--accent-color);
background-color: #FFEBEE;
color: #C62828;
padding: 10px;
border-radius: var(--border-radius);
margin: 10px 0;
font-family: var(--font-family);
font-size: 12px;
}
.sql-results-table {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
width: 100%;
border-collapse: separate;
border-spacing: 0;
margin: 10px 0;
overflow: hidden;
font-size: 11px;
}
.sql-results-table th,
.sql-results-table td {
border: 0.1px solid var(--muted-color);
padding: 6px 8px;
text-align: left;
font-family: var(--font-family);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sql-results-table th {
font-weight: bold;
background-color: rgba(0, 0, 0, 0.05);
position: sticky;
top: 0;
z-index: 10;
}
.sql-results-table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.sql-results-table tbody tr:nth-child(even) {
background-color: rgba(0, 0, 0, 0.02);
}
.no-results {
text-align: center;
font-style: italic;
color: var(--muted-color);
padding: 20px;
font-family: var(--font-family);
}
.loading {
text-align: center;
font-style: italic;
color: var(--muted-color);
padding: 20px;
font-family: var(--font-family);
}
/* Dark mode adjustments for SQL interface */
body.dark-mode .query-info-success {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.1);
color: #81C784;
}
body.dark-mode .error-message {
border-color: var(--accent-color);
background-color: rgba(244, 67, 54, 0.1);
color: #EF5350;
}
body.dark-mode .sql-results-table th {
background-color: rgba(255, 255, 255, 0.05);
}
body.dark-mode .sql-results-table tbody tr:hover {
background-color: rgba(255, 255, 255, 0.05);
}
body.dark-mode .sql-results-table tbody tr:nth-child(even) {
background-color: rgba(255, 255, 255, 0.02);
}
@media (max-width: 700px) {
@@ -711,6 +1011,10 @@ button:disabled {
flex-direction: column;
}
.query-actions {
flex-direction: column;
}
h1 {
font-size: 20px;
}
@@ -718,4 +1022,14 @@ button:disabled {
h2 {
font-size: 14px;
}
.sql-results-table {
font-size: 10px;
}
.sql-results-table th,
.sql-results-table td {
padding: 4px 6px;
max-width: 120px;
}
}

View File

@@ -10,21 +10,38 @@
<body>
<!-- Header with title and profile display -->
<header class="main-header">
<div class="header-content">
<div class="header-title">RELAY</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
<div class="section">
<div class="header-content">
<div class="header-title">
<span class="relay-letter" data-letter="R">R</span>
<span class="relay-letter" data-letter="E">E</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="A">A</span>
<span class="relay-letter" data-letter="Y">Y</span>
</div>
<!-- Logout dropdown -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
<div class="relay-info">
<div id="relay-name" class="relay-name">C-Relay</div>
<div id="relay-description" class="relay-description">Loading...</div>
<div id="relay-pubkey-container" class="relay-pubkey-container">
<div id="relay-pubkey" class="relay-pubkey">Loading...</div>
</div>
</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="admin-label">admin</div>
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
</div>
<!-- Logout dropdown -->
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
<button type="button" id="dark-mode-btn" class="logout-btn">🌙 DARK MODE</button>
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
</div>
</div>
</div>
</div>
</header>
</div>
<!-- Login Modal Overlay -->
<div id="login-modal" class="login-modal-overlay" style="display: none;">
@@ -43,36 +60,30 @@
<!-- Database Overview Table -->
<div class="input-group">
<label>Database Overview:</label>
<div class="config-table-container">
<table class="config-table" id="stats-overview-table">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-overview-table-body">
<tr>
<td>Database Size</td>
<td id="db-size">-</td>
<td>Current database file size</td>
</tr>
<tr>
<td>Total Events</td>
<td id="total-events">-</td>
<td>Total number of events stored</td>
</tr>
<tr>
<td>Oldest Event</td>
<td id="oldest-event">-</td>
<td>Timestamp of oldest event</td>
</tr>
<tr>
<td>Newest Event</td>
<td id="newest-event">-</td>
<td>Timestamp of newest event</td>
</tr>
</tbody>
</table>
@@ -109,24 +120,20 @@
<tr>
<th>Period</th>
<th>Events</th>
<th>Description</th>
</tr>
</thead>
<tbody id="stats-time-table-body">
<tr>
<td>Last 24 Hours</td>
<td id="events-24h">-</td>
<td>Events in the last day</td>
</tr>
<tr>
<td>Last 7 Days</td>
<td id="events-7d">-</td>
<td>Events in the last week</td>
</tr>
<tr>
<td>Last 30 Days</td>
<td id="events-30d">-</td>
<td>Events in the last month</td>
</tr>
</tbody>
</table>
@@ -267,6 +274,52 @@
</div>
</div>
<!-- SQL QUERY Section -->
<div class="section" id="sqlQuerySection" style="display: none;">
<div class="section-header">
<h2>SQL QUERY CONSOLE</h2>
</div>
<!-- Query Selector -->
<div class="input-group">
<label for="query-dropdown">Quick Queries & History:</label>
<select id="query-dropdown" onchange="loadSelectedQuery()">
<option value="">-- Select a query --</option>
<optgroup label="Common Queries">
<option value="recent_events">Recent Events</option>
<option value="event_stats">Event Statistics</option>
<option value="subscriptions">Active Subscriptions</option>
<option value="top_pubkeys">Top Pubkeys</option>
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
</select>
</div>
<!-- Query Editor -->
<div class="input-group">
<label for="sql-input">SQL Query:</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
</div>
<!-- Query Actions -->
<div class="input-group">
<button type="button" id="execute-sql-btn" class="primary-button">EXECUTE QUERY</button>
<button type="button" id="clear-sql-btn">CLEAR</button>
<button type="button" id="clear-history-btn" class="danger-button">CLEAR HISTORY</button>
</div>
<!-- Query Results -->
<div class="input-group">
<label>Query Results:</label>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="config-table-container"></div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
<script src="/api/nostr.bundle.js"></script>

View File

@@ -22,6 +22,7 @@ let currentConfig = null;
// Global subscription state
let relayPool = null;
let subscriptionId = null;
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
// Relay connection state
let relayInfo = null;
let isRelayConnected = false;
@@ -33,6 +34,9 @@ let statsAutoRefreshInterval = null;
let countdownInterval = null;
let countdownSeconds = 10;
// SQL Query state
let pendingSqlQueries = new Map();
// DOM elements
const loginModal = document.getElementById('login-modal');
const loginModalContainer = document.getElementById('login-modal-container');
@@ -353,6 +357,9 @@ async function setupAutomaticRelayConnection(showSections = false) {
// Mark as connected
isRelayConnected = true;
// Update relay info in header
updateRelayInfoInHeader();
// Only show admin sections if explicitly requested
if (showSections) {
updateAdminSectionsVisibility();
@@ -470,12 +477,14 @@ function updateAdminSectionsVisibility() {
const authRulesSection = document.getElementById('authRulesSection');
const databaseStatisticsSection = document.getElementById('databaseStatisticsSection');
const nip17DMSection = document.getElementById('nip17DMSection');
const sqlQuerySection = document.getElementById('sqlQuerySection');
const shouldShow = isLoggedIn && isRelayConnected;
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none';
if (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none';
if (sqlQuerySection) sqlQuerySection.style.display = shouldShow ? 'block' : 'none';
// Start/stop auto-refresh based on visibility
if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') {
@@ -747,6 +756,8 @@ async function logout() {
}
relayPool = null;
subscriptionId = null;
// Reset subscription flag
isSubscribed = false;
}
await nlLite.logout();
@@ -758,6 +769,8 @@ async function logout() {
// Reset relay connection state
isRelayConnected = false;
relayPubkey = null;
// Reset subscription flag
isSubscribed = false;
// Reset UI - hide profile and show login modal
hideProfileFromHeader();
@@ -784,8 +797,8 @@ function updateConfigStatus(loaded) {
// Generate random subscription ID (avoiding colons which are rejected by relay)
function generateSubId() {
// Use only alphanumeric characters, underscores, and hyphens
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
// Use only alphanumeric characters, underscores, hyphens, and commas
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-,';
let result = '';
for (let i = 0; i < 12; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
@@ -798,6 +811,12 @@ async function subscribeToConfiguration() {
try {
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
// Prevent multiple simultaneous subscription attempts
if (isSubscribed) {
console.log('Subscription already established, skipping duplicate subscription attempt');
return true;
}
if (!isLoggedIn) {
console.log('WARNING: Not logged in, but proceeding with subscription test');
}
@@ -810,16 +829,14 @@ async function subscribeToConfiguration() {
console.log(`Connecting to relay via SimplePool: ${url}`);
// Clean up existing pool
if (relayPool) {
console.log('Closing existing pool connection');
relayPool.close([url]);
relayPool = null;
subscriptionId = null;
// Reuse existing pool if available, otherwise create new one
if (!relayPool) {
console.log('Creating new SimplePool instance');
relayPool = new window.NostrTools.SimplePool();
} else {
console.log('Reusing existing SimplePool instance');
}
// Create new SimplePool instance
relayPool = new window.NostrTools.SimplePool();
subscriptionId = generateSubId();
console.log(`Generated subscription ID: ${subscriptionId}`);
@@ -838,6 +855,7 @@ async function subscribeToConfiguration() {
"#p": [userPubkey], // Only DMs directed to this user
limit: 50
}, {
since: Math.floor(Date.now() / 1000), // Start from current time
kinds: [1059], // NIP-17 GiftWrap events
"#p": [userPubkey], // Only GiftWrap events addressed to this user
limit: 50
@@ -940,6 +958,9 @@ async function subscribeToConfiguration() {
// Store subscription for cleanup
relayPool.currentSubscription = subscription;
// Mark as subscribed to prevent duplicate attempts
isSubscribed = true;
console.log('SimplePool subscription established');
return true;
@@ -1051,6 +1072,13 @@ function handleAdminResponseData(responseData) {
return;
}
// Handle SQL query responses
if (responseData.query_type === 'sql_query') {
console.log('Routing to SQL query handler');
handleSqlQueryResponse(responseData);
return;
}
// Generic response handling
console.log('Using generic response handler');
if (typeof logTestEvent === 'function') {
@@ -1101,6 +1129,9 @@ function handleConfigQueryResponse(responseData) {
// Display the configuration using the original display function
displayConfiguration(syntheticEvent);
// Update relay info in header with config data
updateStoredRelayInfo(responseData);
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
} else {
console.log('No configuration data received');
@@ -1335,14 +1366,16 @@ async function fetchConfiguration() {
throw new Error('Must be connected to relay to fetch configuration. Please use the Relay Connection section first.');
}
// First establish subscription to receive responses
// First establish subscription to receive responses (only if not already subscribed)
const subscriptionResult = await subscribeToConfiguration();
if (!subscriptionResult) {
throw new Error('Failed to establish admin response subscription');
}
// Wait a moment for subscription to be established
await new Promise(resolve => setTimeout(resolve, 500));
// Wait a moment for subscription to be established (only if we just created it)
if (!isSubscribed) {
await new Promise(resolve => setTimeout(resolve, 500));
}
// Send config query command if logged in
if (isLoggedIn && userPubkey && relayPool) {
@@ -1698,6 +1731,43 @@ if (logoutBtn) {
});
}
// Initialize dark mode button handler
const darkModeBtn = document.getElementById('dark-mode-btn');
if (darkModeBtn) {
darkModeBtn.addEventListener('click', function(e) {
e.stopPropagation(); // Prevent profile area click
toggleDarkMode();
});
}
// Initialize relay pubkey container click handler for clipboard copy
const relayPubkeyContainer = document.getElementById('relay-pubkey-container');
if (relayPubkeyContainer) {
relayPubkeyContainer.addEventListener('click', async function() {
const relayPubkeyElement = document.getElementById('relay-pubkey');
if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') {
try {
// Get the full npub (remove line breaks for clipboard)
const fullNpub = relayPubkeyElement.textContent.replace(/\n/g, '');
await navigator.clipboard.writeText(fullNpub);
// Add copied class for visual feedback
relayPubkeyContainer.classList.add('copied');
// Remove the class after animation completes
setTimeout(() => {
relayPubkeyContainer.classList.remove('copied');
}, 500);
log('Relay npub copied to clipboard', 'INFO');
} catch (error) {
log('Failed to copy relay npub to clipboard', 'ERROR');
}
}
});
}
// Event handlers
fetchConfigBtn.addEventListener('click', function (e) {
e.preventDefault();
@@ -3181,16 +3251,90 @@ function addMessageToInbox(direction, message, timestamp, pubkey = null) {
}
}
// Update relay info in header
function updateRelayInfoInHeader() {
const relayNameElement = document.getElementById('relay-name');
const relayPubkeyElement = document.getElementById('relay-pubkey');
const relayDescriptionElement = document.getElementById('relay-description');
if (!relayNameElement || !relayPubkeyElement || !relayDescriptionElement) {
return;
}
// Get relay info from NIP-11 data or use defaults
const relayInfo = getRelayInfo();
const relayName = relayInfo.name || 'C-Relay';
const relayDescription = relayInfo.description || 'Nostr Relay';
// Convert relay pubkey to npub
let relayNpub = 'Loading...';
if (relayPubkey) {
try {
relayNpub = window.NostrTools.nip19.npubEncode(relayPubkey);
} catch (error) {
console.log('Failed to encode relay pubkey to npub:', error.message);
relayNpub = relayPubkey.substring(0, 16) + '...';
}
}
// Format npub into 3 lines of 21 characters each
let formattedNpub = relayNpub;
if (relayNpub.length === 63) {
formattedNpub = relayNpub.substring(0, 21) + '\n' +
relayNpub.substring(21, 42) + '\n' +
relayNpub.substring(42, 63);
}
relayNameElement.textContent = relayName;
relayPubkeyElement.textContent = formattedNpub;
relayDescriptionElement.textContent = relayDescription;
}
// Global variable to store relay info from NIP-11 or config
let relayInfoData = null;
// Helper function to get relay info from stored data
function getRelayInfo() {
// Return stored relay info if available, otherwise defaults
if (relayInfoData) {
return relayInfoData;
}
// Default values
return {
name: 'C-Relay',
description: 'Nostr Relay',
pubkey: relayPubkey
};
}
// Update stored relay info when config is loaded
function updateStoredRelayInfo(configData) {
if (configData && configData.data) {
// Extract relay info from config data
const relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'C-Relay';
const relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Nostr Relay';
relayInfoData = {
name: relayName,
description: relayDescription,
pubkey: relayPubkey
};
// Update header immediately
updateRelayInfoInHeader();
}
}
// Helper function to get relay pubkey
function getRelayPubkey() {
// Use the dynamically fetched relay pubkey if available
if (relayPubkey && isRelayConnected) {
if (relayPubkey) {
return relayPubkey;
}
// Fallback to hardcoded value for testing/development
log('Warning: Using hardcoded relay pubkey. Please connect to relay first.', 'WARNING');
return '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
// No fallback - throw error if relay pubkey not available
throw new Error('Relay pubkey not available. Please connect to relay first.');
}
// Enhanced SimplePool message handler to capture test responses
@@ -3692,13 +3836,73 @@ document.addEventListener('DOMContentLoaded', () => {
if (sendDmBtn) {
sendDmBtn.addEventListener('click', sendNIP17DM);
}
// SQL Query event handlers
const executeSqlBtn = document.getElementById('execute-sql-btn');
const clearSqlBtn = document.getElementById('clear-sql-btn');
const clearHistoryBtn = document.getElementById('clear-history-btn');
if (executeSqlBtn) {
executeSqlBtn.addEventListener('click', executeSqlQuery);
}
if (clearSqlBtn) {
clearSqlBtn.addEventListener('click', clearSqlQuery);
}
if (clearHistoryBtn) {
clearHistoryBtn.addEventListener('click', clearQueryHistory);
}
});
// Dark mode functionality
function toggleDarkMode() {
const body = document.body;
const isDarkMode = body.classList.contains('dark-mode');
if (isDarkMode) {
body.classList.remove('dark-mode');
localStorage.setItem('darkMode', 'false');
updateDarkModeButton(false);
log('Switched to light mode', 'INFO');
} else {
body.classList.add('dark-mode');
localStorage.setItem('darkMode', 'true');
updateDarkModeButton(true);
log('Switched to dark mode', 'INFO');
}
}
function updateDarkModeButton(isDarkMode) {
const darkModeBtn = document.getElementById('dark-mode-btn');
if (darkModeBtn) {
darkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE';
}
}
function initializeDarkMode() {
const savedDarkMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark);
if (shouldBeDark) {
document.body.classList.add('dark-mode');
updateDarkModeButton(true);
} else {
updateDarkModeButton(false);
}
}
// Initialize the app
document.addEventListener('DOMContentLoaded', () => {
console.log('C-Relay Admin API interface loaded');
// Initialize dark mode
initializeDarkMode();
// Start RELAY letter animation
startRelayAnimation();
// Ensure admin sections are hidden by default on page load
updateAdminSectionsVisibility();
@@ -3708,4 +3912,339 @@ document.addEventListener('DOMContentLoaded', () => {
// Enhance SimplePool for testing after initialization
setTimeout(enhancePoolForTesting, 2000);
}, 100);
});
});
// ================================
// SQL QUERY FUNCTIONS
// ================================
// Predefined query templates
const SQL_QUERY_TEMPLATES = {
recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20",
event_stats: "SELECT * FROM event_stats",
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT * FROM time_stats_view"
};
// Query history management (localStorage)
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
const MAX_HISTORY_ITEMS = 20;
// Load query history from localStorage
function loadQueryHistory() {
try {
const history = localStorage.getItem(QUERY_HISTORY_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Failed to load query history:', e);
return [];
}
}
// Save query to history
function saveQueryToHistory(query) {
if (!query || query.trim().length === 0) return;
try {
let history = loadQueryHistory();
// Remove duplicate if exists
history = history.filter(q => q !== query);
// Add to beginning
history.unshift(query);
// Limit size
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history));
updateQueryDropdown();
} catch (e) {
console.error('Failed to save query history:', e);
}
}
// Clear query history
function clearQueryHistory() {
if (confirm('Clear all query history?')) {
localStorage.removeItem(QUERY_HISTORY_KEY);
updateQueryDropdown();
}
}
// Update dropdown with history
function updateQueryDropdown() {
const historyGroup = document.getElementById('history-group');
if (!historyGroup) return;
// Clear existing history options
historyGroup.innerHTML = '';
const history = loadQueryHistory();
if (history.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '(no history)';
option.disabled = true;
historyGroup.appendChild(option);
return;
}
history.forEach((query, index) => {
const option = document.createElement('option');
option.value = `history_${index}`;
// Truncate long queries for display
const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query;
option.textContent = displayQuery;
option.dataset.query = query;
historyGroup.appendChild(option);
});
}
// Load selected query from dropdown
function loadSelectedQuery() {
const dropdown = document.getElementById('query-dropdown');
const selectedValue = dropdown.value;
if (!selectedValue) return;
let query = '';
// Check if it's a template
if (SQL_QUERY_TEMPLATES[selectedValue]) {
query = SQL_QUERY_TEMPLATES[selectedValue];
}
// Check if it's from history
else if (selectedValue.startsWith('history_')) {
const selectedOption = dropdown.options[dropdown.selectedIndex];
query = selectedOption.dataset.query;
}
if (query) {
document.getElementById('sql-input').value = query;
}
// Reset dropdown to placeholder
dropdown.value = '';
}
// Clear the SQL query input
function clearSqlQuery() {
document.getElementById('sql-input').value = '';
document.getElementById('query-info').innerHTML = '';
document.getElementById('query-table').innerHTML = '';
}
// Execute SQL query via admin API
async function executeSqlQuery() {
const query = document.getElementById('sql-input').value;
if (!query.trim()) {
log('Please enter a SQL query', 'ERROR');
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Please enter a SQL query</div>';
return;
}
try {
// Show loading state
document.getElementById('query-info').innerHTML = '<div class="loading">Executing query...</div>';
document.getElementById('query-table').innerHTML = '';
// Save to history (before execution, so it's saved even if query fails)
saveQueryToHistory(query.trim());
// Send query as kind 23456 admin command
const command = ["sql_query", query];
const requestEvent = await sendAdminCommand(command);
// Store query info for when response arrives
if (requestEvent && requestEvent.id) {
pendingSqlQueries.set(requestEvent.id, {
query: query,
timestamp: Date.now()
});
}
// Note: Response will be handled by the event listener
// which will call displaySqlQueryResults() when response arrives
} catch (error) {
log('Failed to execute query: ' + error.message, 'ERROR');
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Failed to execute query: ' + error.message + '</div>';
}
}
// Helper function to send admin commands (kind 23456 events)
async function sendAdminCommand(commandArray) {
if (!isLoggedIn || !userPubkey) {
throw new Error('Must be logged in to send admin commands');
}
if (!relayPool) {
throw new Error('SimplePool connection not available');
}
try {
log(`Sending admin command: ${JSON.stringify(commandArray)}`, 'INFO');
// Encrypt the command array directly using NIP-44
const encrypted_content = await encryptForRelay(JSON.stringify(commandArray));
if (!encrypted_content) {
throw new Error('Failed to encrypt command array');
}
// Create single 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 with detailed error diagnostics
const url = relayConnectionUrl.value.trim();
const publishPromises = relayPool.publish([url], signedEvent);
// Use Promise.allSettled to capture per-relay outcomes
const results = await Promise.allSettled(publishPromises);
// Log detailed publish results for diagnostics
let successCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
successCount++;
log(`✅ Admin command published successfully to relay ${index}`, 'INFO');
} else {
log(`❌ Admin command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
}
});
if (successCount === 0) {
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
throw new Error(`All relays rejected admin command event. Details: ${errorDetails}`);
}
log('Admin command sent successfully', 'INFO');
return signedEvent; // Return the signed event for request ID tracking
} catch (error) {
log(`Failed to send admin command: ${error.message}`, 'ERROR');
throw error;
}
}
// Display SQL query results
function displaySqlQueryResults(response) {
const infoDiv = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (response.status === 'error' || response.error) {
infoDiv.innerHTML = `<div class="error-message">❌ ${response.error || 'Query failed'}</div>`;
tableDiv.innerHTML = '';
return;
}
// Show query info with request ID for debugging
const rowCount = response.row_count || 0;
const execTime = response.execution_time_ms || 0;
const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown';
infoDiv.innerHTML = `
<div class="query-info-success">
<span>✅ Query executed successfully</span>
<span>Rows: ${rowCount}</span>
<span>Execution Time: ${execTime}ms</span>
<span class="request-id" title="${response.request_id || ''}">Request: ${requestId}</span>
</div>
`;
// Build results table
if (response.rows && response.rows.length > 0) {
let html = '<table class="sql-results-table"><thead><tr>';
response.columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
response.rows.forEach(row => {
html += '<tr>';
row.forEach(cell => {
const cellValue = cell === null ? '<em>NULL</em>' : escapeHtml(String(cell));
html += `<td>${cellValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p class="no-results">No results returned</p>';
}
}
// Handle SQL query response (called by event listener)
function handleSqlQueryResponse(response) {
// Check if this is a response to one of our queries
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
const queryInfo = pendingSqlQueries.get(response.request_id);
pendingSqlQueries.delete(response.request_id);
// Display results
displaySqlQueryResults(response);
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize query history on page load
document.addEventListener('DOMContentLoaded', function() {
updateQueryDropdown();
});
// RELAY letter animation function
function startRelayAnimation() {
const letters = document.querySelectorAll('.relay-letter');
let currentIndex = 0;
function animateLetter() {
// Remove underline from all letters first
letters.forEach(letter => letter.classList.remove('underlined'));
// Add underline to current letter
if (letters[currentIndex]) {
letters[currentIndex].classList.add('underlined');
}
// Move to next letter
currentIndex++;
// If we've gone through all letters, remove all underlines and wait 4000ms then restart
if (currentIndex > letters.length) {
// Remove all underlines before the pause
letters.forEach(letter => letter.classList.remove('underlined'));
setTimeout(() => {
currentIndex = 0;
animateLetter();
}, 4000);
} else {
// Otherwise, continue to next letter after 200ms
setTimeout(animateLetter, 100);
}
}
// Start the animation
animateLetter();
}

View File

@@ -9,11 +9,21 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BUILD_DIR="$SCRIPT_DIR/build"
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder"
echo "=========================================="
# Parse command line arguments
DEBUG_BUILD=false
if [[ "$1" == "--debug" ]]; then
DEBUG_BUILD=true
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder (DEBUG MODE)"
echo "=========================================="
else
echo "=========================================="
echo "C-Relay MUSL Static Binary Builder (PRODUCTION MODE)"
echo "=========================================="
fi
echo "Project directory: $SCRIPT_DIR"
echo "Build directory: $BUILD_DIR"
echo "Debug build: $DEBUG_BUILD"
echo ""
# Create build directory
@@ -83,6 +93,7 @@ echo ""
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
-f "$DOCKERFILE" \
-t c-relay-musl-builder:latest \
--progress=plain \
@@ -105,6 +116,7 @@ echo "=========================================="
# Build the builder stage to extract the binary
$DOCKER_CMD build \
--platform "$PLATFORM" \
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
--target builder \
-f "$DOCKERFILE" \
-t c-relay-static-builder-stage:latest \
@@ -179,11 +191,16 @@ echo "=========================================="
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
echo "Platform: $PLATFORM"
if [ "$DEBUG_BUILD" = true ]; then
echo "Build Type: DEBUG (with symbols, no optimization)"
else
echo "Build Type: PRODUCTION (optimized, stripped)"
fi
if [ "$TRULY_STATIC" = true ]; then
echo "Type: Fully static binary (Alpine MUSL-based)"
echo "Linkage: Fully static binary (Alpine MUSL-based)"
echo "Portability: Works on ANY Linux distribution"
else
echo "Type: Static binary (may have minimal dependencies)"
echo "Linkage: Static binary (may have minimal dependencies)"
fi
echo ""
echo "✓ Build complete!"

630
docs/sql_query_admin_api.md Normal file
View File

@@ -0,0 +1,630 @@
# SQL Query Admin API Design
## Overview
This document describes the design for a general-purpose SQL query interface for the C-Relay admin API. This allows administrators to execute read-only SQL queries against the relay database through cryptographically signed kind 23456 events with NIP-44 encrypted command arrays.
## Security Model
### Authentication
- All queries must be sent as kind 23456 events with NIP-44 encrypted content
- Events must be signed by the admin's private key
- Admin pubkey verified against `config.admin_pubkey`
- Follows the same authentication pattern as existing admin commands
### Query Restrictions
While authentication is cryptographically secure, we implement defensive safeguards:
1. **Read-Only Enforcement**
- Only SELECT statements allowed
- Block: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, PRAGMA (write operations)
- Allow: SELECT, WITH (for CTEs)
2. **Resource Limits**
- Query timeout: 5 seconds (configurable)
- Result row limit: 1000 rows (configurable)
- Result size limit: 1MB (configurable)
3. **Query Logging**
- All queries logged with timestamp, admin pubkey, execution time
- Failed queries logged with error message
## Command Format
### Admin Event Structure (Kind 23456)
```json
{
"id": "event_id",
"pubkey": "admin_public_key",
"created_at": 1234567890,
"kind": 23456,
"content": "AqHBUgcM7dXFYLQuDVzGwMST1G8jtWYyVvYxXhVGEu4nAb4LVw...",
"tags": [
["p", "relay_public_key"]
],
"sig": "event_signature"
}
```
The `content` field contains a NIP-44 encrypted JSON array:
```json
["sql_query", "SELECT * FROM events LIMIT 10"]
```
### Response Format (Kind 23457)
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44_encrypted_content",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
The `content` field contains NIP-44 encrypted JSON:
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "SELECT * FROM events LIMIT 10",
"execution_time_ms": 45,
"row_count": 10,
"columns": ["id", "pubkey", "created_at", "kind", "content"],
"rows": [
["abc123...", "def456...", 1234567890, 1, "Hello world"],
...
]
}
```
**Note:** The response includes the request event ID in two places:
1. **In tags**: `["e", "request_event_id"]` - Standard Nostr convention for event references
2. **In content**: `"request_id": "request_event_id"` - For easy access after decryption
### Error Response Format (Kind 23457)
```json
["EVENT", "temp_sub_id", {
"id": "response_event_id",
"pubkey": "relay_public_key",
"created_at": 1234567890,
"kind": 23457,
"content": "nip44_encrypted_content",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}]
```
The `content` field contains NIP-44 encrypted JSON:
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "DELETE FROM events",
"status": "error",
"error": "Query blocked: DELETE statements not allowed",
"error_type": "blocked_statement"
}
```
## Available Database Tables and Views
### Core Tables
- **events** - All Nostr events (id, pubkey, created_at, kind, content, tags, sig)
- **config** - Configuration key-value pairs
- **auth_rules** - Authentication and authorization rules
- **subscription_events** - Subscription lifecycle events
- **event_broadcasts** - Event broadcast log
### Useful Views
- **recent_events** - Last 1000 events
- **event_stats** - Event statistics by type
- **configuration_events** - Kind 33334 configuration events
- **subscription_analytics** - Subscription metrics by date
- **active_subscriptions_log** - Currently active subscriptions
- **event_kinds_view** - Event distribution by kind
- **top_pubkeys_view** - Top 10 pubkeys by event count
- **time_stats_view** - Time-based statistics (24h, 7d, 30d)
## Implementation Plan
### Backend (dm_admin.c)
#### 1. Query Validation Function
```c
int validate_sql_query(const char* query, char* error_msg, size_t error_size);
```
- Check for blocked keywords (case-insensitive)
- Validate query syntax (basic checks)
- Return 0 on success, -1 on failure
#### 2. Query Execution Function
```c
char* execute_sql_query(const char* query, char* error_msg, size_t error_size);
```
- Set query timeout using sqlite3_busy_timeout()
- Execute query with row/size limits
- Build JSON response with results
- Log query execution
- Return JSON string or NULL on error
#### 3. Command Handler Integration
Add to `process_dm_admin_command()` in [`dm_admin.c`](src/dm_admin.c:131):
```c
else if (strcmp(command_type, "sql_query") == 0) {
const char* query = get_tag_value(event, "sql_query", 1);
if (!query) {
DEBUG_ERROR("DM Admin: Missing sql_query parameter");
snprintf(error_message, error_size, "invalid: missing SQL query");
} else {
result = handle_sql_query_unified(event, query, error_message, error_size, wsi);
}
}
```
Add unified handler function:
```c
int handle_sql_query_unified(cJSON* event, const char* query,
char* error_message, size_t error_size,
struct lws* wsi) {
// 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);
// Validate query
if (!validate_sql_query(query, error_message, error_size)) {
return -1;
}
// Execute query and include request_id in result
char* result_json = execute_sql_query(query, request_id, error_message, error_size);
if (!result_json) {
return -1;
}
// Send response as kind 23457 event with request ID in tags
cJSON* sender_pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
if (!sender_pubkey_obj || !cJSON_IsString(sender_pubkey_obj)) {
free(result_json);
snprintf(error_message, error_size, "Missing sender pubkey");
return -1;
}
const char* sender_pubkey = cJSON_GetStringValue(sender_pubkey_obj);
int send_result = send_admin_response(sender_pubkey, result_json, request_id,
error_message, error_size, wsi);
free(result_json);
return send_result;
}
```
### Frontend (api/index.html)
#### SQL Query Section UI
Add to [`api/index.html`](api/index.html:1):
```html
<section id="sql-query-section" class="admin-section">
<h2>SQL Query Console</h2>
<div class="query-selector">
<label for="query-dropdown">Quick Queries & History:</label>
<select id="query-dropdown" onchange="loadSelectedQuery()">
<option value="">-- Select a query --</option>
<optgroup label="Common Queries">
<option value="recent_events">Recent Events</option>
<option value="event_stats">Event Statistics</option>
<option value="subscriptions">Active Subscriptions</option>
<option value="top_pubkeys">Top Pubkeys</option>
<option value="event_kinds">Event Kinds Distribution</option>
<option value="time_stats">Time-based Statistics</option>
</optgroup>
<optgroup label="Query History" id="history-group">
<!-- Dynamically populated from localStorage -->
</optgroup>
</select>
</div>
<div class="query-editor">
<label for="sql-input">SQL Query:</label>
<textarea id="sql-input" rows="5" placeholder="SELECT * FROM events LIMIT 10"></textarea>
<div class="query-actions">
<button onclick="executeSqlQuery()" class="primary-button">Execute Query</button>
<button onclick="clearSqlQuery()">Clear</button>
<button onclick="clearQueryHistory()" class="danger-button">Clear History</button>
</div>
</div>
<div class="query-results">
<h3>Results</h3>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="table-container"></div>
</div>
</section>
```
#### JavaScript Functions (api/index.js)
Add to [`api/index.js`](api/index.js:1):
```javascript
// Predefined query templates
const SQL_QUERY_TEMPLATES = {
recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20",
event_stats: "SELECT * FROM event_stats",
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
top_pubkeys: "SELECT * FROM top_pubkeys_view",
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
time_stats: "SELECT * FROM time_stats_view"
};
// Query history management (localStorage)
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
const MAX_HISTORY_ITEMS = 20;
// Load query history from localStorage
function loadQueryHistory() {
try {
const history = localStorage.getItem(QUERY_HISTORY_KEY);
return history ? JSON.parse(history) : [];
} catch (e) {
console.error('Failed to load query history:', e);
return [];
}
}
// Save query to history
function saveQueryToHistory(query) {
if (!query || query.trim().length === 0) return;
try {
let history = loadQueryHistory();
// Remove duplicate if exists
history = history.filter(q => q !== query);
// Add to beginning
history.unshift(query);
// Limit size
if (history.length > MAX_HISTORY_ITEMS) {
history = history.slice(0, MAX_HISTORY_ITEMS);
}
localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history));
updateQueryDropdown();
} catch (e) {
console.error('Failed to save query history:', e);
}
}
// Clear query history
function clearQueryHistory() {
if (confirm('Clear all query history?')) {
localStorage.removeItem(QUERY_HISTORY_KEY);
updateQueryDropdown();
}
}
// Update dropdown with history
function updateQueryDropdown() {
const historyGroup = document.getElementById('history-group');
if (!historyGroup) return;
// Clear existing history options
historyGroup.innerHTML = '';
const history = loadQueryHistory();
if (history.length === 0) {
const option = document.createElement('option');
option.value = '';
option.textContent = '(no history)';
option.disabled = true;
historyGroup.appendChild(option);
return;
}
history.forEach((query, index) => {
const option = document.createElement('option');
option.value = `history_${index}`;
// Truncate long queries for display
const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query;
option.textContent = displayQuery;
option.dataset.query = query;
historyGroup.appendChild(option);
});
}
// Load selected query from dropdown
function loadSelectedQuery() {
const dropdown = document.getElementById('query-dropdown');
const selectedValue = dropdown.value;
if (!selectedValue) return;
let query = '';
// Check if it's a template
if (SQL_QUERY_TEMPLATES[selectedValue]) {
query = SQL_QUERY_TEMPLATES[selectedValue];
}
// Check if it's from history
else if (selectedValue.startsWith('history_')) {
const selectedOption = dropdown.options[dropdown.selectedIndex];
query = selectedOption.dataset.query;
}
if (query) {
document.getElementById('sql-input').value = query;
}
// Reset dropdown to placeholder
dropdown.value = '';
}
// Initialize query history on page load
document.addEventListener('DOMContentLoaded', function() {
updateQueryDropdown();
});
// Clear the SQL query input
function clearSqlQuery() {
document.getElementById('sql-input').value = '';
document.getElementById('query-info').innerHTML = '';
document.getElementById('query-table').innerHTML = '';
}
// Track pending SQL queries by request ID
const pendingSqlQueries = new Map();
// Execute SQL query via admin API
async function executeSqlQuery() {
const query = document.getElementById('sql-input').value;
if (!query.trim()) {
showError('Please enter a SQL query');
return;
}
try {
// Show loading state
document.getElementById('query-info').innerHTML = '<div class="loading">Executing query...</div>';
document.getElementById('query-table').innerHTML = '';
// Save to history (before execution, so it's saved even if query fails)
saveQueryToHistory(query.trim());
// Send query as kind 23456 admin command
const command = ["sql_query", query];
const requestEvent = await sendAdminCommand(command);
// Store query info for when response arrives
if (requestEvent && requestEvent.id) {
pendingSqlQueries.set(requestEvent.id, {
query: query,
timestamp: Date.now()
});
}
// Note: Response will be handled by the event listener
// which will call displaySqlQueryResults() when response arrives
} catch (error) {
showError('Failed to execute query: ' + error.message);
}
}
// Handle SQL query response (called by event listener)
function handleSqlQueryResponse(response) {
// Check if this is a response to one of our queries
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
const queryInfo = pendingSqlQueries.get(response.request_id);
pendingSqlQueries.delete(response.request_id);
// Display results
displaySqlQueryResults(response);
}
}
// Display SQL query results
function displaySqlQueryResults(response) {
const infoDiv = document.getElementById('query-info');
const tableDiv = document.getElementById('query-table');
if (response.status === 'error' || response.error) {
infoDiv.innerHTML = `<div class="error-message">❌ ${response.error || 'Query failed'}</div>`;
tableDiv.innerHTML = '';
return;
}
// Show query info with request ID for debugging
const rowCount = response.row_count || 0;
const execTime = response.execution_time_ms || 0;
const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown';
infoDiv.innerHTML = `
<div class="query-info-success">
<span>✅ Query executed successfully</span>
<span>Rows: ${rowCount}</span>
<span>Execution Time: ${execTime}ms</span>
<span class="request-id" title="${response.request_id || ''}">Request: ${requestId}</span>
</div>
`;
// Build results table
if (response.rows && response.rows.length > 0) {
let html = '<table class="sql-results-table"><thead><tr>';
response.columns.forEach(col => {
html += `<th>${escapeHtml(col)}</th>`;
});
html += '</tr></thead><tbody>';
response.rows.forEach(row => {
html += '<tr>';
row.forEach(cell => {
const cellValue = cell === null ? '<em>NULL</em>' : escapeHtml(String(cell));
html += `<td>${cellValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
tableDiv.innerHTML = html;
} else {
tableDiv.innerHTML = '<p class="no-results">No results returned</p>';
}
}
// Helper function to escape HTML
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
```
## Example Queries
### Subscription Statistics
```sql
SELECT
date,
subscriptions_created,
subscriptions_ended,
avg_duration_seconds,
unique_clients
FROM subscription_analytics
ORDER BY date DESC
LIMIT 7;
```
### Event Distribution by Kind
```sql
SELECT kind, count, percentage
FROM event_kinds_view
ORDER BY count DESC;
```
### Recent Events by Specific Pubkey
```sql
SELECT id, created_at, kind, content
FROM events
WHERE pubkey = 'abc123...'
ORDER BY created_at DESC
LIMIT 20;
```
### Active Subscriptions with Details
```sql
SELECT
subscription_id,
client_ip,
events_sent,
duration_seconds,
filter_json
FROM active_subscriptions_log
ORDER BY created_at DESC;
```
### Database Size and Event Count
```sql
SELECT
(SELECT COUNT(*) FROM events) as total_events,
(SELECT COUNT(*) FROM subscription_events) as total_subscriptions,
(SELECT COUNT(*) FROM auth_rules WHERE active = 1) as active_rules;
```
## Configuration Options
Add to config table:
```sql
INSERT INTO config (key, value, data_type, description, category) VALUES
('sql_query_enabled', 'true', 'boolean', 'Enable SQL query admin API', 'admin'),
('sql_query_timeout', '5', 'integer', 'Query timeout in seconds', 'admin'),
('sql_query_row_limit', '1000', 'integer', 'Maximum rows per query', 'admin'),
('sql_query_size_limit', '1048576', 'integer', 'Maximum result size in bytes', 'admin'),
('sql_query_log_enabled', 'true', 'boolean', 'Log all SQL queries', 'admin');
```
## Security Considerations
### What This Protects Against
1. **Unauthorized Access** - Only admin can execute queries (cryptographic verification)
2. **Data Modification** - Read-only enforcement prevents accidental/malicious changes
3. **Resource Exhaustion** - Timeouts and limits prevent DoS
4. **Audit Trail** - All queries logged for security review
### What This Does NOT Protect Against
1. **Admin Compromise** - If admin private key is stolen, attacker has full read access
2. **Information Disclosure** - Admin can read all data (by design)
3. **Complex Attacks** - Sophisticated SQL injection might bypass simple keyword blocking
### Recommendations
1. **Secure Admin Key** - Store admin private key securely, never commit to git
2. **Monitor Query Logs** - Review query logs regularly for suspicious activity
3. **Backup Database** - Regular backups in case of issues
4. **Test Queries** - Test complex queries on development relay first
## Testing Plan
### Unit Tests
1. Query validation (blocked keywords, syntax)
2. Result formatting (JSON structure)
3. Error handling (timeouts, limits)
### Integration Tests
1. Execute queries through NIP-17 DM
2. Verify authentication (admin vs non-admin)
3. Test resource limits (timeout, row limit)
4. Test error responses
### Security Tests
1. Attempt blocked statements (INSERT, DELETE, etc.)
2. Attempt SQL injection patterns
3. Test query timeout with slow queries
4. Test row limit with large result sets
## Future Enhancements
1. **Query History** - Store recent queries for quick re-execution
2. **Query Favorites** - Save frequently used queries
3. **Export Results** - Download results as CSV/JSON
4. **Query Builder** - Visual query builder for common operations
5. **Real-time Updates** - WebSocket updates for live data
6. **Query Sharing** - Share queries with other admins (if multi-admin support added)
## Migration Path
### Phase 1: Backend Implementation
1. Add query validation function
2. Add query execution function
3. Integrate with NIP-17 command handler
4. Add configuration options
5. Add query logging
### Phase 2: Frontend Implementation
1. Add SQL query section to index.html
2. Add query execution JavaScript
3. Add predefined query templates
4. Add results display formatting
### Phase 3: Testing and Documentation
1. Write unit tests
2. Write integration tests
3. Update user documentation
4. Create query examples guide
### Phase 4: Enhancement
1. Add query history
2. Add export functionality
3. Optimize performance
4. Add more predefined templates

258
docs/sql_test_design.md Normal file
View File

@@ -0,0 +1,258 @@
# SQL Query Test Script Design
## Overview
Test script for validating the SQL query admin API functionality. Tests query validation, execution, error handling, and security features.
## Script: tests/sql_test.sh
### Test Categories
#### 1. Query Validation Tests
- ✅ Valid SELECT queries accepted
- ❌ INSERT statements blocked
- ❌ UPDATE statements blocked
- ❌ DELETE statements blocked
- ❌ DROP statements blocked
- ❌ CREATE statements blocked
- ❌ ALTER statements blocked
- ❌ PRAGMA write operations blocked
#### 2. Query Execution Tests
- ✅ Simple SELECT query
- ✅ SELECT with WHERE clause
- ✅ SELECT with JOIN
- ✅ SELECT with ORDER BY and LIMIT
- ✅ Query against views
- ✅ Query with aggregate functions (COUNT, SUM, AVG)
#### 3. Response Format Tests
- ✅ Response includes request_id
- ✅ Response includes query_type
- ✅ Response includes columns array
- ✅ Response includes rows array
- ✅ Response includes row_count
- ✅ Response includes execution_time_ms
#### 4. Error Handling Tests
- ❌ Invalid SQL syntax
- ❌ Non-existent table
- ❌ Non-existent column
- ❌ Query timeout (if configurable)
#### 5. Security Tests
- ❌ SQL injection attempts blocked
- ❌ Nested query attacks blocked
- ❌ Comment-based attacks blocked
#### 6. Concurrent Query Tests
- ✅ Multiple queries in parallel
- ✅ Responses correctly correlated to requests
## Script Structure
```bash
#!/bin/bash
# SQL Query Admin API Test Script
# Tests the sql_query command functionality
set -e
RELAY_URL="${RELAY_URL:-ws://localhost:8888}"
ADMIN_PRIVKEY="${ADMIN_PRIVKEY:-}"
RELAY_PUBKEY="${RELAY_PUBKEY:-}"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Test counters
TESTS_RUN=0
TESTS_PASSED=0
TESTS_FAILED=0
# Helper functions
print_test() {
echo -e "${YELLOW}TEST: $1${NC}"
TESTS_RUN=$((TESTS_RUN + 1))
}
print_pass() {
echo -e "${GREEN}✓ PASS: $1${NC}"
TESTS_PASSED=$((TESTS_PASSED + 1))
}
print_fail() {
echo -e "${RED}✗ FAIL: $1${NC}"
TESTS_FAILED=$((TESTS_FAILED + 1))
}
# Send SQL query command
send_sql_query() {
local query="$1"
# Implementation using nostr CLI tools or curl
# Returns response JSON
}
# Test functions
test_valid_select() {
print_test "Valid SELECT query"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1")
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "Valid SELECT accepted"
else
print_fail "Valid SELECT rejected"
fi
}
test_blocked_insert() {
print_test "INSERT statement blocked"
local response=$(send_sql_query "INSERT INTO events VALUES (...)")
if echo "$response" | grep -q '"error"'; then
print_pass "INSERT correctly blocked"
else
print_fail "INSERT not blocked"
fi
}
# ... more test functions ...
# Main test execution
main() {
echo "================================"
echo "SQL Query Admin API Tests"
echo "================================"
echo ""
# Check prerequisites
if [ -z "$ADMIN_PRIVKEY" ]; then
echo "Error: ADMIN_PRIVKEY not set"
exit 1
fi
# Run test suites
echo "1. Query Validation Tests"
test_valid_select
test_blocked_insert
test_blocked_update
test_blocked_delete
test_blocked_drop
echo ""
echo "2. Query Execution Tests"
test_simple_select
test_select_with_where
test_select_with_join
test_select_views
echo ""
echo "3. Response Format Tests"
test_response_format
test_request_id_correlation
echo ""
echo "4. Error Handling Tests"
test_invalid_syntax
test_nonexistent_table
echo ""
echo "5. Security Tests"
test_sql_injection
echo ""
echo "6. Concurrent Query Tests"
test_concurrent_queries
# Print summary
echo ""
echo "================================"
echo "Test Summary"
echo "================================"
echo "Tests Run: $TESTS_RUN"
echo "Tests Passed: $TESTS_PASSED"
echo "Tests Failed: $TESTS_FAILED"
if [ $TESTS_FAILED -eq 0 ]; then
echo -e "${GREEN}All tests passed!${NC}"
exit 0
else
echo -e "${RED}Some tests failed${NC}"
exit 1
fi
}
main "$@"
```
## Test Data Setup
The script should work with the existing relay database without requiring special test data, using:
- Existing events table
- Existing views (event_stats, recent_events, etc.)
- Existing config table
## Usage
```bash
# Set environment variables
export ADMIN_PRIVKEY="your_admin_private_key_hex"
export RELAY_PUBKEY="relay_public_key_hex"
export RELAY_URL="ws://localhost:8888"
# Run tests
./tests/sql_test.sh
# Run specific test category
./tests/sql_test.sh validation
./tests/sql_test.sh security
```
## Integration with CI/CD
The script should:
- Return exit code 0 on success, 1 on failure
- Output TAP (Test Anything Protocol) format for CI integration
- Be runnable in automated test pipelines
- Not require manual intervention
## Dependencies
- `bash` (version 4+)
- `curl` or `websocat` for WebSocket communication
- `jq` for JSON parsing
- Nostr CLI tools (optional, for event signing)
- Running c-relay instance
## Example Output
```
================================
SQL Query Admin API Tests
================================
1. Query Validation Tests
TEST: Valid SELECT query
✓ PASS: Valid SELECT accepted
TEST: INSERT statement blocked
✓ PASS: INSERT correctly blocked
TEST: UPDATE statement blocked
✓ PASS: UPDATE correctly blocked
2. Query Execution Tests
TEST: Simple SELECT query
✓ PASS: Query executed successfully
TEST: SELECT with WHERE clause
✓ PASS: WHERE clause works correctly
...
================================
Test Summary
================================
Tests Run: 24
Tests Passed: 24
Tests Failed: 0
All tests passed!

View File

@@ -39,6 +39,40 @@ Even simpler: Use this one-liner
cd /usr/local/bin/c_relay
sudo -u c-relay ./c_relay --debug-level=5 & sleep 2 && sudo gdb -p $(pgrep c_relay)
Once gdb attaches, type continue and wait for the crash. This way the relay starts normally and gdb just monitors it.
Which approach would you like to try?
How to View the Logs
Check systemd journal:
# View all c-relay logs
sudo journalctl -u c-relay
# View recent logs (last 50 lines)
sudo journalctl -u c-relay -n 50
# Follow logs in real-time
sudo journalctl -u c-relay -f
# View logs since last boot
sudo journalctl -u c-relay -b
Check if service is running:
To immediately trim the syslog file size:
Safe Syslog Truncation
Stop syslog service first:
sudo systemctl stop rsyslog
Truncate the syslog file:
sudo truncate -s 0 /var/log/syslog
Restart syslog service:
sudo systemctl start rsyslog
sudo systemctl status rsyslog
sudo -u c-relay ./c_relay --debug-level=5 -r 85d0b37e2ae822966dcadd06b2dc9368cde73865f90ea4d44f8b57d47ef0820a -a 1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139
./c_relay_static_x86_64 -p 7889 --debug-level=5 -r 85d0b37e2ae822966dcadd06b2dc9368cde73865f90ea4d44f8b57d47ef0820a -a 1ec454734dcbf6fe54901ce25c0c7c6bca5edd89443416761fadc321d38df139

View File

@@ -1 +1 @@
4042238
752613

1526
src/api.c

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
// API module for serving embedded web content
// API module for serving embedded web content and admin API functions
#ifndef API_H
#define API_H
#include <libwebsockets.h>
#include <cjson/cJSON.h>
// Embedded file session data structure for managing buffer lifetime
struct embedded_file_session_data {
@@ -14,10 +15,48 @@ struct embedded_file_session_data {
int body_sent;
};
// Configuration change pending structure
typedef struct pending_config_change {
char admin_pubkey[65]; // Who requested the change
char config_key[128]; // What config to change
char old_value[256]; // Current value
char new_value[256]; // Requested new value
time_t timestamp; // When requested
char change_id[33]; // Unique ID for this change (first 32 chars of hash)
struct pending_config_change* next; // Linked list for concurrent changes
} pending_config_change_t;
// Handle HTTP request for embedded API files
int handle_embedded_file_request(struct lws* wsi, const char* requested_uri);
// Generate stats JSON from database queries
char* generate_stats_json(void);
// Generate human-readable stats text
char* generate_stats_text(void);
// Generate config text from database
char* generate_config_text(void);
// Send admin response with request ID correlation
int send_admin_response(const char* sender_pubkey, const char* response_content, const char* request_id,
char* error_message, size_t error_size, struct lws* wsi);
// Configuration change system functions
int parse_config_command(const char* message, char* key, char* value);
int validate_config_change(const char* key, const char* value);
char* store_pending_config_change(const char* admin_pubkey, const char* key,
const char* old_value, const char* new_value);
pending_config_change_t* find_pending_change(const char* admin_pubkey, const char* change_id);
int apply_config_change(const char* key, const char* value);
void cleanup_expired_pending_changes(void);
int handle_config_confirmation(const char* admin_pubkey, const char* response);
char* generate_config_change_confirmation(const char* key, const char* old_value, const char* new_value);
int process_config_change_request(const char* admin_pubkey, const char* message);
// SQL query functions
int validate_sql_query(const char* query, char* error_message, size_t error_size);
char* execute_sql_query(const char* query, const char* request_id, char* error_message, size_t error_size);
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
#endif // API_H

File diff suppressed because it is too large Load Diff

View File

@@ -24,4 +24,11 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
char* generate_config_text(void);
char* generate_stats_text(void);
// SQL query admin functions
int validate_sql_query(const char* query, char* error_message, size_t error_size);
char* execute_sql_query(const char* query, const char* request_id, char* error_message, size_t error_size);
int handle_sql_query_unified(cJSON* event, const char* query, char* error_message, size_t error_size, struct lws* wsi);
int send_admin_response(const char* sender_pubkey, const char* response_content, const char* request_id,
char* error_message, size_t error_size, struct lws* wsi);
#endif // DM_ADMIN_H

File diff suppressed because one or more lines are too long

View File

@@ -123,7 +123,7 @@ void free_subscription_filter(subscription_filter_t* filter) {
}
// Validate subscription ID format and length
static int validate_subscription_id(const char* sub_id) {
int validate_subscription_id(const char* sub_id) {
if (!sub_id) {
return 0; // NULL pointer
}
@@ -133,11 +133,11 @@ static int validate_subscription_id(const char* sub_id) {
return 0; // Empty or too long
}
// Check for valid characters (alphanumeric, underscore, hyphen, colon)
// Check for valid characters (alphanumeric, underscore, hyphen, colon, comma)
for (size_t i = 0; i < len; i++) {
char c = sub_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':' || c == ',')) {
return 0; // Invalid character
}
}

View File

@@ -93,6 +93,7 @@ struct subscription_manager {
};
// Function declarations
int validate_subscription_id(const char* sub_id);
subscription_filter_t* create_subscription_filter(cJSON* filter_json);
void free_subscription_filter(subscription_filter_t* filter);
subscription_t* create_subscription(const char* sub_id, struct lws* wsi, cJSON* filters_array, const char* client_ip);

View File

@@ -707,38 +707,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Check subscription ID format and length
size_t id_len = strlen(subscription_id);
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
send_notice_message(wsi, "error: subscription ID too long or empty");
DEBUG_WARN("REQ rejected: invalid subscription ID length");
cJSON_Delete(json);
free(message);
return 0;
}
// Validate characters in subscription ID
int valid_id = 1;
char invalid_char = '\0';
size_t invalid_pos = 0;
for (size_t i = 0; i < id_len; i++) {
char c = subscription_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
valid_id = 0;
invalid_char = c;
invalid_pos = i;
break;
}
}
if (!valid_id) {
char debug_msg[512];
snprintf(debug_msg, sizeof(debug_msg),
"REQ rejected: invalid character '%c' (0x%02X) at position %zu in subscription ID: '%s'",
invalid_char, (unsigned char)invalid_char, invalid_pos, subscription_id);
DEBUG_WARN(debug_msg);
send_notice_message(wsi, "error: invalid characters in subscription ID");
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID");
DEBUG_WARN("REQ rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;
@@ -866,30 +838,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
return 0;
}
// Check subscription ID format and length
size_t id_len = strlen(subscription_id);
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
send_notice_message(wsi, "error: subscription ID too long or empty in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID length");
cJSON_Delete(json);
free(message);
return 0;
}
// Validate characters in subscription ID
int valid_id = 1;
for (size_t i = 0; i < id_len; i++) {
char c = subscription_id[i];
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '_' || c == '-' || c == ':')) {
valid_id = 0;
break;
}
}
if (!valid_id) {
send_notice_message(wsi, "error: invalid characters in subscription ID for CLOSE");
DEBUG_WARN("CLOSE rejected: invalid characters in subscription ID");
// Validate subscription ID
if (!validate_subscription_id(subscription_id)) {
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
DEBUG_WARN("CLOSE rejected: invalid subscription ID");
cJSON_Delete(json);
free(message);
return 0;

448
tests/sql_test.sh Executable file
View File

@@ -0,0 +1,448 @@
#!/bin/bash
# SQL Query Admin API Test Script
# Tests the sql_query command functionality
set -e
# Configuration
RELAY_URL="ws://localhost:8888"
ADMIN_PRIVKEY="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
ADMIN_PUBKEY="6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3"
RELAY_PUBKEY="4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Test counters
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
# Helper functions
print_test() {
echo -e "${YELLOW}TEST: $1${NC}"
TOTAL_TESTS=$((TOTAL_TESTS + 1))
}
print_pass() {
echo -e "${GREEN}✓ PASS: $1${NC}"
PASSED_TESTS=$((PASSED_TESTS + 1))
}
print_fail() {
echo -e "${RED}✗ FAIL: $1${NC}"
FAILED_TESTS=$((FAILED_TESTS + 1))
}
# Check if nak is installed
check_nak() {
if ! command -v nak &> /dev/null; then
echo -e "${RED}ERROR: nak command not found. Please install nak first.${NC}"
echo -e "${RED}Visit: https://github.com/fiatjaf/nak${NC}"
exit 1
fi
echo -e "${GREEN}✓ nak is available${NC}"
}
# Send SQL query command via WebSocket using nak
send_sql_query() {
local query="$1"
local description="$2"
echo -n "Testing $description... "
# Create the admin command
COMMAND="[\"sql_query\", \"$query\"]"
# Encrypt the command using NIP-44
ENCRYPTED_COMMAND=$(nak encrypt "$COMMAND" \
--sec "$ADMIN_PRIVKEY" \
--recipient-pubkey "$RELAY_PUBKEY" 2>/dev/null)
if [ -z "$ENCRYPTED_COMMAND" ]; then
echo -e "${RED}FAILED${NC} - Failed to encrypt admin command"
return 1
fi
# Create admin event
ADMIN_EVENT=$(nak event \
--kind 23456 \
--content "$ENCRYPTED_COMMAND" \
--sec "$ADMIN_PRIVKEY" \
--tag "p=$RELAY_PUBKEY" 2>/dev/null)
if [ -z "$ADMIN_EVENT" ]; then
echo -e "${RED}FAILED${NC} - Failed to create admin event"
return 1
fi
echo "=== SENT EVENT ==="
echo "$ADMIN_EVENT"
echo "==================="
# Send SQL query event via WebSocket
local response
response=$(echo "$ADMIN_EVENT" | timeout 10 websocat -B 1048576 "$RELAY_URL" 2>/dev/null | head -3 || echo 'TIMEOUT')
echo "=== RECEIVED RESPONSE ==="
echo "$response"
echo "=========================="
if [[ "$response" == *"TIMEOUT"* ]]; then
echo -e "${RED}FAILED${NC} - Connection timeout"
return 1
fi
echo "$response" # Return the response for further processing
}
# Test functions
test_valid_select() {
print_test "Valid SELECT query"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "valid SELECT query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' && echo "$response" | grep -q '"row_count"'; then
print_pass "Valid SELECT accepted and executed"
else
print_fail "Valid SELECT failed: $response"
fi
}
test_select_count() {
print_test "SELECT COUNT(*) query"
local response=$(send_sql_query "SELECT COUNT(*) FROM events" "COUNT query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' && echo "$response" | grep -q '"row_count"'; then
print_pass "COUNT query executed successfully"
else
print_fail "COUNT query failed: $response"
fi
}
test_blocked_insert() {
print_test "INSERT statement blocked"
local response=$(send_sql_query "INSERT INTO events VALUES ('id', 'pubkey', 1234567890, 1, 'content', 'sig')" "INSERT blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "INSERT correctly blocked"
else
print_fail "INSERT not blocked: $response"
fi
}
test_blocked_update() {
print_test "UPDATE statement blocked"
local response=$(send_sql_query "UPDATE events SET content = 'test' WHERE id = 'abc123'" "UPDATE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "UPDATE correctly blocked"
else
print_fail "UPDATE not blocked: $response"
fi
}
test_blocked_delete() {
print_test "DELETE statement blocked"
local response=$(send_sql_query "DELETE FROM events WHERE id = 'abc123'" "DELETE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "DELETE correctly blocked"
else
print_fail "DELETE not blocked: $response"
fi
}
test_blocked_drop() {
print_test "DROP statement blocked"
local response=$(send_sql_query "DROP TABLE events" "DROP blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "DROP correctly blocked"
else
print_fail "DROP not blocked: $response"
fi
}
test_blocked_create() {
print_test "CREATE statement blocked"
local response=$(send_sql_query "CREATE TABLE test (id TEXT)" "CREATE blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "CREATE correctly blocked"
else
print_fail "CREATE not blocked: $response"
fi
}
test_blocked_alter() {
print_test "ALTER statement blocked"
local response=$(send_sql_query "ALTER TABLE events ADD COLUMN test TEXT" "ALTER blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "ALTER correctly blocked"
else
print_fail "ALTER not blocked: $response"
fi
}
test_blocked_pragma() {
print_test "PRAGMA statement blocked"
local response=$(send_sql_query "PRAGMA table_info(events)" "PRAGMA blocking")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"' && echo "$response" | grep -q '"error_type":"blocked_statement"'; then
print_pass "PRAGMA correctly blocked"
else
print_fail "PRAGMA not blocked: $response"
fi
}
test_select_with_where() {
print_test "SELECT with WHERE clause"
local response=$(send_sql_query "SELECT id, kind FROM events WHERE kind = 1 LIMIT 5" "WHERE clause query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "WHERE clause query executed"
else
print_fail "WHERE clause query failed: $response"
fi
}
test_select_with_join() {
print_test "SELECT with JOIN"
local response=$(send_sql_query "SELECT e.id, e.kind, s.events_sent FROM events e LEFT JOIN active_subscriptions_log s ON e.id = s.subscription_id LIMIT 3" "JOIN query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "JOIN query executed"
else
print_fail "JOIN query failed: $response"
fi
}
test_select_views() {
print_test "SELECT from views"
local response=$(send_sql_query "SELECT * FROM event_kinds_view LIMIT 5" "view query")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "View query executed"
else
print_fail "View query failed: $response"
fi
}
test_nonexistent_table() {
print_test "Query nonexistent table"
local response=$(send_sql_query "SELECT * FROM nonexistent_table" "nonexistent table")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"'; then
print_pass "Nonexistent table error handled correctly"
else
print_fail "Nonexistent table error not handled: $response"
fi
}
test_invalid_syntax() {
print_test "Invalid SQL syntax"
local response=$(send_sql_query "SELECT * FROM events WHERE" "invalid syntax")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"status":"error"'; then
print_pass "Invalid syntax error handled"
else
print_fail "Invalid syntax not handled: $response"
fi
}
test_request_id_correlation() {
print_test "Request ID correlation"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "request ID correlation")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"request_id"'; then
print_pass "Request ID included in response"
else
print_fail "Request ID missing from response: $response"
fi
}
test_response_format() {
print_test "Response format validation"
local response=$(send_sql_query "SELECT * FROM events LIMIT 1" "response format")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"' &&
echo "$response" | grep -q '"timestamp"' &&
echo "$response" | grep -q '"execution_time_ms"' &&
echo "$response" | grep -q '"row_count"' &&
echo "$response" | grep -q '"columns"' &&
echo "$response" | grep -q '"rows"'; then
print_pass "Response format is valid"
else
print_fail "Response format invalid: $response"
fi
}
test_empty_result() {
print_test "Empty result set"
local response=$(send_sql_query "SELECT * FROM events WHERE kind = 99999" "empty result")
if [[ "$response" == *"TIMEOUT"* ]]; then
FAILED_TESTS=$((FAILED_TESTS + 1))
return 1
fi
if echo "$response" | grep -q '"query_type":"sql_query"'; then
print_pass "Empty result handled correctly"
else
print_fail "Empty result not handled: $response"
fi
}
echo "=========================================="
echo "C-Relay SQL Query Admin API Testing Suite"
echo "=========================================="
echo "Testing SQL query functionality at $RELAY_URL"
echo ""
# Check prerequisites
check_nak
# Test basic connectivity first
echo "=== Basic Connectivity Test ==="
print_test "Basic connectivity"
response=$(send_sql_query "SELECT 1" "basic connectivity")
if [[ "$response" == *"TIMEOUT"* ]]; then
echo -e "${RED}FAILED${NC} - Cannot connect to relay at $RELAY_URL"
echo "Make sure the relay is running and accessible."
exit 1
else
print_pass "Relay connection established"
fi
echo ""
# Run test suites
echo "=== Query Validation Tests ==="
test_valid_select
test_select_count
test_blocked_insert
test_blocked_update
test_blocked_delete
test_blocked_drop
test_blocked_create
test_blocked_alter
test_blocked_pragma
echo ""
echo "=== Query Execution Tests ==="
test_select_with_where
test_select_with_join
test_select_views
test_empty_result
echo ""
echo "=== Error Handling Tests ==="
test_nonexistent_table
test_invalid_syntax
echo ""
echo "=== Response Format Tests ==="
test_request_id_correlation
test_response_format
echo ""
echo "=== Test Results ==="
echo "Total tests: $TOTAL_TESTS"
echo -e "Passed: ${GREEN}$PASSED_TESTS${NC}"
echo -e "Failed: ${RED}$FAILED_TESTS${NC}"
if [[ $FAILED_TESTS -eq 0 ]]; then
echo -e "${GREEN}✓ All SQL query tests passed!${NC}"
echo "SQL query admin API is working correctly."
exit 0
else
echo -e "${RED}✗ Some SQL query tests failed!${NC}"
echo "SQL query admin API may have issues."
exit 1
fi