Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e312d7e18c | ||
|
|
6c38aaebf3 | ||
|
|
18b0ac44bf | ||
|
|
b6749eff2f |
@@ -1,6 +1,8 @@
|
|||||||
# Alpine-based MUSL static binary builder for C-Relay
|
# Alpine-based MUSL static binary builder for C-Relay
|
||||||
# Produces truly portable binaries with zero runtime dependencies
|
# Produces truly portable binaries with zero runtime dependencies
|
||||||
|
|
||||||
|
ARG DEBUG_BUILD=false
|
||||||
|
|
||||||
FROM alpine:3.19 AS builder
|
FROM alpine:3.19 AS builder
|
||||||
|
|
||||||
# Install build dependencies
|
# Install build dependencies
|
||||||
@@ -98,9 +100,19 @@ RUN cd nostr_core_lib && \
|
|||||||
COPY src/ /build/src/
|
COPY src/ /build/src/
|
||||||
COPY Makefile /build/Makefile
|
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
|
# 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 \
|
-U_FORTIFY_SOURCE -D_FORTIFY_SOURCE=0 \
|
||||||
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
-I. -Ic_utils_lib/src -Inostr_core_lib -Inostr_core_lib/nostr_core \
|
||||||
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
-Inostr_core_lib/cjson -Inostr_core_lib/nostr_websocket \
|
||||||
@@ -111,10 +123,8 @@ RUN gcc -static -g -O0 -DDEBUG -Wall -Wextra -std=c99 \
|
|||||||
c_utils_lib/libc_utils.a \
|
c_utils_lib/libc_utils.a \
|
||||||
nostr_core_lib/libnostr_core_x64.a \
|
nostr_core_lib/libnostr_core_x64.a \
|
||||||
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
|
-lwebsockets -lssl -lcrypto -lsqlite3 -lsecp256k1 \
|
||||||
-lcurl -lz -lpthread -lm -ldl
|
-lcurl -lz -lpthread -lm -ldl && \
|
||||||
|
eval "$STRIP_CMD"
|
||||||
# DO NOT strip - we need debug symbols for debugging
|
|
||||||
# RUN strip /build/c_relay_static
|
|
||||||
|
|
||||||
# Verify it's truly static
|
# Verify it's truly static
|
||||||
RUN echo "=== Binary Information ===" && \
|
RUN echo "=== Binary Information ===" && \
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -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_clear_auth` | `["system_command", "clear_all_auth_rules"]` | Clear all auth rules |
|
||||||
| `system_status` | `["system_command", "system_status"]` | Get system status |
|
| `system_status` | `["system_command", "system_status"]` | Get system status |
|
||||||
| `stats_query` | `["stats_query"]` | Get comprehensive database statistics |
|
| `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
|
### Available Configuration Keys
|
||||||
|
|
||||||
@@ -320,8 +322,68 @@ All admin commands return **signed EVENT responses** via WebSocket following sta
|
|||||||
],
|
],
|
||||||
"sig": "response_event_signature"
|
"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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
346
api/index.css
346
api/index.css
@@ -22,6 +22,23 @@
|
|||||||
--tab-border-opacity-logged-in: 0.1;
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -41,10 +58,8 @@ body {
|
|||||||
/* Header Styles */
|
/* Header Styles */
|
||||||
.main-header {
|
.main-header {
|
||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
border-bottom: var(--border-width) solid var(--border-color);
|
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -67,6 +82,86 @@ body {
|
|||||||
text-align: left;
|
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 {
|
.header-user-name {
|
||||||
display: block;
|
display: block;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -78,6 +173,7 @@ body {
|
|||||||
|
|
||||||
.profile-area {
|
.profile-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
position: relative;
|
position: relative;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -87,6 +183,14 @@ body {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-weight: normal;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.profile-container {
|
.profile-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -129,13 +233,13 @@ body {
|
|||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 10px 15px;
|
padding: 5px 10px;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 10px;
|
||||||
font-family: var(--font-family);
|
font-family: var(--font-family);
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
@@ -255,10 +359,10 @@ button:active {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
background-color: #ccc;
|
background-color: var(--muted-color);
|
||||||
color: var(--muted-color);
|
color: var(--primary-color);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
border-color: #ccc;
|
border-color: var(--muted-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flash animation for refresh button */
|
/* Flash animation for refresh button */
|
||||||
@@ -269,7 +373,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flash-red {
|
.flash-red {
|
||||||
animation: flash-red 0.5s ease-in-out;
|
animation: flash-red 1s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Flash animation for updated statistics values */
|
/* Flash animation for updated statistics values */
|
||||||
@@ -280,7 +384,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.flash-value {
|
.flash-value {
|
||||||
animation: flash-value 0.5s ease-in-out;
|
animation: flash-value 1s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Npub links styling */
|
/* Npub links styling */
|
||||||
@@ -346,6 +450,10 @@ button:disabled {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-table tbody tr:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
.config-table-container {
|
.config-table-container {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
@@ -353,12 +461,13 @@ button:disabled {
|
|||||||
|
|
||||||
.config-table th {
|
.config-table th {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
height: 40px; /* Double the default height */
|
height: 24px; /* Base height for tbody rows */
|
||||||
line-height: 40px; /* Center text vertically */
|
line-height: 24px; /* Center text vertically */
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-table tr:hover {
|
.config-table td {
|
||||||
background-color: var(--muted-color);
|
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 */
|
/* 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;
|
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;
|
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) {
|
@media (max-width: 700px) {
|
||||||
@@ -711,6 +1011,10 @@ button:disabled {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.query-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
@@ -718,4 +1022,14 @@ button:disabled {
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sql-results-table {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sql-results-table th,
|
||||||
|
.sql-results-table td {
|
||||||
|
padding: 4px 6px;
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10,21 +10,38 @@
|
|||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- Header with title and profile display -->
|
<!-- Header with title and profile display -->
|
||||||
<header class="main-header">
|
<div class="section">
|
||||||
<div class="header-content">
|
|
||||||
<div class="header-title">RELAY</div>
|
<div class="header-content">
|
||||||
<div class="profile-area" id="profile-area" style="display: none;">
|
<div class="header-title">
|
||||||
<div class="profile-container">
|
<span class="relay-letter" data-letter="R">R</span>
|
||||||
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
|
<span class="relay-letter" data-letter="E">E</span>
|
||||||
<span id="header-user-name" class="header-user-name">Loading...</span>
|
<span class="relay-letter" data-letter="L">L</span>
|
||||||
|
<span class="relay-letter" data-letter="A">A</span>
|
||||||
|
<span class="relay-letter" data-letter="Y">Y</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Logout dropdown -->
|
<div class="relay-info">
|
||||||
<div class="logout-dropdown" id="logout-dropdown" style="display: none;">
|
<div id="relay-name" class="relay-name">C-Relay</div>
|
||||||
<button type="button" id="logout-btn" class="logout-btn">LOGOUT</button>
|
<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>
|
</div>
|
||||||
</div>
|
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Login Modal Overlay -->
|
<!-- Login Modal Overlay -->
|
||||||
<div id="login-modal" class="login-modal-overlay" style="display: none;">
|
<div id="login-modal" class="login-modal-overlay" style="display: none;">
|
||||||
@@ -43,36 +60,30 @@
|
|||||||
|
|
||||||
<!-- Database Overview Table -->
|
<!-- Database Overview Table -->
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label>Database Overview:</label>
|
|
||||||
<div class="config-table-container">
|
<div class="config-table-container">
|
||||||
<table class="config-table" id="stats-overview-table">
|
<table class="config-table" id="stats-overview-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Metric</th>
|
<th>Metric</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="stats-overview-table-body">
|
<tbody id="stats-overview-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td>Database Size</td>
|
<td>Database Size</td>
|
||||||
<td id="db-size">-</td>
|
<td id="db-size">-</td>
|
||||||
<td>Current database file size</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Total Events</td>
|
<td>Total Events</td>
|
||||||
<td id="total-events">-</td>
|
<td id="total-events">-</td>
|
||||||
<td>Total number of events stored</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Oldest Event</td>
|
<td>Oldest Event</td>
|
||||||
<td id="oldest-event">-</td>
|
<td id="oldest-event">-</td>
|
||||||
<td>Timestamp of oldest event</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Newest Event</td>
|
<td>Newest Event</td>
|
||||||
<td id="newest-event">-</td>
|
<td id="newest-event">-</td>
|
||||||
<td>Timestamp of newest event</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -109,24 +120,20 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Period</th>
|
<th>Period</th>
|
||||||
<th>Events</th>
|
<th>Events</th>
|
||||||
<th>Description</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="stats-time-table-body">
|
<tbody id="stats-time-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td>Last 24 Hours</td>
|
<td>Last 24 Hours</td>
|
||||||
<td id="events-24h">-</td>
|
<td id="events-24h">-</td>
|
||||||
<td>Events in the last day</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Last 7 Days</td>
|
<td>Last 7 Days</td>
|
||||||
<td id="events-7d">-</td>
|
<td id="events-7d">-</td>
|
||||||
<td>Events in the last week</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>Last 30 Days</td>
|
<td>Last 30 Days</td>
|
||||||
<td id="events-30d">-</td>
|
<td id="events-30d">-</td>
|
||||||
<td>Events in the last month</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -267,6 +274,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Load the official nostr-tools bundle first -->
|
||||||
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
|
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
|
||||||
<script src="/api/nostr.bundle.js"></script>
|
<script src="/api/nostr.bundle.js"></script>
|
||||||
|
|||||||
575
api/index.js
575
api/index.js
@@ -22,6 +22,7 @@ let currentConfig = null;
|
|||||||
// Global subscription state
|
// Global subscription state
|
||||||
let relayPool = null;
|
let relayPool = null;
|
||||||
let subscriptionId = null;
|
let subscriptionId = null;
|
||||||
|
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
|
||||||
// Relay connection state
|
// Relay connection state
|
||||||
let relayInfo = null;
|
let relayInfo = null;
|
||||||
let isRelayConnected = false;
|
let isRelayConnected = false;
|
||||||
@@ -33,6 +34,9 @@ let statsAutoRefreshInterval = null;
|
|||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let countdownSeconds = 10;
|
let countdownSeconds = 10;
|
||||||
|
|
||||||
|
// SQL Query state
|
||||||
|
let pendingSqlQueries = new Map();
|
||||||
|
|
||||||
// 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');
|
||||||
@@ -353,6 +357,9 @@ async function setupAutomaticRelayConnection(showSections = false) {
|
|||||||
// Mark as connected
|
// Mark as connected
|
||||||
isRelayConnected = true;
|
isRelayConnected = true;
|
||||||
|
|
||||||
|
// Update relay info in header
|
||||||
|
updateRelayInfoInHeader();
|
||||||
|
|
||||||
// Only show admin sections if explicitly requested
|
// Only show admin sections if explicitly requested
|
||||||
if (showSections) {
|
if (showSections) {
|
||||||
updateAdminSectionsVisibility();
|
updateAdminSectionsVisibility();
|
||||||
@@ -470,12 +477,14 @@ function updateAdminSectionsVisibility() {
|
|||||||
const authRulesSection = document.getElementById('authRulesSection');
|
const authRulesSection = document.getElementById('authRulesSection');
|
||||||
const databaseStatisticsSection = document.getElementById('databaseStatisticsSection');
|
const databaseStatisticsSection = document.getElementById('databaseStatisticsSection');
|
||||||
const nip17DMSection = document.getElementById('nip17DMSection');
|
const nip17DMSection = document.getElementById('nip17DMSection');
|
||||||
|
const sqlQuerySection = document.getElementById('sqlQuerySection');
|
||||||
const shouldShow = isLoggedIn && isRelayConnected;
|
const shouldShow = isLoggedIn && isRelayConnected;
|
||||||
|
|
||||||
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
|
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
|
||||||
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
|
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
|
||||||
if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none';
|
if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none';
|
||||||
if (nip17DMSection) nip17DMSection.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
|
// Start/stop auto-refresh based on visibility
|
||||||
if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') {
|
if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') {
|
||||||
@@ -747,6 +756,8 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
relayPool = null;
|
relayPool = null;
|
||||||
subscriptionId = null;
|
subscriptionId = null;
|
||||||
|
// Reset subscription flag
|
||||||
|
isSubscribed = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
await nlLite.logout();
|
await nlLite.logout();
|
||||||
@@ -758,6 +769,8 @@ async function logout() {
|
|||||||
// Reset relay connection state
|
// Reset relay connection state
|
||||||
isRelayConnected = false;
|
isRelayConnected = false;
|
||||||
relayPubkey = null;
|
relayPubkey = null;
|
||||||
|
// Reset subscription flag
|
||||||
|
isSubscribed = false;
|
||||||
|
|
||||||
// Reset UI - hide profile and show login modal
|
// Reset UI - hide profile and show login modal
|
||||||
hideProfileFromHeader();
|
hideProfileFromHeader();
|
||||||
@@ -784,8 +797,8 @@ function updateConfigStatus(loaded) {
|
|||||||
|
|
||||||
// Generate random subscription ID (avoiding colons which are rejected by relay)
|
// Generate random subscription ID (avoiding colons which are rejected by relay)
|
||||||
function generateSubId() {
|
function generateSubId() {
|
||||||
// Use only alphanumeric characters, underscores, and hyphens
|
// Use only alphanumeric characters, underscores, hyphens, and commas
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-,';
|
||||||
let result = '';
|
let result = '';
|
||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
@@ -798,6 +811,12 @@ async function subscribeToConfiguration() {
|
|||||||
try {
|
try {
|
||||||
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
|
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) {
|
if (!isLoggedIn) {
|
||||||
console.log('WARNING: Not logged in, but proceeding with subscription test');
|
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}`);
|
console.log(`Connecting to relay via SimplePool: ${url}`);
|
||||||
|
|
||||||
// Clean up existing pool
|
// Reuse existing pool if available, otherwise create new one
|
||||||
if (relayPool) {
|
if (!relayPool) {
|
||||||
console.log('Closing existing pool connection');
|
console.log('Creating new SimplePool instance');
|
||||||
relayPool.close([url]);
|
relayPool = new window.NostrTools.SimplePool();
|
||||||
relayPool = null;
|
} else {
|
||||||
subscriptionId = null;
|
console.log('Reusing existing SimplePool instance');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new SimplePool instance
|
|
||||||
relayPool = new window.NostrTools.SimplePool();
|
|
||||||
subscriptionId = generateSubId();
|
subscriptionId = generateSubId();
|
||||||
|
|
||||||
console.log(`Generated subscription ID: ${subscriptionId}`);
|
console.log(`Generated subscription ID: ${subscriptionId}`);
|
||||||
@@ -838,6 +855,7 @@ async function subscribeToConfiguration() {
|
|||||||
"#p": [userPubkey], // Only DMs directed to this user
|
"#p": [userPubkey], // Only DMs directed to this user
|
||||||
limit: 50
|
limit: 50
|
||||||
}, {
|
}, {
|
||||||
|
since: Math.floor(Date.now() / 1000), // Start from current time
|
||||||
kinds: [1059], // NIP-17 GiftWrap events
|
kinds: [1059], // NIP-17 GiftWrap events
|
||||||
"#p": [userPubkey], // Only GiftWrap events addressed to this user
|
"#p": [userPubkey], // Only GiftWrap events addressed to this user
|
||||||
limit: 50
|
limit: 50
|
||||||
@@ -940,6 +958,9 @@ async function subscribeToConfiguration() {
|
|||||||
// Store subscription for cleanup
|
// Store subscription for cleanup
|
||||||
relayPool.currentSubscription = subscription;
|
relayPool.currentSubscription = subscription;
|
||||||
|
|
||||||
|
// Mark as subscribed to prevent duplicate attempts
|
||||||
|
isSubscribed = true;
|
||||||
|
|
||||||
console.log('SimplePool subscription established');
|
console.log('SimplePool subscription established');
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
@@ -1051,6 +1072,13 @@ function handleAdminResponseData(responseData) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle SQL query responses
|
||||||
|
if (responseData.query_type === 'sql_query') {
|
||||||
|
console.log('Routing to SQL query handler');
|
||||||
|
handleSqlQueryResponse(responseData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Generic response handling
|
// Generic response handling
|
||||||
console.log('Using generic response handler');
|
console.log('Using generic response handler');
|
||||||
if (typeof logTestEvent === 'function') {
|
if (typeof logTestEvent === 'function') {
|
||||||
@@ -1101,6 +1129,9 @@ function handleConfigQueryResponse(responseData) {
|
|||||||
// Display the configuration using the original display function
|
// Display the configuration using the original display function
|
||||||
displayConfiguration(syntheticEvent);
|
displayConfiguration(syntheticEvent);
|
||||||
|
|
||||||
|
// Update relay info in header with config data
|
||||||
|
updateStoredRelayInfo(responseData);
|
||||||
|
|
||||||
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
|
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
|
||||||
} else {
|
} else {
|
||||||
console.log('No configuration data received');
|
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.');
|
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();
|
const subscriptionResult = await subscribeToConfiguration();
|
||||||
if (!subscriptionResult) {
|
if (!subscriptionResult) {
|
||||||
throw new Error('Failed to establish admin response subscription');
|
throw new Error('Failed to establish admin response subscription');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a moment for subscription to be established
|
// Wait a moment for subscription to be established (only if we just created it)
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
if (!isSubscribed) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
// Send config query command if logged in
|
// Send config query command if logged in
|
||||||
if (isLoggedIn && userPubkey && relayPool) {
|
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
|
// Event handlers
|
||||||
fetchConfigBtn.addEventListener('click', function (e) {
|
fetchConfigBtn.addEventListener('click', function (e) {
|
||||||
e.preventDefault();
|
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
|
// Helper function to get relay pubkey
|
||||||
function getRelayPubkey() {
|
function getRelayPubkey() {
|
||||||
// Use the dynamically fetched relay pubkey if available
|
// Use the dynamically fetched relay pubkey if available
|
||||||
if (relayPubkey && isRelayConnected) {
|
if (relayPubkey) {
|
||||||
return relayPubkey;
|
return relayPubkey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to hardcoded value for testing/development
|
// No fallback - throw error if relay pubkey not available
|
||||||
log('Warning: Using hardcoded relay pubkey. Please connect to relay first.', 'WARNING');
|
throw new Error('Relay pubkey not available. Please connect to relay first.');
|
||||||
return '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhanced SimplePool message handler to capture test responses
|
// Enhanced SimplePool message handler to capture test responses
|
||||||
@@ -3692,13 +3836,73 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (sendDmBtn) {
|
if (sendDmBtn) {
|
||||||
sendDmBtn.addEventListener('click', sendNIP17DM);
|
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
|
// Initialize the app
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
console.log('C-Relay Admin API interface loaded');
|
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
|
// Ensure admin sections are hidden by default on page load
|
||||||
updateAdminSectionsVisibility();
|
updateAdminSectionsVisibility();
|
||||||
@@ -3708,4 +3912,339 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Enhance SimplePool for testing after initialization
|
// Enhance SimplePool for testing after initialization
|
||||||
setTimeout(enhancePoolForTesting, 2000);
|
setTimeout(enhancePoolForTesting, 2000);
|
||||||
}, 100);
|
}, 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();
|
||||||
|
}
|
||||||
@@ -9,11 +9,21 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
BUILD_DIR="$SCRIPT_DIR/build"
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
DOCKERFILE="$SCRIPT_DIR/Dockerfile.alpine-musl"
|
||||||
|
|
||||||
echo "=========================================="
|
# Parse command line arguments
|
||||||
echo "C-Relay MUSL Static Binary Builder"
|
DEBUG_BUILD=false
|
||||||
echo "=========================================="
|
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 "Project directory: $SCRIPT_DIR"
|
||||||
echo "Build directory: $BUILD_DIR"
|
echo "Build directory: $BUILD_DIR"
|
||||||
|
echo "Debug build: $DEBUG_BUILD"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Create build directory
|
# Create build directory
|
||||||
@@ -83,6 +93,7 @@ echo ""
|
|||||||
|
|
||||||
$DOCKER_CMD build \
|
$DOCKER_CMD build \
|
||||||
--platform "$PLATFORM" \
|
--platform "$PLATFORM" \
|
||||||
|
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||||
-f "$DOCKERFILE" \
|
-f "$DOCKERFILE" \
|
||||||
-t c-relay-musl-builder:latest \
|
-t c-relay-musl-builder:latest \
|
||||||
--progress=plain \
|
--progress=plain \
|
||||||
@@ -105,6 +116,7 @@ echo "=========================================="
|
|||||||
# Build the builder stage to extract the binary
|
# Build the builder stage to extract the binary
|
||||||
$DOCKER_CMD build \
|
$DOCKER_CMD build \
|
||||||
--platform "$PLATFORM" \
|
--platform "$PLATFORM" \
|
||||||
|
--build-arg DEBUG_BUILD=$DEBUG_BUILD \
|
||||||
--target builder \
|
--target builder \
|
||||||
-f "$DOCKERFILE" \
|
-f "$DOCKERFILE" \
|
||||||
-t c-relay-static-builder-stage:latest \
|
-t c-relay-static-builder-stage:latest \
|
||||||
@@ -179,11 +191,16 @@ echo "=========================================="
|
|||||||
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
|
echo "Binary: $BUILD_DIR/$OUTPUT_NAME"
|
||||||
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
|
echo "Size: $(du -h "$BUILD_DIR/$OUTPUT_NAME" | cut -f1)"
|
||||||
echo "Platform: $PLATFORM"
|
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
|
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"
|
echo "Portability: Works on ANY Linux distribution"
|
||||||
else
|
else
|
||||||
echo "Type: Static binary (may have minimal dependencies)"
|
echo "Linkage: Static binary (may have minimal dependencies)"
|
||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "✓ Build complete!"
|
echo "✓ Build complete!"
|
||||||
|
|||||||
630
docs/sql_query_admin_api.md
Normal file
630
docs/sql_query_admin_api.md
Normal 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
258
docs/sql_test_design.md
Normal 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!
|
||||||
38
notes.txt
38
notes.txt
@@ -39,6 +39,40 @@ Even simpler: Use this one-liner
|
|||||||
cd /usr/local/bin/c_relay
|
cd /usr/local/bin/c_relay
|
||||||
sudo -u c-relay ./c_relay --debug-level=5 & sleep 2 && sudo gdb -p $(pgrep 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
|
||||||
41
src/api.h
41
src/api.h
@@ -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
|
#ifndef API_H
|
||||||
#define API_H
|
#define API_H
|
||||||
|
|
||||||
#include <libwebsockets.h>
|
#include <libwebsockets.h>
|
||||||
|
#include <cjson/cJSON.h>
|
||||||
|
|
||||||
// Embedded file session data structure for managing buffer lifetime
|
// Embedded file session data structure for managing buffer lifetime
|
||||||
struct embedded_file_session_data {
|
struct embedded_file_session_data {
|
||||||
@@ -14,10 +15,48 @@ struct embedded_file_session_data {
|
|||||||
int body_sent;
|
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
|
// Handle HTTP request for embedded API files
|
||||||
int handle_embedded_file_request(struct lws* wsi, const char* requested_uri);
|
int handle_embedded_file_request(struct lws* wsi, const char* requested_uri);
|
||||||
|
|
||||||
// Generate stats JSON from database queries
|
// Generate stats JSON from database queries
|
||||||
char* generate_stats_json(void);
|
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
|
#endif // API_H
|
||||||
1162
src/dm_admin.c
1162
src/dm_admin.c
File diff suppressed because it is too large
Load Diff
@@ -24,4 +24,11 @@ int send_nip17_response(const char* sender_pubkey, const char* response_content,
|
|||||||
char* generate_config_text(void);
|
char* generate_config_text(void);
|
||||||
char* generate_stats_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
|
#endif // DM_ADMIN_H
|
||||||
File diff suppressed because one or more lines are too long
@@ -123,7 +123,7 @@ void free_subscription_filter(subscription_filter_t* filter) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate subscription ID format and length
|
// 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) {
|
if (!sub_id) {
|
||||||
return 0; // NULL pointer
|
return 0; // NULL pointer
|
||||||
}
|
}
|
||||||
@@ -133,11 +133,11 @@ static int validate_subscription_id(const char* sub_id) {
|
|||||||
return 0; // Empty or too long
|
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++) {
|
for (size_t i = 0; i < len; i++) {
|
||||||
char c = sub_id[i];
|
char c = sub_id[i];
|
||||||
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
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
|
return 0; // Invalid character
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ struct subscription_manager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Function declarations
|
// Function declarations
|
||||||
|
int validate_subscription_id(const char* sub_id);
|
||||||
subscription_filter_t* create_subscription_filter(cJSON* filter_json);
|
subscription_filter_t* create_subscription_filter(cJSON* filter_json);
|
||||||
void free_subscription_filter(subscription_filter_t* filter);
|
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);
|
subscription_t* create_subscription(const char* sub_id, struct lws* wsi, cJSON* filters_array, const char* client_ip);
|
||||||
|
|||||||
@@ -707,38 +707,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check subscription ID format and length
|
// Validate subscription ID
|
||||||
size_t id_len = strlen(subscription_id);
|
if (!validate_subscription_id(subscription_id)) {
|
||||||
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
|
send_notice_message(wsi, "error: invalid subscription ID");
|
||||||
send_notice_message(wsi, "error: subscription ID too long or empty");
|
DEBUG_WARN("REQ rejected: invalid subscription ID");
|
||||||
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");
|
|
||||||
cJSON_Delete(json);
|
cJSON_Delete(json);
|
||||||
free(message);
|
free(message);
|
||||||
return 0;
|
return 0;
|
||||||
@@ -866,30 +838,10 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check subscription ID format and length
|
// Validate subscription ID
|
||||||
size_t id_len = strlen(subscription_id);
|
if (!validate_subscription_id(subscription_id)) {
|
||||||
if (id_len == 0 || id_len >= SUBSCRIPTION_ID_MAX_LENGTH) {
|
send_notice_message(wsi, "error: invalid subscription ID in CLOSE");
|
||||||
send_notice_message(wsi, "error: subscription ID too long or empty in CLOSE");
|
DEBUG_WARN("CLOSE rejected: invalid subscription ID");
|
||||||
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");
|
|
||||||
cJSON_Delete(json);
|
cJSON_Delete(json);
|
||||||
free(message);
|
free(message);
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
448
tests/sql_test.sh
Executable file
448
tests/sql_test.sh
Executable 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
|
||||||
Reference in New Issue
Block a user