4 Commits

33 changed files with 119834 additions and 1659 deletions

2
.gitignore vendored
View File

@@ -3,4 +3,4 @@ logs/
nostr_core_lib/
blobs/
c-relay/
text_graph/

View File

@@ -8,15 +8,24 @@ BUILDDIR = build
TARGET = $(BUILDDIR)/ginxsom-fcgi
# Source files
SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/request_validator.c $(SRCDIR)/relay_client.c
SOURCES = $(SRCDIR)/main.c $(SRCDIR)/admin_api.c $(SRCDIR)/admin_auth.c $(SRCDIR)/admin_event.c $(SRCDIR)/admin_handlers.c $(SRCDIR)/admin_interface.c $(SRCDIR)/bud04.c $(SRCDIR)/bud06.c $(SRCDIR)/bud08.c $(SRCDIR)/bud09.c $(SRCDIR)/request_validator.c $(SRCDIR)/relay_client.c $(SRCDIR)/admin_commands.c
OBJECTS = $(SOURCES:$(SRCDIR)/%.c=$(BUILDDIR)/%.o)
# Embedded web interface files
EMBEDDED_HEADER = $(SRCDIR)/admin_interface_embedded.h
EMBED_SCRIPT = scripts/embed_web_files.sh
# Add core_relay_pool.c from nostr_core_lib
POOL_SRC = nostr_core_lib/nostr_core/core_relay_pool.c
POOL_OBJ = $(BUILDDIR)/core_relay_pool.o
# Default target
all: $(TARGET)
all: $(EMBEDDED_HEADER) $(TARGET)
# Generate embedded web interface files
$(EMBEDDED_HEADER): $(EMBED_SCRIPT) api/*.html api/*.css api/*.js
@echo "Embedding web interface files..."
@$(EMBED_SCRIPT)
# Create build directory
$(BUILDDIR):
@@ -37,6 +46,7 @@ $(TARGET): $(OBJECTS) $(POOL_OBJ)
# Clean build files
clean:
rm -rf $(BUILDDIR)
rm -f $(EMBEDDED_HEADER)
# Install (copy to system location)
install: $(TARGET)
@@ -55,4 +65,8 @@ run: $(TARGET)
debug: CFLAGS += -g -DDEBUG
debug: $(TARGET)
.PHONY: all clean install uninstall run debug
# Rebuild embedded files
embed:
@$(EMBED_SCRIPT)
.PHONY: all clean install uninstall run debug embed

126
README.md
View File

@@ -369,6 +369,132 @@ Error responses include specific error codes:
- `no_blob_hashes`: Missing valid SHA-256 hashes
- `unsupported_media_type`: Non-JSON Content-Type
## Administrator API
Ginxsom uses an **event-based administration system** where all configuration and management commands are sent as signed Nostr events using the admin private key. All admin commands use **NIP-44 encrypted command arrays** for security.
### Authentication
All admin commands require signing with the admin private key configured in the server. The admin public key is stored in the database and checked against incoming Kind 23458 events.
### Event Structure
**Admin Command Event (Kind 23458):**
```json
{
"id": "event_id",
"pubkey": "admin_public_key",
"created_at": 1234587890,
"kind": 23458,
"content": "NIP44_ENCRYPTED_COMMAND_ARRAY",
"tags": [
["p", "blossom_server_pubkey"]
],
"sig": "event_signature"
}
```
The `content` field contains a NIP-44 encrypted JSON array representing the command.
**Admin Response Event (Kind 23459):**
```json
{
"id": "response_event_id",
"pubkey": "blossom_server_pubkey",
"created_at": 1234587890,
"kind": 23459,
"content": "NIP44_ENCRYPTED_RESPONSE_OBJECT",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}
```
The `content` field contains a NIP-44 encrypted JSON response object.
### Admin Commands
All commands are sent as NIP-44 encrypted JSON arrays in the event content:
| Command Type | Command Format | Description |
|--------------|----------------|-------------|
| **Configuration Management** |
| `config_query` | `["config_query", "all"]` | Query all configuration parameters |
| `config_update` | `["config_update", [{"key": "max_file_size", "value": "209715200", ...}]]` | Update configuration parameters |
| **Statistics & Monitoring** |
| `stats_query` | `["stats_query"]` | Get comprehensive database and storage statistics |
| `system_status` | `["system_command", "system_status"]` | Get system status and health metrics |
| **Blossom Operations** |
| `blob_list` | `["blob_list", "all"]` or `["blob_list", "pubkey", "abc123..."]` | List blobs with filtering |
| `storage_stats` | `["storage_stats"]` | Get detailed storage statistics |
| `mirror_status` | `["mirror_status"]` | Get status of mirroring operations |
| `report_query` | `["report_query", "all"]` | Query content reports (BUD-09) |
| **Database Queries** |
| `sql_query` | `["sql_query", "SELECT * FROM blobs LIMIT 10"]` | Execute read-only SQL query |
### Configuration Categories
**Blossom Settings:**
- `max_file_size`: Maximum upload size in bytes
- `storage_path`: Blob storage directory path
- `cdn_origin`: CDN URL for blob descriptors
- `enable_nip94`: Include NIP-94 tags in responses
**Relay Client Settings:**
- `enable_relay_connect`: Enable relay client functionality
- `kind_0_content`: Profile metadata JSON
- `kind_10002_tags`: Relay list JSON array
**Authentication Settings:**
- `auth_enabled`: Enable auth rules system
- `require_auth_upload`: Require authentication for uploads
- `require_auth_delete`: Require authentication for deletes
**Limits:**
- `max_blobs_per_user`: Per-user blob limit
- `rate_limit_uploads`: Uploads per minute
- `max_total_storage`: Total storage limit in bytes
### Response Format
All admin commands return signed EVENT responses via the relay connection. Responses use NIP-44 encrypted JSON content with structured data.
**Success Response Example:**
```json
{
"query_type": "stats_query",
"timestamp": 1234587890,
"database_size_bytes": 1048576,
"storage_size_bytes": 10737418240,
"total_blobs": 1543,
"blob_types": [
{"type": "image/jpeg", "count": 856, "size_bytes": 5368709120}
]
}
```
**Error Response Example:**
```json
{
"query_type": "config_update",
"status": "error",
"error": "invalid configuration value",
"timestamp": 1234587890
}
```
### Security Features
- **Cryptographic Authentication**: Only admin pubkey can send commands
- **NIP-44 Encryption**: All commands and responses are encrypted
- **Command Logging**: All admin actions logged to database
- **SQL Safety**: Only SELECT statements allowed with timeout and row limits
- **Rate Limiting**: Prevents admin command flooding
For detailed command specifications and examples, see [`docs/ADMIN_COMMANDS_PLAN.md`](docs/ADMIN_COMMANDS_PLAN.md).
## File Storage
### Current (Flat) Structure

58
api/embedded.html Normal file
View File

@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Embedded NOSTR_LOGIN_LITE</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 40px;
background: white;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.container {
max-width: 400px;
width: 100%;
}
#login-container {
/* No styling - let embedded modal blend seamlessly */
}
</style>
</head>
<body>
<div class="container">
<div id="login-container"></div>
</div>
<script src="../lite/nostr.bundle.js"></script>
<script src="../lite/nostr-lite.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
await window.NOSTR_LOGIN_LITE.init({
theme:'default',
methods: {
extension: true,
local: true,
seedphrase: true,
readonly: true,
connect: true,
remote: true,
otp: true
}
});
window.NOSTR_LOGIN_LITE.embed('#login-container', {
seamless: true
});
});
</script>
</body>
</html>

1310
api/index.css Normal file

File diff suppressed because it is too large Load Diff

425
api/index.html Normal file
View File

@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blossom Admin</title>
<link rel="stylesheet" href="/api/index.css">
</head>
<body>
<!-- Side Navigation Menu -->
<nav class="side-nav" id="side-nav">
<ul class="nav-menu">
<li><button class="nav-item" data-page="statistics">Statistics</button></li>
<li><button class="nav-item" data-page="configuration">Configuration</button></li>
<li><button class="nav-item" data-page="authorization">Authorization</button></li>
<li><button class="nav-item" data-page="relay-events">Blossom Events</button></li>
<li><button class="nav-item" data-page="database">Database Query</button></li>
</ul>
<div class="nav-footer">
<button class="nav-footer-btn" id="nav-dark-mode-btn">DARK MODE</button>
<button class="nav-footer-btn" id="nav-logout-btn">LOGOUT</button>
</div>
</nav>
<!-- Side Navigation Overlay -->
<div class="side-nav-overlay" id="side-nav-overlay"></div>
<!-- Header with title and profile display -->
<div class="section">
<div class="header-content">
<div class="header-title clickable" id="header-title">
<span class="relay-letter" data-letter="B">B</span>
<span class="relay-letter" data-letter="L">L</span>
<span class="relay-letter" data-letter="O">O</span>
<span class="relay-letter" data-letter="S">S</span>
<span class="relay-letter" data-letter="S">S</span>
<span class="relay-letter" data-letter="O">O</span>
<span class="relay-letter" data-letter="M">M</span>
</div>
<div class="relay-info">
<div id="relay-name" class="relay-name">Blossom</div>
<div id="relay-description" class="relay-description">Loading...</div>
<div id="relay-pubkey-container" class="relay-pubkey-container">
<div id="relay-pubkey" class="relay-pubkey">Loading...</div>
</div>
</div>
<div class="profile-area" id="profile-area" style="display: none;">
<div class="admin-label">admin</div>
<div class="profile-container">
<img id="header-user-image" class="header-user-image" alt="Profile" style="display: none;">
<span id="header-user-name" class="header-user-name">Loading...</span>
</div>
<!-- Logout dropdown -->
<!-- Dropdown menu removed - buttons moved to sidebar -->
</div>
</div>
</div>
<!-- Login Modal Overlay -->
<div id="login-modal" class="login-modal-overlay" style="display: none;">
<div class="login-modal-content">
<div id="login-modal-container"></div>
</div>
</div>
<!-- DATABASE STATISTICS Section -->
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
<div class="section-header">
DATABASE STATISTICS
</div>
<!-- Blob Rate Graph Container -->
<div id="event-rate-chart"></div>
<!-- Database Overview Table -->
<div class="input-group">
<div class="config-table-container">
<table class="config-table" id="stats-overview-table">
<thead>
<tr>
<th>Metric</th>
<th>Value</th>
</tr>
</thead>
<tbody id="stats-overview-table-body">
<tr>
<td>Database Size</td>
<td id="db-size">-</td>
</tr>
<tr>
<td>Total Blobs</td>
<td id="total-events">-</td>
</tr>
<tr>
<td>Total Size</td>
<td id="total-size">-</td>
</tr>
<tr>
<td>Process ID</td>
<td id="process-id">-</td>
</tr>
<tr>
<td>Memory Usage</td>
<td id="memory-usage">-</td>
</tr>
<tr>
<td>CPU Core</td>
<td id="cpu-core">-</td>
</tr>
<tr>
<td>CPU Usage</td>
<td id="cpu-usage">-</td>
</tr>
<tr>
<td>Oldest Blob</td>
<td id="oldest-event">-</td>
</tr>
<tr>
<td>Newest Blob</td>
<td id="newest-event">-</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Blob Type Distribution Table -->
<div class="input-group">
<label>Blob Type Distribution:</label>
<div class="config-table-container">
<table class="config-table" id="stats-kinds-table">
<thead>
<tr>
<th>Blob Type</th>
<th>Count</th>
<th>Percentage</th>
</tr>
</thead>
<tbody id="stats-kinds-table-body">
<tr>
<td colspan="3" style="text-align: center; font-style: italic;">No data loaded</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Time-based Statistics Table -->
<div class="input-group">
<label>Time-based Statistics:</label>
<div class="config-table-container">
<table class="config-table" id="stats-time-table">
<thead>
<tr>
<th>Period</th>
<th>Blobs</th>
</tr>
</thead>
<tbody id="stats-time-table-body">
<tr>
<td>Last 24 Hours</td>
<td id="events-24h">-</td>
</tr>
<tr>
<td>Last 7 Days</td>
<td id="events-7d">-</td>
</tr>
<tr>
<td>Last 30 Days</td>
<td id="events-30d">-</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Top Pubkeys Table -->
<div class="input-group">
<label>Top Pubkeys by Event Count:</label>
<div class="config-table-container">
<table class="config-table" id="stats-pubkeys-table">
<thead>
<tr>
<th>Rank</th>
<th>Pubkey</th>
<th>Blob Count</th>
<th>Total Size</th>
<th>Percentage</th>
</tr>
</thead>
<tbody id="stats-pubkeys-table-body">
<tr>
<td colspan="4" style="text-align: center; font-style: italic;">No data loaded</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Testing Section -->
<div id="div_config" class="section flex-section" style="display: none;">
<div class="section-header">
BLOSSOM CONFIGURATION
</div>
<div id="config-display" class="hidden">
<div class="config-table-container">
<table class="config-table" id="config-table">
<thead>
<tr>
<th>Parameter</th>
<th>Value</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="config-table-body">
</tbody>
</table>
</div>
<div class="inline-buttons">
<button type="button" id="fetch-config-btn">REFRESH</button>
</div>
</div>
</div>
<!-- Auth Rules Management - Moved after configuration -->
<div class="section flex-section" id="authRulesSection" style="display: none;">
<div class="section-header">
AUTH RULES MANAGEMENT
</div>
<!-- Auth Rules Table -->
<div id="authRulesTableContainer" style="display: none;">
<table class="config-table" id="authRulesTable">
<thead>
<tr>
<th>Rule Type</th>
<th>Pattern Type</th>
<th>Pattern Value</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="authRulesTableBody">
</tbody>
</table>
</div>
<!-- Simplified Auth Rule Input Section -->
<div id="authRuleInputSections" style="display: block;">
<!-- Combined Pubkey Auth Rule Section -->
<div class="input-group">
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
</div>
<div id="whitelistWarning" class="warning-box" style="display: none;">
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
mode.
Only whitelisted users will be able to interact with the relay.
</div>
<div class="inline-buttons">
<button type="button" id="addWhitelistBtn" onclick="addWhitelistRule()">ADD TO
WHITELIST</button>
<button type="button" id="addBlacklistBtn" onclick="addBlacklistRule()">ADD TO
BLACKLIST</button>
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
</div>
</div>
</div>
<!-- BLOSSOM EVENTS Section -->
<div class="section" id="relayEventsSection" style="display: none;">
<div class="section-header">
BLOSSOM EVENTS MANAGEMENT
</div>
<!-- Kind 0: User Metadata -->
<div class="input-group">
<h3>Kind 0: User Metadata</h3>
<div class="form-group">
<label for="kind0-name">Name:</label>
<input type="text" id="kind0-name" placeholder="Blossom Server Name">
</div>
<div class="form-group">
<label for="kind0-about">About:</label>
<textarea id="kind0-about" rows="3" placeholder="Blossom Server Description"></textarea>
</div>
<div class="form-group">
<label for="kind0-picture">Picture URL:</label>
<input type="url" id="kind0-picture" placeholder="https://example.com/logo.png">
</div>
<div class="form-group">
<label for="kind0-banner">Banner URL:</label>
<input type="url" id="kind0-banner" placeholder="https://example.com/banner.png">
</div>
<div class="form-group">
<label for="kind0-nip05">NIP-05:</label>
<input type="text" id="kind0-nip05" placeholder="blossom@example.com">
</div>
<div class="form-group">
<label for="kind0-website">Website:</label>
<input type="url" id="kind0-website" placeholder="https://example.com">
</div>
<div class="inline-buttons">
<button type="button" id="submit-kind0-btn">UPDATE METADATA</button>
</div>
<div id="kind0-status" class="status-message"></div>
</div>
<!-- Kind 10050: DM Blossom List -->
<div class="input-group">
<h3>Kind 10050: DM Blossom List</h3>
<div class="form-group">
<label for="kind10050-relays">Blossom URLs (one per line):</label>
<textarea id="kind10050-relays" rows="4" placeholder="https://blossom1.com&#10;https://blossom2.com"></textarea>
</div>
<div class="inline-buttons">
<button type="button" id="submit-kind10050-btn">UPDATE DM BLOSSOM SERVERS</button>
</div>
<div id="kind10050-status" class="status-message"></div>
</div>
<!-- Kind 10002: Blossom List -->
<div class="input-group">
<h3>Kind 10002: Blossom Server List</h3>
<div id="kind10002-relay-entries">
<!-- Dynamic blossom server entries will be added here -->
</div>
<div class="inline-buttons">
<button type="button" id="add-relay-entry-btn">ADD SERVER</button>
<button type="button" id="submit-kind10002-btn">UPDATE SERVERS</button>
</div>
<div id="kind10002-status" class="status-message"></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">
<div class="inline-buttons">
<button type="button" id="execute-sql-btn">EXECUTE QUERY</button>
<button type="button" id="clear-sql-btn">CLEAR</button>
<button type="button" id="clear-history-btn">CLEAR HISTORY</button>
</div>
</div>
<!-- Query Results -->
<div class="input-group">
<label>Query Results:</label>
<div id="query-info" class="info-box"></div>
<div id="query-table" class="config-table-container"></div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr.bundle.js"></script> -->
<script src="/api/nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<!-- <script src="https://laantungir.net/nostr-login-lite/nostr-lite.js"></script> -->
<script src="/api/nostr-lite.js"></script>
<!-- Load text_graph library -->
<script src="/api/text_graph.js"></script>
<script src="/api/index.js"></script>
</body>
</html>

5814
api/index.js Normal file

File diff suppressed because it is too large Load Diff

4282
api/nostr-lite.js Normal file

File diff suppressed because it is too large Load Diff

11534
api/nostr.bundle.js Normal file

File diff suppressed because it is too large Load Diff

463
api/text_graph.js Normal file
View File

@@ -0,0 +1,463 @@
/**
* ASCIIBarChart - A dynamic ASCII-based vertical bar chart renderer
*
* Creates real-time animated bar charts using monospaced characters (X)
* with automatic scaling, labels, and responsive font sizing.
*/
class ASCIIBarChart {
/**
* Create a new ASCII bar chart
* @param {string} containerId - The ID of the HTML element to render the chart in
* @param {Object} options - Configuration options
* @param {number} [options.maxHeight=20] - Maximum height of the chart in rows
* @param {number} [options.maxDataPoints=30] - Maximum number of data columns before scrolling
* @param {string} [options.title=''] - Chart title (displayed centered at top)
* @param {string} [options.xAxisLabel=''] - X-axis label (displayed centered at bottom)
* @param {string} [options.yAxisLabel=''] - Y-axis label (displayed vertically on left)
* @param {boolean} [options.autoFitWidth=true] - Automatically adjust font size to fit container width
* @param {boolean} [options.useBinMode=false] - Enable time bin mode for data aggregation
* @param {number} [options.binDuration=10000] - Duration of each time bin in milliseconds (10 seconds default)
* @param {string} [options.xAxisLabelFormat='elapsed'] - X-axis label format: 'elapsed', 'bins', 'timestamps', 'ranges'
* @param {boolean} [options.debug=false] - Enable debug logging
*/
constructor(containerId, options = {}) {
this.container = document.getElementById(containerId);
this.data = [];
this.maxHeight = options.maxHeight || 20;
this.maxDataPoints = options.maxDataPoints || 30;
this.totalDataPoints = 0; // Track total number of data points added
this.title = options.title || '';
this.xAxisLabel = options.xAxisLabel || '';
this.yAxisLabel = options.yAxisLabel || '';
this.autoFitWidth = options.autoFitWidth !== false; // Default to true
this.debug = options.debug || false; // Debug logging option
// Time bin configuration
this.useBinMode = options.useBinMode !== false; // Default to true
this.binDuration = options.binDuration || 4000; // 4 seconds default
this.xAxisLabelFormat = options.xAxisLabelFormat || 'elapsed';
// Time bin data structures
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.binCheckInterval = null;
this.chartStartTime = Date.now();
// Set up resize observer if auto-fit is enabled
if (this.autoFitWidth) {
this.resizeObserver = new ResizeObserver(() => {
this.adjustFontSize();
});
this.resizeObserver.observe(this.container);
}
// Initialize first bin if bin mode is enabled
if (this.useBinMode) {
this.initializeBins();
}
}
/**
* Add a new data point to the chart
* @param {number} value - The numeric value to add
*/
addValue(value) {
// Time bin mode: add value to current active bin count
this.checkBinRotation(); // Ensure we have an active bin
this.bins[this.currentBinIndex].count += value; // Changed from ++ to += value
this.totalDataPoints++;
this.render();
this.updateInfo();
}
/**
* Clear all data from the chart
*/
clear() {
this.data = [];
this.totalDataPoints = 0;
if (this.useBinMode) {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.initializeBins();
}
this.render();
this.updateInfo();
}
/**
* Calculate the width of the chart in characters
* @returns {number} The chart width in characters
* @private
*/
getChartWidth() {
let dataLength = this.maxDataPoints; // Always use maxDataPoints for consistent width
if (dataLength === 0) return 50; // Default width for empty chart
const yAxisPadding = this.yAxisLabel ? 2 : 0;
const yAxisNumbers = 3; // Width of Y-axis numbers
const separator = 1; // The '|' character
// const dataWidth = dataLength * 2; // Each column is 2 characters wide // TEMP: commented for no-space test
const dataWidth = dataLength; // Each column is 1 character wide // TEMP: adjusted for no-space columns
const padding = 1; // Extra padding
const totalWidth = yAxisPadding + yAxisNumbers + separator + dataWidth + padding;
// Only log when width changes
if (this.debug && this.lastChartWidth !== totalWidth) {
console.log('getChartWidth changed:', { dataLength, totalWidth, previous: this.lastChartWidth });
this.lastChartWidth = totalWidth;
}
return totalWidth;
}
/**
* Adjust font size to fit container width
* @private
*/
adjustFontSize() {
if (!this.autoFitWidth) return;
const containerWidth = this.container.clientWidth;
const chartWidth = this.getChartWidth();
if (chartWidth === 0) return;
// Calculate optimal font size
// For monospace fonts, character width is approximately 0.6 * font size
// Use a slightly smaller ratio to fit more content
const charWidthRatio = 0.7;
const padding = 30; // Reduce padding to fit more content
const availableWidth = containerWidth - padding;
const optimalFontSize = Math.floor((availableWidth / chartWidth) / charWidthRatio);
// Set reasonable bounds (min 4px, max 20px)
const fontSize = Math.max(4, Math.min(20, optimalFontSize));
// Only log when font size changes
if (this.debug && this.lastFontSize !== fontSize) {
console.log('fontSize changed:', { containerWidth, chartWidth, fontSize, previous: this.lastFontSize });
this.lastFontSize = fontSize;
}
this.container.style.fontSize = fontSize + 'px';
this.container.style.lineHeight = '1.0';
}
/**
* Render the chart to the container
* @private
*/
render() {
let dataToRender = [];
let maxValue = 0;
let minValue = 0;
let valueRange = 0;
if (this.useBinMode) {
// Bin mode: render bin counts
if (this.bins.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
// Always create a fixed-length array filled with 0s, then overlay actual bin data
dataToRender = new Array(this.maxDataPoints).fill(0);
// Overlay actual bin data (most recent bins, reversed for left-to-right display)
const startIndex = Math.max(0, this.bins.length - this.maxDataPoints);
const recentBins = this.bins.slice(startIndex);
// Reverse the bins so most recent is on the left, and overlay onto the fixed array
recentBins.reverse().forEach((bin, index) => {
if (index < this.maxDataPoints) {
dataToRender[index] = bin.count;
}
});
if (this.debug) {
console.log('render() dataToRender:', dataToRender, 'bins length:', this.bins.length);
}
maxValue = Math.max(...dataToRender);
minValue = Math.min(...dataToRender);
valueRange = maxValue - minValue;
} else {
// Legacy mode: render individual values
if (this.data.length === 0) {
this.container.textContent = 'No data yet. Click Start to begin.';
return;
}
dataToRender = this.data;
maxValue = Math.max(...this.data);
minValue = Math.min(...this.data);
valueRange = maxValue - minValue;
}
let output = '';
const scale = this.maxHeight;
// Calculate scaling factor: each X represents at least 1 count
const maxCount = Math.max(...dataToRender);
const scaleFactor = Math.max(1, Math.ceil(maxCount / scale)); // 1 X = scaleFactor counts
const scaledMax = Math.ceil(maxCount / scaleFactor) * scaleFactor;
// Calculate Y-axis label width (for vertical text)
const yLabelWidth = this.yAxisLabel ? 2 : 0;
const yAxisPadding = this.yAxisLabel ? ' ' : '';
// Add title if provided (centered)
if (this.title) {
// const chartWidth = 4 + this.maxDataPoints * 2; // Y-axis numbers + data columns // TEMP: commented for no-space test
const chartWidth = 4 + this.maxDataPoints; // Y-axis numbers + data columns // TEMP: adjusted for no-space columns
const titlePadding = Math.floor((chartWidth - this.title.length) / 2);
output += yAxisPadding + ' '.repeat(Math.max(0, titlePadding)) + this.title + '\n\n';
}
// Draw from top to bottom
for (let row = scale; row > 0; row--) {
let line = '';
// Add vertical Y-axis label character
if (this.yAxisLabel) {
const L = this.yAxisLabel.length;
const startRow = Math.floor((scale - L) / 2) + 1;
const relativeRow = scale - row + 1; // 1 at top, scale at bottom
if (relativeRow >= startRow && relativeRow < startRow + L) {
const labelIndex = relativeRow - startRow;
line += this.yAxisLabel[labelIndex] + ' ';
} else {
line += ' ';
}
}
// Calculate the actual count value this row represents (1 at bottom, increasing upward)
const rowCount = (row - 1) * scaleFactor + 1;
// Add Y-axis label (show actual count values)
line += String(rowCount).padStart(3, ' ') + ' |';
// Draw each column
for (let i = 0; i < dataToRender.length; i++) {
const count = dataToRender[i];
const scaledHeight = Math.ceil(count / scaleFactor);
if (scaledHeight >= row) {
// line += ' X'; // TEMP: commented out space between columns
line += 'X'; // TEMP: no space between columns
} else {
// line += ' '; // TEMP: commented out space between columns
line += ' '; // TEMP: single space for empty columns
}
}
output += line + '\n';
}
// Draw X-axis
// output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints * 2) + '\n'; // TEMP: commented out for no-space test
output += yAxisPadding + ' +' + '-'.repeat(this.maxDataPoints) + '\n'; // TEMP: back to original length
// Draw X-axis labels based on mode and format
let xAxisLabels = yAxisPadding + ' '; // Initial padding to align with X-axis
// Determine label interval (every 5 columns)
const labelInterval = 5;
// Generate all labels first and store in array
let labels = [];
for (let i = 0; i < this.maxDataPoints; i++) {
if (i % labelInterval === 0) {
let label = '';
if (this.useBinMode) {
// For bin mode, show labels for all possible positions
// i=0 is leftmost (most recent), i=maxDataPoints-1 is rightmost (oldest)
const elapsedSec = (i * this.binDuration) / 1000;
// Format with appropriate precision for sub-second bins
if (this.binDuration < 1000) {
// Show decimal seconds for sub-second bins
label = elapsedSec.toFixed(1) + 's';
} else {
// Show whole seconds for 1+ second bins
label = String(Math.round(elapsedSec)) + 's';
}
} else {
// For legacy mode, show data point numbers
const startIndex = Math.max(1, this.totalDataPoints - this.maxDataPoints + 1);
label = String(startIndex + i);
}
labels.push(label);
}
}
// Build the label string with calculated spacing
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
xAxisLabels += label;
// Add spacing: labelInterval - label.length (except for last label)
if (i < labels.length - 1) {
const spacing = labelInterval - label.length;
xAxisLabels += ' '.repeat(spacing);
}
}
// Ensure the label line extends to match the X-axis dash line length
// The dash line is this.maxDataPoints characters long, starting after " +"
const dashLineLength = this.maxDataPoints;
const minLabelLineLength = yAxisPadding.length + 4 + dashLineLength; // 4 for " "
if (xAxisLabels.length < minLabelLineLength) {
xAxisLabels += ' '.repeat(minLabelLineLength - xAxisLabels.length);
}
output += xAxisLabels + '\n';
// Add X-axis label if provided
if (this.xAxisLabel) {
// const labelPadding = Math.floor((this.maxDataPoints * 2 - this.xAxisLabel.length) / 2); // TEMP: commented for no-space test
const labelPadding = Math.floor((this.maxDataPoints - this.xAxisLabel.length) / 2); // TEMP: adjusted for no-space columns
output += '\n' + yAxisPadding + ' ' + ' '.repeat(Math.max(0, labelPadding)) + this.xAxisLabel + '\n';
}
this.container.textContent = output;
// Adjust font size to fit width (only once at initialization)
if (this.autoFitWidth) {
this.adjustFontSize();
}
// Update the external info display
if (this.useBinMode) {
const binCounts = this.bins.map(bin => bin.count);
const scaleFactor = Math.max(1, Math.ceil(maxValue / scale));
document.getElementById('values').textContent = `[${dataToRender.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, 1X=${scaleFactor} counts`;
} else {
document.getElementById('values').textContent = `[${this.data.join(', ')}]`;
document.getElementById('max-value').textContent = maxValue;
document.getElementById('scale').textContent = `Min: ${minValue}, Max: ${maxValue}, Height: ${scale}`;
}
}
/**
* Update the info display
* @private
*/
updateInfo() {
if (this.useBinMode) {
const totalCount = this.bins.reduce((sum, bin) => sum + bin.count, 0);
document.getElementById('count').textContent = totalCount;
} else {
document.getElementById('count').textContent = this.data.length;
}
}
/**
* Initialize the bin system
* @private
*/
initializeBins() {
this.bins = [];
this.currentBinIndex = -1;
this.binStartTime = null;
this.chartStartTime = Date.now();
// Create first bin
this.rotateBin();
// Set up automatic bin rotation check
this.binCheckInterval = setInterval(() => {
this.checkBinRotation();
}, 100); // Check every 100ms for responsiveness
}
/**
* Check if current bin should rotate and create new bin if needed
* @private
*/
checkBinRotation() {
if (!this.useBinMode || !this.binStartTime) return;
const now = Date.now();
if ((now - this.binStartTime) >= this.binDuration) {
this.rotateBin();
}
}
/**
* Rotate to a new bin, finalizing the current one
*/
rotateBin() {
// Finalize current bin if it exists
if (this.currentBinIndex >= 0) {
this.bins[this.currentBinIndex].isActive = false;
}
// Create new bin
const newBin = {
startTime: Date.now(),
count: 0,
isActive: true
};
this.bins.push(newBin);
this.currentBinIndex = this.bins.length - 1;
this.binStartTime = newBin.startTime;
// Keep only the most recent bins
if (this.bins.length > this.maxDataPoints) {
this.bins.shift();
this.currentBinIndex--;
}
// Ensure currentBinIndex points to the last bin (the active one)
this.currentBinIndex = this.bins.length - 1;
// Force a render to update the display immediately
this.render();
this.updateInfo();
}
/**
* Format X-axis label for a bin based on the configured format
* @param {number} binIndex - Index of the bin
* @returns {string} Formatted label
* @private
*/
formatBinLabel(binIndex) {
const bin = this.bins[binIndex];
if (!bin) return ' ';
switch (this.xAxisLabelFormat) {
case 'bins':
return String(binIndex + 1).padStart(2, ' ');
case 'timestamps':
const time = new Date(bin.startTime);
return time.toLocaleTimeString('en-US', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).replace(/:/g, '');
case 'ranges':
const startSec = Math.floor((bin.startTime - this.chartStartTime) / 1000);
const endSec = startSec + Math.floor(this.binDuration / 1000);
return `${startSec}-${endSec}`;
case 'elapsed':
default:
// For elapsed time, always show time relative to the first bin (index 0)
// This keeps the leftmost label as 0s and increases to the right
const firstBinTime = this.bins[0] ? this.bins[0].startTime : this.chartStartTime;
const elapsedSec = Math.floor((bin.startTime - firstBinTime) / 1000);
return String(elapsedSec).padStart(2, ' ') + 's';
}
}
}

BIN
build/admin_commands.o Normal file

Binary file not shown.

Binary file not shown.

BIN
build/admin_interface.o Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -220,6 +220,35 @@ http {
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
# Admin web interface (/admin)
location /admin {
if ($request_method !~ ^(GET)$) {
return 405;
}
fastcgi_pass fastcgi_backend;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param REDIRECT_STATUS 200;
fastcgi_param SCRIPT_FILENAME $document_root/ginxsom.fcgi;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
# Admin API endpoints (/api/*)
location /api/ {
if ($request_method !~ ^(GET|PUT|POST)$) {
@@ -571,6 +600,35 @@ http {
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
# Admin web interface (/admin)
location /admin {
if ($request_method !~ ^(GET)$) {
return 405;
}
fastcgi_pass fastcgi_backend;
fastcgi_param QUERY_STRING $query_string;
fastcgi_param REQUEST_METHOD $request_method;
fastcgi_param CONTENT_TYPE $content_type;
fastcgi_param CONTENT_LENGTH $content_length;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param DOCUMENT_URI $document_uri;
fastcgi_param DOCUMENT_ROOT $document_root;
fastcgi_param SERVER_PROTOCOL $server_protocol;
fastcgi_param REQUEST_SCHEME $scheme;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param GATEWAY_INTERFACE CGI/1.1;
fastcgi_param SERVER_SOFTWARE nginx/$nginx_version;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param REMOTE_PORT $remote_port;
fastcgi_param SERVER_ADDR $server_addr;
fastcgi_param SERVER_PORT $server_port;
fastcgi_param SERVER_NAME $server_name;
fastcgi_param REDIRECT_STATUS 200;
fastcgi_param SCRIPT_FILENAME $document_root/ginxsom.fcgi;
fastcgi_param HTTP_AUTHORIZATION $http_authorization;
}
# Admin API endpoints (/api/*)
location /api/ {
if ($request_method !~ ^(GET|PUT|POST)$) {

29471
debug.log

File diff suppressed because it is too large Load Diff

535
docs/ADMIN_COMMANDS_PLAN.md Normal file
View File

@@ -0,0 +1,535 @@
# Ginxsom Admin Commands Implementation Plan
## Overview
This document outlines the implementation plan for Ginxsom's admin command system, adapted from c-relay's event-based administration system. Commands are sent as NIP-44 encrypted Kind 23456 events and responses are returned as Kind 23457 events.
## Command Analysis: c-relay vs Ginxsom
### Commands to Implement (Blossom-Relevant)
| c-relay Command | Ginxsom Equivalent | Rationale |
|-----------------|-------------------|-----------|
| `config_query` | `config_query` | Query Blossom server configuration |
| `config_update` | `config_update` | Update server settings dynamically |
| `stats_query` | `stats_query` | Database statistics (blobs, storage, etc.) |
| `system_status` | `system_status` | Server health and status |
| `sql_query` | `sql_query` | Direct database queries for debugging |
| N/A | `blob_list` | List blobs by pubkey or criteria |
| N/A | `storage_stats` | Storage usage and capacity info |
| N/A | `mirror_status` | Status of mirroring operations |
| N/A | `report_query` | Query content reports (BUD-09) |
### Commands to Exclude (Not Blossom-Relevant)
| c-relay Command | Reason for Exclusion |
|-----------------|---------------------|
| `auth_add_blacklist` | Blossom uses different auth model (per-blob, not per-pubkey) |
| `auth_add_whitelist` | Same as above |
| `auth_delete_rule` | Same as above |
| `auth_query_all` | Same as above |
| `system_clear_auth` | Same as above |
**Note**: Blossom's authentication is event-based per operation (upload/delete), not relay-level whitelist/blacklist. Auth rules in Ginxsom are configured via the `auth_rules` table but managed differently than c-relay.
## Event Structure
### Admin Command Event (Kind 23456)
```json
{
"id": "event_id",
"pubkey": "admin_public_key",
"created_at": 1234567890,
"kind": 23456,
"content": "NIP44_ENCRYPTED_COMMAND_ARRAY",
"tags": [
["p", "blossom_server_pubkey"]
],
"sig": "event_signature"
}
```
### Admin Response Event (Kind 23457)
```json
{
"id": "response_event_id",
"pubkey": "blossom_server_pubkey",
"created_at": 1234567890,
"kind": 23457,
"content": "NIP44_ENCRYPTED_RESPONSE_OBJECT",
"tags": [
["p", "admin_public_key"],
["e", "request_event_id"]
],
"sig": "response_event_signature"
}
```
## Command Specifications
### 1. Configuration Management
#### `config_query`
Query server configuration parameters.
**Command Format:**
```json
["config_query", "all"]
["config_query", "category", "blossom"]
["config_query", "key", "max_file_size"]
```
**Response:**
```json
{
"query_type": "config_all",
"total_results": 15,
"timestamp": 1234567890,
"data": [
{
"key": "max_file_size",
"value": "104857600",
"data_type": "integer",
"category": "blossom",
"description": "Maximum file size in bytes"
},
{
"key": "enable_relay_connect",
"value": "true",
"data_type": "boolean",
"category": "relay",
"description": "Enable relay client functionality"
}
]
}
```
**Configuration Categories:**
- `blossom`: Blossom protocol settings (max_file_size, storage_path, etc.)
- `relay`: Relay client settings (enable_relay_connect, kind_0_content, etc.)
- `auth`: Authentication settings (auth_enabled, nip42_required, etc.)
- `limits`: Rate limits and quotas
- `system`: System-level settings
#### `config_update`
Update configuration parameters dynamically.
**Command Format:**
```json
["config_update", [
{
"key": "max_file_size",
"value": "209715200",
"data_type": "integer",
"category": "blossom"
},
{
"key": "enable_relay_connect",
"value": "true",
"data_type": "boolean",
"category": "relay"
}
]]
```
**Response:**
```json
{
"query_type": "config_update",
"status": "success",
"total_results": 2,
"timestamp": 1234567890,
"data": [
{
"key": "max_file_size",
"value": "209715200",
"status": "updated",
"restart_required": false
},
{
"key": "enable_relay_connect",
"value": "true",
"status": "updated",
"restart_required": true
}
]
}
```
### 2. Statistics and Monitoring
#### `stats_query`
Get comprehensive database and storage statistics.
**Command Format:**
```json
["stats_query"]
```
**Response:**
```json
{
"query_type": "stats_query",
"timestamp": 1234567890,
"database_size_bytes": 1048576,
"storage_size_bytes": 10737418240,
"total_blobs": 1543,
"unique_uploaders": 234,
"blob_types": [
{"type": "image/jpeg", "count": 856, "size_bytes": 5368709120, "percentage": 55.4},
{"type": "image/png", "count": 432, "size_bytes": 3221225472, "percentage": 28.0},
{"type": "video/mp4", "count": 123, "size_bytes": 2147483648, "percentage": 8.0}
],
"time_stats": {
"total": 1543,
"last_24h": 45,
"last_7d": 234,
"last_30d": 876
},
"top_uploaders": [
{"pubkey": "abc123...", "blob_count": 234, "total_bytes": 1073741824, "percentage": 15.2},
{"pubkey": "def456...", "blob_count": 187, "total_bytes": 858993459, "percentage": 12.1}
]
}
```
#### `system_status`
Get current system status and health metrics.
**Command Format:**
```json
["system_command", "system_status"]
```
**Response:**
```json
{
"query_type": "system_status",
"timestamp": 1234567890,
"uptime_seconds": 86400,
"version": "0.1.0",
"relay_client": {
"enabled": true,
"connected_relays": 1,
"relay_status": [
{
"url": "wss://relay.laantungir.net",
"state": "connected",
"events_received": 12,
"events_published": 3
}
]
},
"storage": {
"path": "/home/teknari/lt_gitea/ginxsom/blobs",
"total_bytes": 10737418240,
"available_bytes": 53687091200,
"usage_percentage": 16.7
},
"database": {
"path": "db/52e366edfa4e9cc6a6d4653828e51ccf828a2f5a05227d7a768f33b5a198681a.db",
"size_bytes": 1048576,
"total_blobs": 1543
}
}
```
### 3. Blossom-Specific Commands
#### `blob_list`
List blobs with filtering options.
**Command Format:**
```json
["blob_list", "all"]
["blob_list", "pubkey", "abc123..."]
["blob_list", "type", "image/jpeg"]
["blob_list", "recent", 50]
```
**Response:**
```json
{
"query_type": "blob_list",
"total_results": 50,
"timestamp": 1234567890,
"data": [
{
"sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
"size": 184292,
"type": "application/pdf",
"uploaded_at": 1725105921,
"uploader_pubkey": "abc123...",
"url": "https://cdn.example.com/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
}
]
}
```
#### `storage_stats`
Get detailed storage statistics.
**Command Format:**
```json
["storage_stats"]
```
**Response:**
```json
{
"query_type": "storage_stats",
"timestamp": 1234567890,
"storage_path": "/home/teknari/lt_gitea/ginxsom/blobs",
"total_bytes": 10737418240,
"available_bytes": 53687091200,
"used_bytes": 10737418240,
"usage_percentage": 16.7,
"blob_count": 1543,
"average_blob_size": 6958592,
"largest_blob": {
"sha256": "abc123...",
"size": 104857600,
"type": "video/mp4"
},
"by_type": [
{"type": "image/jpeg", "count": 856, "total_bytes": 5368709120},
{"type": "image/png", "count": 432, "total_bytes": 3221225472}
]
}
```
#### `mirror_status`
Get status of blob mirroring operations (BUD-04).
**Command Format:**
```json
["mirror_status"]
["mirror_status", "sha256", "abc123..."]
```
**Response:**
```json
{
"query_type": "mirror_status",
"timestamp": 1234567890,
"total_mirrors": 23,
"data": [
{
"sha256": "abc123...",
"source_url": "https://cdn.example.com/abc123.jpg",
"status": "completed",
"mirrored_at": 1725105921,
"size": 1048576
}
]
}
```
#### `report_query`
Query content reports (BUD-09).
**Command Format:**
```json
["report_query", "all"]
["report_query", "blob", "abc123..."]
["report_query", "type", "nudity"]
```
**Response:**
```json
{
"query_type": "report_query",
"total_results": 12,
"timestamp": 1234567890,
"data": [
{
"report_id": 1,
"blob_sha256": "abc123...",
"report_type": "nudity",
"reporter_pubkey": "def456...",
"content": "Inappropriate content",
"reported_at": 1725105921
}
]
}
```
### 4. Database Queries
#### `sql_query`
Execute read-only SQL queries for debugging.
**Command Format:**
```json
["sql_query", "SELECT * FROM blobs LIMIT 10"]
```
**Response:**
```json
{
"query_type": "sql_query",
"request_id": "request_event_id",
"timestamp": 1234567890,
"query": "SELECT * FROM blobs LIMIT 10",
"execution_time_ms": 12,
"row_count": 10,
"columns": ["sha256", "size", "type", "uploaded_at", "uploader_pubkey"],
"rows": [
["b1674191...", 184292, "application/pdf", 1725105921, "abc123..."]
]
}
```
**Security:**
- Only SELECT statements allowed
- Query timeout: 5 seconds
- Result row limit: 1000 rows
- All queries logged
## Implementation Architecture
### 1. Command Processing Flow
```
1. Relay client receives Kind 23456 event
2. Verify sender is admin_pubkey
3. Decrypt content using NIP-44
4. Parse command array
5. Validate command structure
6. Execute command handler
7. Generate response object
8. Encrypt response using NIP-44
9. Create Kind 23457 event
10. Publish to relays
```
### 2. Code Structure
**New Files:**
- `src/admin_commands.c` - Command handlers
- `src/admin_commands.h` - Command interface
- `src/nip44.c` - NIP-44 encryption wrapper (uses nostr_core_lib)
- `src/nip44.h` - NIP-44 interface
**Modified Files:**
- `src/relay_client.c` - Add command processing to `on_admin_command_event()`
- `src/main.c` - Initialize admin command system
### 3. Database Schema Additions
```sql
-- Admin command log
CREATE TABLE IF NOT EXISTS admin_commands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id TEXT NOT NULL,
command_type TEXT NOT NULL,
admin_pubkey TEXT NOT NULL,
executed_at INTEGER NOT NULL,
execution_time_ms INTEGER,
status TEXT NOT NULL,
error TEXT
);
-- Create index for command history queries
CREATE INDEX IF NOT EXISTS idx_admin_commands_executed
ON admin_commands(executed_at DESC);
```
### 4. Configuration Keys
**Blossom Category:**
- `max_file_size` - Maximum upload size in bytes
- `storage_path` - Blob storage directory
- `cdn_origin` - CDN URL for blob descriptors
- `enable_nip94` - Include NIP-94 tags in responses
**Relay Category:**
- `enable_relay_connect` - Enable relay client
- `kind_0_content` - Profile metadata JSON
- `kind_10002_tags` - Relay list JSON array
**Auth Category:**
- `auth_enabled` - Enable auth rules system
- `require_auth_upload` - Require auth for uploads
- `require_auth_delete` - Require auth for deletes
**Limits Category:**
- `max_blobs_per_user` - Per-user blob limit
- `rate_limit_uploads` - Uploads per minute
- `max_total_storage` - Total storage limit in bytes
## Implementation Phases
### Phase 1: NIP-44 Encryption Support
- Integrate nostr_core_lib NIP-44 functions
- Create encryption/decryption wrappers
- Test with sample data
### Phase 2: Command Infrastructure
- Create admin_commands.c/h
- Implement command parser
- Add command logging to database
- Implement response builder
### Phase 3: Core Commands
- Implement `config_query`
- Implement `config_update`
- Implement `stats_query`
- Implement `system_status`
### Phase 4: Blossom Commands
- Implement `blob_list`
- Implement `storage_stats`
- Implement `mirror_status`
- Implement `report_query`
### Phase 5: Advanced Features
- Implement `sql_query` with security
- Add command history tracking
- Implement rate limiting for admin commands
### Phase 6: Testing & Documentation
- Create test suite for each command
- Update README.md with admin API section
- Create example scripts using nak tool
## Security Considerations
1. **Authentication**: Only admin_pubkey can send commands
2. **Encryption**: All commands/responses use NIP-44
3. **Logging**: All admin actions logged to database
4. **Rate Limiting**: Prevent admin command flooding
5. **SQL Safety**: Only SELECT allowed, with timeout and row limits
6. **Input Validation**: Strict validation of all command parameters
## Testing Strategy
1. **Unit Tests**: Test each command handler independently
2. **Integration Tests**: Test full command flow with encryption
3. **Security Tests**: Verify auth checks and SQL injection prevention
4. **Performance Tests**: Ensure commands don't block relay operations
5. **Manual Tests**: Use nak tool to send real encrypted commands
## Documentation Updates
Add new section to README.md after "Content Reporting (BUD-09)":
```markdown
## Administrator API
Ginxsom uses an event-based administration system where commands are sent as
NIP-44 encrypted Kind 23456 events and responses are returned as Kind 23457
events. This provides secure, cryptographically authenticated remote management.
[Full admin API documentation here]

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
# Configuration
# Parse command line arguments
TEST_MODE=0
TEST_MODE=1 # Default to test mode
FOLLOW_LOGS=0
while [[ $# -gt 0 ]]; do
@@ -15,14 +15,19 @@ while [[ $# -gt 0 ]]; do
TEST_MODE=1
shift
;;
-p|--production)
TEST_MODE=0
shift
;;
--follow)
FOLLOW_LOGS=1
shift
;;
*)
echo "Unknown option: $1"
echo "Usage: $0 [-t|--test-keys] [--follow]"
echo " -t, --test-keys Use test mode with keys from .test_keys"
echo "Usage: $0 [-t|--test-keys] [-p|--production] [--follow]"
echo " -t, --test-keys Use test mode with keys from .test_keys (DEFAULT)"
echo " -p, --production Use production mode (generate new keys)"
echo " --follow Follow logs in real-time"
exit 1
;;
@@ -170,6 +175,12 @@ echo -e "${GREEN}FastCGI cleanup complete${NC}"
# Step 3: Always rebuild FastCGI binary with clean build
echo -e "\n${YELLOW}3. Rebuilding FastCGI binary (clean build)...${NC}"
echo "Embedding web files..."
./scripts/embed_web_files.sh
if [ $? -ne 0 ]; then
echo -e "${RED}Web file embedding failed! Cannot continue.${NC}"
exit 1
fi
echo "Performing clean rebuild to ensure all changes are compiled..."
make clean && make
if [ $? -ne 0 ]; then

82
scripts/embed_web_files.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Embed web interface files into C source code
# This script converts HTML, CSS, and JS files into C byte arrays
set -e
# Configuration
API_DIR="api"
OUTPUT_DIR="src"
OUTPUT_FILE="${OUTPUT_DIR}/admin_interface_embedded.h"
# Files to embed
FILES=(
"index.html"
"index.css"
"index.js"
"nostr-lite.js"
"nostr.bundle.js"
"text_graph.js"
)
echo "=== Embedding Web Interface Files ==="
echo "Source directory: ${API_DIR}"
echo "Output file: ${OUTPUT_FILE}"
echo ""
# Start output file
cat > "${OUTPUT_FILE}" << 'EOF'
/*
* Embedded Web Interface Files
* Auto-generated by scripts/embed_web_files.sh
* DO NOT EDIT MANUALLY
*/
#ifndef ADMIN_INTERFACE_EMBEDDED_H
#define ADMIN_INTERFACE_EMBEDDED_H
#include <stddef.h>
EOF
# Process each file
for file in "${FILES[@]}"; do
filepath="${API_DIR}/${file}"
if [[ ! -f "${filepath}" ]]; then
echo "WARNING: File not found: ${filepath}"
continue
fi
# Create variable name from filename (replace . and - with _)
varname=$(echo "${file}" | tr '.-' '__')
echo "Embedding: ${file} -> embedded_${varname}"
# Get file size
filesize=$(stat -f%z "${filepath}" 2>/dev/null || stat -c%s "${filepath}" 2>/dev/null)
# Add comment
echo "" >> "${OUTPUT_FILE}"
echo "// Embedded file: ${file} (${filesize} bytes)" >> "${OUTPUT_FILE}"
# Convert file to C byte array
echo "static const unsigned char embedded_${varname}[] = {" >> "${OUTPUT_FILE}"
# Use xxd to convert to hex, then format as C array
xxd -i < "${filepath}" >> "${OUTPUT_FILE}"
echo "};" >> "${OUTPUT_FILE}"
echo "static const size_t embedded_${varname}_size = sizeof(embedded_${varname});" >> "${OUTPUT_FILE}"
done
# Close header guard
cat >> "${OUTPUT_FILE}" << 'EOF'
#endif /* ADMIN_INTERFACE_EMBEDDED_H */
EOF
echo ""
echo "=== Embedding Complete ==="
echo "Generated: ${OUTPUT_FILE}"
echo "Total files embedded: ${#FILES[@]}"

743
src/admin_commands.c Normal file
View File

@@ -0,0 +1,743 @@
/*
* Ginxsom Admin Commands Implementation
*/
#include "admin_commands.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
// Forward declare app_log
typedef enum {
LOG_DEBUG = 0,
LOG_INFO = 1,
LOG_WARN = 2,
LOG_ERROR = 3
} log_level_t;
void app_log(log_level_t level, const char* format, ...);
// Global state
static struct {
int initialized;
char db_path[512];
} g_admin_state = {0};
// Initialize admin command system
int admin_commands_init(const char *db_path) {
if (g_admin_state.initialized) {
return 0;
}
strncpy(g_admin_state.db_path, db_path, sizeof(g_admin_state.db_path) - 1);
g_admin_state.initialized = 1;
app_log(LOG_INFO, "Admin command system initialized");
return 0;
}
// NIP-44 encryption helper
int admin_encrypt_response(
const unsigned char* server_privkey,
const unsigned char* admin_pubkey,
const char* plaintext_json,
char* output,
size_t output_size
) {
int result = nostr_nip44_encrypt(
server_privkey,
admin_pubkey,
plaintext_json,
output,
output_size
);
if (result != 0) {
app_log(LOG_ERROR, "Failed to encrypt admin response: %d", result);
return -1;
}
return 0;
}
// NIP-44 decryption helper
int admin_decrypt_command(
const unsigned char* server_privkey,
const unsigned char* admin_pubkey,
const char* encrypted_data,
char* output,
size_t output_size
) {
int result = nostr_nip44_decrypt(
server_privkey,
admin_pubkey,
encrypted_data,
output,
output_size
);
if (result != 0) {
app_log(LOG_ERROR, "Failed to decrypt admin command: %d", result);
return -1;
}
return 0;
}
// Create error response
static cJSON* create_error_response(const char* query_type, const char* error_msg) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", query_type);
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", error_msg);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Process admin command array and generate response
cJSON* admin_commands_process(cJSON* command_array, const char* request_event_id) {
(void)request_event_id; // Reserved for future use (e.g., logging, tracking)
if (!cJSON_IsArray(command_array) || cJSON_GetArraySize(command_array) < 1) {
return create_error_response("unknown", "Invalid command format");
}
cJSON* cmd_type = cJSON_GetArrayItem(command_array, 0);
if (!cJSON_IsString(cmd_type)) {
return create_error_response("unknown", "Command type must be string");
}
const char* command = cmd_type->valuestring;
app_log(LOG_INFO, "Processing admin command: %s", command);
// Route to appropriate handler
if (strcmp(command, "config_query") == 0) {
return admin_cmd_config_query(command_array);
}
else if (strcmp(command, "config_update") == 0) {
return admin_cmd_config_update(command_array);
}
else if (strcmp(command, "stats_query") == 0) {
return admin_cmd_stats_query(command_array);
}
else if (strcmp(command, "system_command") == 0) {
// Check second parameter for system_status
if (cJSON_GetArraySize(command_array) >= 2) {
cJSON* subcmd = cJSON_GetArrayItem(command_array, 1);
if (cJSON_IsString(subcmd) && strcmp(subcmd->valuestring, "system_status") == 0) {
return admin_cmd_system_status(command_array);
}
}
return create_error_response("system_command", "Unknown system command");
}
else if (strcmp(command, "blob_list") == 0) {
return admin_cmd_blob_list(command_array);
}
else if (strcmp(command, "storage_stats") == 0) {
return admin_cmd_storage_stats(command_array);
}
else if (strcmp(command, "sql_query") == 0) {
return admin_cmd_sql_query(command_array);
}
else {
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "Unknown command: %s", command);
return create_error_response("unknown", error_msg);
}
}
// ============================================================================
// COMMAND HANDLERS (Stub implementations - to be completed)
// ============================================================================
cJSON* admin_cmd_config_query(cJSON* args) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "config_query");
// Open database
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Check if specific keys were requested (args[1] should be array of keys or null for all)
cJSON* keys_array = NULL;
if (cJSON_GetArraySize(args) >= 2) {
keys_array = cJSON_GetArrayItem(args, 1);
if (!cJSON_IsArray(keys_array) && !cJSON_IsNull(keys_array)) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Keys parameter must be array or null");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
}
sqlite3_stmt* stmt;
const char* sql;
if (keys_array && cJSON_IsArray(keys_array) && cJSON_GetArraySize(keys_array) > 0) {
// Query specific keys
int key_count = cJSON_GetArraySize(keys_array);
// Build SQL with placeholders
char sql_buffer[1024] = "SELECT key, value, description FROM config WHERE key IN (?";
for (int i = 1; i < key_count && i < 50; i++) { // Limit to 50 keys
strncat(sql_buffer, ",?", sizeof(sql_buffer) - strlen(sql_buffer) - 1);
}
strncat(sql_buffer, ")", sizeof(sql_buffer) - strlen(sql_buffer) - 1);
rc = sqlite3_prepare_v2(db, sql_buffer, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
// Bind keys
for (int i = 0; i < key_count && i < 50; i++) {
cJSON* key_item = cJSON_GetArrayItem(keys_array, i);
if (cJSON_IsString(key_item)) {
sqlite3_bind_text(stmt, i + 1, key_item->valuestring, -1, SQLITE_STATIC);
}
}
} else {
// Query all config values
sql = "SELECT key, value, description FROM config ORDER BY key";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
}
// Execute query and build result
cJSON* config_obj = cJSON_CreateObject();
int count = 0;
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char* key = (const char*)sqlite3_column_text(stmt, 0);
const char* value = (const char*)sqlite3_column_text(stmt, 1);
const char* description = (const char*)sqlite3_column_text(stmt, 2);
cJSON* entry = cJSON_CreateObject();
cJSON_AddStringToObject(entry, "value", value ? value : "");
if (description && strlen(description) > 0) {
cJSON_AddStringToObject(entry, "description", description);
}
cJSON_AddItemToObject(config_obj, key, entry);
count++;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "count", count);
cJSON_AddItemToObject(response, "config", config_obj);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
app_log(LOG_INFO, "Config query returned %d entries", count);
return response;
}
cJSON* admin_cmd_config_update(cJSON* args) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "config_update");
// Expected format: ["config_update", {"key1": "value1", "key2": "value2"}]
if (cJSON_GetArraySize(args) < 2) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Missing config updates object");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* updates = cJSON_GetArrayItem(args, 1);
if (!cJSON_IsObject(updates)) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Updates must be an object");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Open database for writing
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READWRITE, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Prepare update statement
const char* sql = "UPDATE config SET value = ?, updated_at = strftime('%s', 'now') WHERE key = ?";
sqlite3_stmt* stmt;
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to prepare update statement");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
// Process each update
cJSON* updated_keys = cJSON_CreateArray();
cJSON* failed_keys = cJSON_CreateArray();
int success_count = 0;
int fail_count = 0;
cJSON* item = NULL;
cJSON_ArrayForEach(item, updates) {
const char* key = item->string;
const char* value = cJSON_GetStringValue(item);
if (!value) {
cJSON_AddItemToArray(failed_keys, cJSON_CreateString(key));
fail_count++;
continue;
}
sqlite3_reset(stmt);
sqlite3_bind_text(stmt, 1, value, -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, key, -1, SQLITE_TRANSIENT);
rc = sqlite3_step(stmt);
if (rc == SQLITE_DONE && sqlite3_changes(db) > 0) {
cJSON_AddItemToArray(updated_keys, cJSON_CreateString(key));
success_count++;
app_log(LOG_INFO, "Updated config key: %s", key);
} else {
cJSON_AddItemToArray(failed_keys, cJSON_CreateString(key));
fail_count++;
}
}
sqlite3_finalize(stmt);
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "updated_count", success_count);
cJSON_AddNumberToObject(response, "failed_count", fail_count);
cJSON_AddItemToObject(response, "updated_keys", updated_keys);
if (fail_count > 0) {
cJSON_AddItemToObject(response, "failed_keys", failed_keys);
} else {
cJSON_Delete(failed_keys);
}
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* admin_cmd_stats_query(cJSON* args) {
(void)args;
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "stats_query");
// Open database
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Query storage stats view
const char* sql = "SELECT * FROM storage_stats";
sqlite3_stmt* stmt;
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to query stats");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
cJSON* stats = cJSON_CreateObject();
if (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON_AddNumberToObject(stats, "total_blobs", sqlite3_column_int64(stmt, 0));
cJSON_AddNumberToObject(stats, "total_bytes", sqlite3_column_int64(stmt, 1));
cJSON_AddNumberToObject(stats, "avg_blob_size", sqlite3_column_double(stmt, 2));
cJSON_AddNumberToObject(stats, "first_upload", sqlite3_column_int64(stmt, 3));
cJSON_AddNumberToObject(stats, "last_upload", sqlite3_column_int64(stmt, 4));
cJSON_AddNumberToObject(stats, "unique_uploaders", sqlite3_column_int64(stmt, 5));
}
sqlite3_finalize(stmt);
// Get auth rules count
sql = "SELECT COUNT(*) FROM auth_rules WHERE enabled = 1";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
cJSON_AddNumberToObject(stats, "active_auth_rules", sqlite3_column_int(stmt, 0));
}
sqlite3_finalize(stmt);
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddItemToObject(response, "stats", stats);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* admin_cmd_system_status(cJSON* args) {
(void)args;
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "system_status");
cJSON* status = cJSON_CreateObject();
// Server uptime (would need to track start time - placeholder for now)
cJSON_AddStringToObject(status, "server_status", "running");
cJSON_AddNumberToObject(status, "current_time", (double)time(NULL));
// Database status
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc == SQLITE_OK) {
cJSON_AddStringToObject(status, "database_status", "connected");
// Get database size
sqlite3_stmt* stmt;
const char* sql = "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()";
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON_AddNumberToObject(status, "database_size_bytes", sqlite3_column_int64(stmt, 0));
}
sqlite3_finalize(stmt);
}
sqlite3_close(db);
} else {
cJSON_AddStringToObject(status, "database_status", "error");
}
// Memory info (basic - would need more system calls for detailed info)
cJSON_AddStringToObject(status, "memory_status", "ok");
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddItemToObject(response, "system", status);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* admin_cmd_blob_list(cJSON* args) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "blob_list");
// Parse optional parameters: limit, offset, uploader_pubkey
int limit = 100; // Default limit
int offset = 0;
const char* uploader_filter = NULL;
if (cJSON_GetArraySize(args) >= 2) {
cJSON* params = cJSON_GetArrayItem(args, 1);
if (cJSON_IsObject(params)) {
cJSON* limit_item = cJSON_GetObjectItem(params, "limit");
if (cJSON_IsNumber(limit_item)) {
limit = limit_item->valueint;
if (limit > 1000) limit = 1000; // Max 1000
if (limit < 1) limit = 1;
}
cJSON* offset_item = cJSON_GetObjectItem(params, "offset");
if (cJSON_IsNumber(offset_item)) {
offset = offset_item->valueint;
if (offset < 0) offset = 0;
}
cJSON* uploader_item = cJSON_GetObjectItem(params, "uploader");
if (cJSON_IsString(uploader_item)) {
uploader_filter = uploader_item->valuestring;
}
}
}
// Open database
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Build query
char sql[512];
if (uploader_filter) {
snprintf(sql, sizeof(sql),
"SELECT sha256, size, type, uploaded_at, uploader_pubkey, filename "
"FROM blobs WHERE uploader_pubkey = ? "
"ORDER BY uploaded_at DESC LIMIT ? OFFSET ?");
} else {
snprintf(sql, sizeof(sql),
"SELECT sha256, size, type, uploaded_at, uploader_pubkey, filename "
"FROM blobs ORDER BY uploaded_at DESC LIMIT ? OFFSET ?");
}
sqlite3_stmt* stmt;
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to prepare query");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
// Bind parameters
int param_idx = 1;
if (uploader_filter) {
sqlite3_bind_text(stmt, param_idx++, uploader_filter, -1, SQLITE_STATIC);
}
sqlite3_bind_int(stmt, param_idx++, limit);
sqlite3_bind_int(stmt, param_idx++, offset);
// Execute and build results
cJSON* blobs = cJSON_CreateArray();
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON* blob = cJSON_CreateObject();
cJSON_AddStringToObject(blob, "sha256", (const char*)sqlite3_column_text(stmt, 0));
cJSON_AddNumberToObject(blob, "size", sqlite3_column_int64(stmt, 1));
cJSON_AddStringToObject(blob, "type", (const char*)sqlite3_column_text(stmt, 2));
cJSON_AddNumberToObject(blob, "uploaded_at", sqlite3_column_int64(stmt, 3));
const char* uploader = (const char*)sqlite3_column_text(stmt, 4);
if (uploader) {
cJSON_AddStringToObject(blob, "uploader_pubkey", uploader);
}
const char* filename = (const char*)sqlite3_column_text(stmt, 5);
if (filename) {
cJSON_AddStringToObject(blob, "filename", filename);
}
cJSON_AddItemToArray(blobs, blob);
count++;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddNumberToObject(response, "count", count);
cJSON_AddNumberToObject(response, "limit", limit);
cJSON_AddNumberToObject(response, "offset", offset);
cJSON_AddItemToObject(response, "blobs", blobs);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* admin_cmd_storage_stats(cJSON* args) {
(void)args;
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "storage_stats");
// Open database
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* storage = cJSON_CreateObject();
// Get overall stats from view
const char* sql = "SELECT * FROM storage_stats";
sqlite3_stmt* stmt;
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK && sqlite3_step(stmt) == SQLITE_ROW) {
cJSON_AddNumberToObject(storage, "total_blobs", sqlite3_column_int64(stmt, 0));
cJSON_AddNumberToObject(storage, "total_bytes", sqlite3_column_int64(stmt, 1));
cJSON_AddNumberToObject(storage, "avg_blob_size", sqlite3_column_double(stmt, 2));
cJSON_AddNumberToObject(storage, "first_upload", sqlite3_column_int64(stmt, 3));
cJSON_AddNumberToObject(storage, "last_upload", sqlite3_column_int64(stmt, 4));
cJSON_AddNumberToObject(storage, "unique_uploaders", sqlite3_column_int64(stmt, 5));
}
sqlite3_finalize(stmt);
// Get stats by MIME type
sql = "SELECT type, COUNT(*) as count, SUM(size) as total_size "
"FROM blobs GROUP BY type ORDER BY count DESC LIMIT 10";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
cJSON* by_type = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON* type_stat = cJSON_CreateObject();
cJSON_AddStringToObject(type_stat, "mime_type", (const char*)sqlite3_column_text(stmt, 0));
cJSON_AddNumberToObject(type_stat, "count", sqlite3_column_int64(stmt, 1));
cJSON_AddNumberToObject(type_stat, "total_bytes", sqlite3_column_int64(stmt, 2));
cJSON_AddItemToArray(by_type, type_stat);
}
cJSON_AddItemToObject(storage, "by_mime_type", by_type);
sqlite3_finalize(stmt);
}
// Get top uploaders
sql = "SELECT uploader_pubkey, COUNT(*) as count, SUM(size) as total_size "
"FROM blobs WHERE uploader_pubkey IS NOT NULL "
"GROUP BY uploader_pubkey ORDER BY count DESC LIMIT 10";
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc == SQLITE_OK) {
cJSON* top_uploaders = cJSON_CreateArray();
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON* uploader_stat = cJSON_CreateObject();
cJSON_AddStringToObject(uploader_stat, "pubkey", (const char*)sqlite3_column_text(stmt, 0));
cJSON_AddNumberToObject(uploader_stat, "blob_count", sqlite3_column_int64(stmt, 1));
cJSON_AddNumberToObject(uploader_stat, "total_bytes", sqlite3_column_int64(stmt, 2));
cJSON_AddItemToArray(top_uploaders, uploader_stat);
}
cJSON_AddItemToObject(storage, "top_uploaders", top_uploaders);
sqlite3_finalize(stmt);
}
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddItemToObject(response, "storage", storage);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* admin_cmd_sql_query(cJSON* args) {
cJSON* response = cJSON_CreateObject();
cJSON_AddStringToObject(response, "query_type", "sql_query");
// Expected format: ["sql_query", "SELECT ..."]
if (cJSON_GetArraySize(args) < 2) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Missing SQL query");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
cJSON* query_item = cJSON_GetArrayItem(args, 1);
if (!cJSON_IsString(query_item)) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Query must be a string");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
const char* sql = query_item->valuestring;
// Security: Only allow SELECT queries
const char* sql_upper = sql;
while (*sql_upper == ' ' || *sql_upper == '\t' || *sql_upper == '\n') sql_upper++;
if (strncasecmp(sql_upper, "SELECT", 6) != 0) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Only SELECT queries are allowed");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Open database (read-only for safety)
sqlite3* db;
int rc = sqlite3_open_v2(g_admin_state.db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
cJSON_AddStringToObject(response, "error", "Failed to open database");
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
return response;
}
// Prepare and execute query
sqlite3_stmt* stmt;
rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
cJSON_AddStringToObject(response, "status", "error");
char error_msg[256];
snprintf(error_msg, sizeof(error_msg), "SQL error: %s", sqlite3_errmsg(db));
cJSON_AddStringToObject(response, "error", error_msg);
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
sqlite3_close(db);
return response;
}
// Get column names
int col_count = sqlite3_column_count(stmt);
cJSON* columns = cJSON_CreateArray();
for (int i = 0; i < col_count; i++) {
cJSON_AddItemToArray(columns, cJSON_CreateString(sqlite3_column_name(stmt, i)));
}
// Execute and collect rows (limit to 1000 rows for safety)
cJSON* rows = cJSON_CreateArray();
int row_count = 0;
const int MAX_ROWS = 1000;
while (row_count < MAX_ROWS && (rc = sqlite3_step(stmt)) == SQLITE_ROW) {
cJSON* row = cJSON_CreateArray();
for (int i = 0; i < col_count; i++) {
int col_type = sqlite3_column_type(stmt, i);
switch (col_type) {
case SQLITE_INTEGER:
cJSON_AddItemToArray(row, cJSON_CreateNumber(sqlite3_column_int64(stmt, i)));
break;
case SQLITE_FLOAT:
cJSON_AddItemToArray(row, cJSON_CreateNumber(sqlite3_column_double(stmt, i)));
break;
case SQLITE_TEXT:
cJSON_AddItemToArray(row, cJSON_CreateString((const char*)sqlite3_column_text(stmt, i)));
break;
case SQLITE_NULL:
cJSON_AddItemToArray(row, cJSON_CreateNull());
break;
default:
cJSON_AddItemToArray(row, cJSON_CreateString(""));
}
}
cJSON_AddItemToArray(rows, row);
row_count++;
}
sqlite3_finalize(stmt);
sqlite3_close(db);
cJSON_AddStringToObject(response, "status", "success");
cJSON_AddItemToObject(response, "columns", columns);
cJSON_AddItemToObject(response, "rows", rows);
cJSON_AddNumberToObject(response, "row_count", row_count);
if (row_count >= MAX_ROWS) {
cJSON_AddBoolToObject(response, "truncated", 1);
}
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
app_log(LOG_INFO, "SQL query executed: %d rows returned", row_count);
return response;
}

56
src/admin_commands.h Normal file
View File

@@ -0,0 +1,56 @@
/*
* Ginxsom Admin Commands Interface
*
* Handles encrypted admin commands sent via Kind 23458 events
* and generates encrypted responses as Kind 23459 events.
*/
#ifndef ADMIN_COMMANDS_H
#define ADMIN_COMMANDS_H
#include <cjson/cJSON.h>
// Command handler result codes
typedef enum {
ADMIN_CMD_SUCCESS = 0,
ADMIN_CMD_ERROR_PARSE = -1,
ADMIN_CMD_ERROR_UNKNOWN = -2,
ADMIN_CMD_ERROR_INVALID = -3,
ADMIN_CMD_ERROR_DATABASE = -4,
ADMIN_CMD_ERROR_PERMISSION = -5
} admin_cmd_result_t;
// Initialize admin command system
int admin_commands_init(const char *db_path);
// Process an admin command and generate response
// Returns cJSON response object (caller must free with cJSON_Delete)
cJSON* admin_commands_process(cJSON* command_array, const char* request_event_id);
// Individual command handlers
cJSON* admin_cmd_config_query(cJSON* args);
cJSON* admin_cmd_config_update(cJSON* args);
cJSON* admin_cmd_stats_query(cJSON* args);
cJSON* admin_cmd_system_status(cJSON* args);
cJSON* admin_cmd_blob_list(cJSON* args);
cJSON* admin_cmd_storage_stats(cJSON* args);
cJSON* admin_cmd_sql_query(cJSON* args);
// NIP-44 encryption/decryption helpers
int admin_encrypt_response(
const unsigned char* server_privkey,
const unsigned char* admin_pubkey,
const char* plaintext_json,
char* output,
size_t output_size
);
int admin_decrypt_command(
const unsigned char* server_privkey,
const unsigned char* admin_pubkey,
const char* encrypted_data,
char* output,
size_t output_size
);
#endif /* ADMIN_COMMANDS_H */

View File

@@ -1,8 +1,10 @@
// Admin event handler for Kind 23456/23457 admin commands
// Admin event handler for Kind 23458/23459 admin commands
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/types.h>
#include "ginxsom.h"
// Forward declarations for nostr_core_lib functions
@@ -27,90 +29,162 @@ extern char g_db_path[];
static int get_server_privkey(unsigned char* privkey_bytes);
static int get_server_pubkey(char* pubkey_hex, size_t size);
static int handle_config_query_command(cJSON* response_data);
static int send_admin_response_event(const char* admin_pubkey, const char* request_id,
static int handle_query_view_command(cJSON* command_array, cJSON* response_data);
static int send_admin_response_event(const char* admin_pubkey, const char* request_id,
cJSON* response_data);
static cJSON* parse_authorization_header(void);
static int process_admin_event(cJSON* event);
/**
* Handle Kind 23456 admin command event
* Expects POST to /api/admin with JSON body containing the event
* Handle Kind 23458 admin command event
* Supports two delivery methods:
* 1. POST body with JSON event
* 2. Authorization header with Nostr event
*/
void handle_admin_event_request(void) {
// Read request body
const char* content_length_str = getenv("CONTENT_LENGTH");
if (!content_length_str) {
printf("Status: 411 Length Required\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Content-Length header required\"}\n");
return;
}
cJSON* event = NULL;
int should_free_event = 1;
long content_length = atol(content_length_str);
if (content_length <= 0 || content_length > 65536) {
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid content length\"}\n");
return;
}
char* json_body = malloc(content_length + 1);
if (!json_body) {
printf("Status: 500 Internal Server Error\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Memory allocation failed\"}\n");
return;
}
size_t bytes_read = fread(json_body, 1, content_length, stdin);
if (bytes_read != (size_t)content_length) {
free(json_body);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Failed to read complete request body\"}\n");
return;
}
json_body[content_length] = '\0';
// Parse event JSON
cJSON* event = cJSON_Parse(json_body);
free(json_body);
// First, try to get event from Authorization header
event = parse_authorization_header();
// If not in header, try POST body
if (!event) {
const char* content_length_str = getenv("CONTENT_LENGTH");
if (!content_length_str) {
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Event required in POST body or Authorization header\"}\n");
return;
}
long content_length = atol(content_length_str);
if (content_length <= 0 || content_length > 65536) {
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid content length\"}\n");
return;
}
char* json_body = malloc(content_length + 1);
if (!json_body) {
printf("Status: 500 Internal Server Error\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Memory allocation failed\"}\n");
return;
}
size_t bytes_read = fread(json_body, 1, content_length, stdin);
if (bytes_read != (size_t)content_length) {
free(json_body);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Failed to read complete request body\"}\n");
return;
}
json_body[content_length] = '\0';
// Parse event JSON
event = cJSON_Parse(json_body);
// Debug: Log the received JSON
app_log(LOG_DEBUG, "ADMIN_EVENT: Received POST body: %s", json_body);
free(json_body);
if (!event) {
app_log(LOG_ERROR, "ADMIN_EVENT: Failed to parse JSON");
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid JSON\"}\n");
return;
}
// Debug: Log parsed event
char* event_str = cJSON_Print(event);
if (event_str) {
app_log(LOG_DEBUG, "ADMIN_EVENT: Parsed event: %s", event_str);
free(event_str);
}
}
// Process the event (handles validation, decryption, command execution, response)
int result = process_admin_event(event);
// Clean up
if (should_free_event && event) {
cJSON_Delete(event);
}
(void)result; // Result already handled by process_admin_event
}
/**
* Parse Kind 23458 event from Authorization header
* Format: Authorization: Nostr <base64-encoded-event-json>
* Returns: cJSON event object or NULL if not present/invalid
*/
static cJSON* parse_authorization_header(void) {
const char* auth_header = getenv("HTTP_AUTHORIZATION");
if (!auth_header) {
return NULL;
}
// Check for "Nostr " prefix (case-insensitive)
if (strncasecmp(auth_header, "Nostr ", 6) != 0) {
return NULL;
}
// Skip "Nostr " prefix
const char* base64_event = auth_header + 6;
// Decode base64 (simple implementation - in production use proper base64 decoder)
// For now, assume the event is JSON directly (not base64 encoded)
// This matches the pattern from c-relay's admin interface
cJSON* event = cJSON_Parse(base64_event);
return event;
}
/**
* Process a Kind 23458 admin event (from POST body or Authorization header)
* Returns: 0 on success, -1 on error (error response already sent)
*/
static int process_admin_event(cJSON* event) {
if (!event) {
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid JSON\"}\n");
return;
printf("{\"error\":\"Invalid event\"}\n");
return -1;
}
// Verify it's Kind 23456
// Verify it's Kind 23458
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
if (!kind_obj || !cJSON_IsNumber(kind_obj) ||
(int)cJSON_GetNumberValue(kind_obj) != 23456) {
cJSON_Delete(event);
if (!kind_obj || !cJSON_IsNumber(kind_obj) ||
(int)cJSON_GetNumberValue(kind_obj) != 23458) {
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Event must be Kind 23456\"}\n");
return;
printf("{\"error\":\"Event must be Kind 23458\"}\n");
return -1;
}
// Get event ID for response correlation
cJSON* id_obj = cJSON_GetObjectItem(event, "id");
if (!id_obj || !cJSON_IsString(id_obj)) {
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Event missing id\"}\n");
return;
return -1;
}
const char* request_id = cJSON_GetStringValue(id_obj);
// Get admin pubkey from event
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
if (!pubkey_obj || !cJSON_IsString(pubkey_obj)) {
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Event missing pubkey\"}\n");
return;
return -1;
}
const char* admin_pubkey = cJSON_GetStringValue(pubkey_obj);
@@ -118,11 +192,10 @@ void handle_admin_event_request(void) {
sqlite3* db;
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
cJSON_Delete(event);
printf("Status: 500 Internal Server Error\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Database error\"}\n");
return;
return -1;
}
sqlite3_stmt* stmt;
@@ -141,42 +214,38 @@ void handle_admin_event_request(void) {
sqlite3_close(db);
if (!is_admin) {
cJSON_Delete(event);
printf("Status: 403 Forbidden\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Not authorized as admin\"}\n");
return;
return -1;
}
// Get encrypted content
cJSON* content_obj = cJSON_GetObjectItem(event, "content");
if (!content_obj || !cJSON_IsString(content_obj)) {
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Event missing content\"}\n");
return;
return -1;
}
const char* encrypted_content = cJSON_GetStringValue(content_obj);
// Get server private key for decryption
unsigned char server_privkey[32];
if (get_server_privkey(server_privkey) != 0) {
cJSON_Delete(event);
printf("Status: 500 Internal Server Error\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Failed to get server private key\"}\n");
return;
return -1;
}
// Convert admin pubkey to bytes
unsigned char admin_pubkey_bytes[32];
if (nostr_hex_to_bytes(admin_pubkey, admin_pubkey_bytes, 32) != 0) {
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid admin pubkey format\"}\n");
return;
return -1;
}
// Decrypt content using NIP-44 (or use plaintext for testing)
@@ -195,34 +264,37 @@ void handle_admin_event_request(void) {
);
if (decrypt_result != 0) {
cJSON_Delete(event);
app_log(LOG_ERROR, "ADMIN_EVENT: Decryption failed with result: %d", decrypt_result);
app_log(LOG_ERROR, "ADMIN_EVENT: Encrypted content: %s", encrypted_content);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Failed to decrypt content\"}\n");
return;
return -1;
}
content_to_parse = decrypted_content;
app_log(LOG_DEBUG, "ADMIN_EVENT: Decrypted content: %s", decrypted_content);
} else {
app_log(LOG_DEBUG, "ADMIN_EVENT: Using plaintext content (starts with '['): %s", encrypted_content);
}
// Parse command array (either decrypted or plaintext)
app_log(LOG_DEBUG, "ADMIN_EVENT: Parsing command array from: %s", content_to_parse);
cJSON* command_array = cJSON_Parse(content_to_parse);
if (!command_array || !cJSON_IsArray(command_array)) {
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Decrypted content is not a valid command array\"}\n");
return;
return -1;
}
// Get command type
cJSON* command_type = cJSON_GetArrayItem(command_array, 0);
if (!command_type || !cJSON_IsString(command_type)) {
cJSON_Delete(command_array);
cJSON_Delete(event);
printf("Status: 400 Bad Request\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Invalid command format\"}\n");
return;
return -1;
}
const char* cmd = cJSON_GetStringValue(command_type);
@@ -235,23 +307,35 @@ void handle_admin_event_request(void) {
// Handle command
int result = -1;
if (strcmp(cmd, "config_query") == 0) {
app_log(LOG_DEBUG, "ADMIN_EVENT: Handling config_query command");
result = handle_config_query_command(response_data);
app_log(LOG_DEBUG, "ADMIN_EVENT: config_query result: %d", result);
} else if (strcmp(cmd, "query_view") == 0) {
app_log(LOG_DEBUG, "ADMIN_EVENT: Handling query_view command");
result = handle_query_view_command(command_array, response_data);
app_log(LOG_DEBUG, "ADMIN_EVENT: query_view result: %d", result);
} else {
app_log(LOG_WARN, "ADMIN_EVENT: Unknown command: %s", cmd);
cJSON_AddStringToObject(response_data, "status", "error");
cJSON_AddStringToObject(response_data, "error", "Unknown command");
result = -1;
}
cJSON_Delete(command_array);
cJSON_Delete(event);
if (result == 0) {
// Send Kind 23457 response
send_admin_response_event(admin_pubkey, request_id, response_data);
app_log(LOG_DEBUG, "ADMIN_EVENT: Sending Kind 23459 response");
// Send Kind 23459 response
int send_result = send_admin_response_event(admin_pubkey, request_id, response_data);
app_log(LOG_DEBUG, "ADMIN_EVENT: Response sent with result: %d", send_result);
return send_result;
} else {
app_log(LOG_ERROR, "ADMIN_EVENT: Command processing failed");
cJSON_Delete(response_data);
printf("Status: 500 Internal Server Error\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("{\"error\":\"Command processing failed\"}\n");
return -1;
}
}
@@ -350,7 +434,126 @@ static int handle_config_query_command(cJSON* response_data) {
}
/**
* Send Kind 23457 admin response event
* Handle query_view command - returns data from a specified database view
* Command format: ["query_view", "view_name"]
*/
static int handle_query_view_command(cJSON* command_array, cJSON* response_data) {
app_log(LOG_DEBUG, "ADMIN_EVENT: handle_query_view_command called");
// Get view name from command array
cJSON* view_name_obj = cJSON_GetArrayItem(command_array, 1);
if (!view_name_obj || !cJSON_IsString(view_name_obj)) {
app_log(LOG_ERROR, "ADMIN_EVENT: View name missing or not a string");
cJSON_AddStringToObject(response_data, "status", "error");
cJSON_AddStringToObject(response_data, "error", "View name required");
return -1;
}
const char* view_name = cJSON_GetStringValue(view_name_obj);
app_log(LOG_DEBUG, "ADMIN_EVENT: Querying view: %s", view_name);
// Validate view name (whitelist approach for security)
const char* allowed_views[] = {
"blob_overview",
"blob_type_distribution",
"blob_time_stats",
"top_uploaders",
NULL
};
int view_allowed = 0;
for (int i = 0; allowed_views[i] != NULL; i++) {
if (strcmp(view_name, allowed_views[i]) == 0) {
view_allowed = 1;
break;
}
}
if (!view_allowed) {
cJSON_AddStringToObject(response_data, "status", "error");
cJSON_AddStringToObject(response_data, "error", "Invalid view name");
app_log(LOG_WARN, "ADMIN_EVENT: Attempted to query invalid view: %s", view_name);
return -1;
}
app_log(LOG_DEBUG, "ADMIN_EVENT: View '%s' is allowed, opening database: %s", view_name, g_db_path);
// Open database
sqlite3* db;
int rc = sqlite3_open_v2(g_db_path, &db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
app_log(LOG_ERROR, "ADMIN_EVENT: Failed to open database: %s (error: %s)", g_db_path, sqlite3_errmsg(db));
cJSON_AddStringToObject(response_data, "status", "error");
cJSON_AddStringToObject(response_data, "error", "Database error");
return -1;
}
// Build SQL query
char sql[256];
snprintf(sql, sizeof(sql), "SELECT * FROM %s", view_name);
app_log(LOG_DEBUG, "ADMIN_EVENT: Executing SQL: %s", sql);
sqlite3_stmt* stmt;
if (sqlite3_prepare_v2(db, sql, -1, &stmt, NULL) != SQLITE_OK) {
app_log(LOG_ERROR, "ADMIN_EVENT: Failed to prepare query: %s (error: %s)", sql, sqlite3_errmsg(db));
sqlite3_close(db);
cJSON_AddStringToObject(response_data, "status", "error");
cJSON_AddStringToObject(response_data, "error", "Failed to prepare query");
return -1;
}
// Get column count and names
int col_count = sqlite3_column_count(stmt);
// Create results array
cJSON* results = cJSON_CreateArray();
// Fetch all rows
while (sqlite3_step(stmt) == SQLITE_ROW) {
cJSON* row = cJSON_CreateObject();
for (int i = 0; i < col_count; i++) {
const char* col_name = sqlite3_column_name(stmt, i);
int col_type = sqlite3_column_type(stmt, i);
switch (col_type) {
case SQLITE_INTEGER:
cJSON_AddNumberToObject(row, col_name, (double)sqlite3_column_int64(stmt, i));
break;
case SQLITE_FLOAT:
cJSON_AddNumberToObject(row, col_name, sqlite3_column_double(stmt, i));
break;
case SQLITE_TEXT:
cJSON_AddStringToObject(row, col_name, (const char*)sqlite3_column_text(stmt, i));
break;
case SQLITE_NULL:
cJSON_AddNullToObject(row, col_name);
break;
default:
// For BLOB or unknown types, skip
break;
}
}
cJSON_AddItemToArray(results, row);
}
sqlite3_finalize(stmt);
sqlite3_close(db);
// Build response
cJSON_AddStringToObject(response_data, "status", "success");
cJSON_AddStringToObject(response_data, "view_name", view_name);
cJSON_AddItemToObject(response_data, "data", results);
app_log(LOG_DEBUG, "ADMIN_EVENT: Query view '%s' returned %d rows", view_name, cJSON_GetArraySize(results));
return 0;
}
/**
* Send Kind 23459 admin response event
*/
static int send_admin_response_event(const char* admin_pubkey, const char* request_id,
cJSON* response_data) {
@@ -407,11 +610,11 @@ static int send_admin_response_event(const char* admin_pubkey, const char* reque
return -1;
}
// Create Kind 23457 response event
// Create Kind 23459 response event
cJSON* response_event = cJSON_CreateObject();
cJSON_AddStringToObject(response_event, "pubkey", server_pubkey);
cJSON_AddNumberToObject(response_event, "created_at", (double)time(NULL));
cJSON_AddNumberToObject(response_event, "kind", 23457);
cJSON_AddNumberToObject(response_event, "kind", 23459);
cJSON_AddStringToObject(response_event, "content", encrypted_response);
// Add tags
@@ -433,7 +636,7 @@ static int send_admin_response_event(const char* admin_pubkey, const char* reque
// Sign the event
cJSON* signed_event = nostr_create_and_sign_event(
23457,
23459,
encrypted_response,
tags,
server_privkey,

62
src/admin_interface.c Normal file
View File

@@ -0,0 +1,62 @@
// Admin interface handler - serves embedded web UI files
#include <stdio.h>
#include <string.h>
#include "ginxsom.h"
#include "admin_interface_embedded.h"
/**
* Serve embedded file with appropriate content type
*/
static void serve_embedded_file(const unsigned char* data, size_t size, const char* content_type) {
printf("Status: 200 OK\r\n");
printf("Content-Type: %s\r\n", content_type);
printf("Content-Length: %zu\r\n", size);
printf("Cache-Control: public, max-age=3600\r\n");
printf("\r\n");
fwrite((void*)data, 1, size, stdout);
fflush(stdout);
}
/**
* Handle admin interface requests
* Serves embedded web UI files from /api path (consistent with c-relay)
*/
void handle_admin_interface_request(const char* path) {
// Normalize path - remove trailing slash
char normalized_path[256];
strncpy(normalized_path, path, sizeof(normalized_path) - 1);
normalized_path[sizeof(normalized_path) - 1] = '\0';
size_t len = strlen(normalized_path);
if (len > 1 && normalized_path[len - 1] == '/') {
normalized_path[len - 1] = '\0';
}
// Route to appropriate embedded file
// All paths use /api/ prefix for consistency with c-relay
if (strcmp(normalized_path, "/api") == 0 || strcmp(normalized_path, "/api/index.html") == 0) {
serve_embedded_file(embedded_index_html, embedded_index_html_size, "text/html; charset=utf-8");
}
else if (strcmp(normalized_path, "/api/index.css") == 0) {
serve_embedded_file(embedded_index_css, embedded_index_css_size, "text/css; charset=utf-8");
}
else if (strcmp(normalized_path, "/api/index.js") == 0) {
serve_embedded_file(embedded_index_js, embedded_index_js_size, "application/javascript; charset=utf-8");
}
else if (strcmp(normalized_path, "/api/nostr-lite.js") == 0) {
serve_embedded_file(embedded_nostr_lite_js, embedded_nostr_lite_js_size, "application/javascript; charset=utf-8");
}
else if (strcmp(normalized_path, "/api/nostr.bundle.js") == 0) {
serve_embedded_file(embedded_nostr_bundle_js, embedded_nostr_bundle_js_size, "application/javascript; charset=utf-8");
}
else if (strcmp(normalized_path, "/api/text_graph.js") == 0) {
serve_embedded_file(embedded_text_graph_js, embedded_text_graph_js_size, "application/javascript; charset=utf-8");
}
else {
// 404 Not Found
printf("Status: 404 Not Found\r\n");
printf("Content-Type: text/html; charset=utf-8\r\n");
printf("\r\n");
printf("<html><body><h1>404 Not Found</h1><p>File not found: %s</p></body></html>\n", normalized_path);
}
}

63278
src/admin_interface_embedded.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -10,8 +10,8 @@
// Version information (auto-updated by build system)
#define VERSION_MAJOR 0
#define VERSION_MINOR 1
#define VERSION_PATCH 12
#define VERSION "v0.1.12"
#define VERSION_PATCH 16
#define VERSION "v0.1.16"
#include <stddef.h>
#include <stdint.h>
@@ -272,9 +272,12 @@ int validate_sha256_format(const char* sha256);
// Admin API request handler
void handle_admin_api_request(const char* method, const char* uri, const char* validated_pubkey, int is_authenticated);
// Admin event handler (Kind 23456/23457)
// Admin event handler (Kind 23458/23459)
void handle_admin_event_request(void);
// Admin interface handler (serves embedded web UI)
void handle_admin_interface_request(const char* path);
// Individual endpoint handlers
void handle_stats_api(void);
void handle_config_get_api(void);

View File

@@ -6,6 +6,7 @@
#define _GNU_SOURCE
#include "ginxsom.h"
#include "relay_client.h"
#include "admin_commands.h"
#include "../nostr_core_lib/nostr_core/nostr_common.h"
#include "../nostr_core_lib/nostr_core/utils.h"
#include <getopt.h>
@@ -247,7 +248,7 @@ int initialize_database(const char *db_path) {
return -1;
}
// Create storage_stats view
// Create storage_stats view (legacy - kept for backward compatibility)
const char *create_view =
"CREATE VIEW IF NOT EXISTS storage_stats AS "
"SELECT "
@@ -267,6 +268,85 @@ int initialize_database(const char *db_path) {
return -1;
}
// Create blob_overview view for admin dashboard
const char *create_overview_view =
"CREATE VIEW IF NOT EXISTS blob_overview AS "
"SELECT "
" COUNT(*) as total_blobs, "
" COALESCE(SUM(size), 0) as total_bytes, "
" MIN(uploaded_at) as first_upload, "
" MAX(uploaded_at) as last_upload "
"FROM blobs;";
rc = sqlite3_exec(db, create_overview_view, NULL, NULL, &err_msg);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to create blob_overview view: %s\n", err_msg);
sqlite3_free(err_msg);
sqlite3_close(db);
return -1;
}
// Create blob_type_distribution view for MIME type statistics
const char *create_type_view =
"CREATE VIEW IF NOT EXISTS blob_type_distribution AS "
"SELECT "
" type as mime_type, "
" COUNT(*) as blob_count, "
" SUM(size) as total_bytes, "
" ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM blobs), 2) as percentage "
"FROM blobs "
"GROUP BY type "
"ORDER BY blob_count DESC;";
rc = sqlite3_exec(db, create_type_view, NULL, NULL, &err_msg);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to create blob_type_distribution view: %s\n", err_msg);
sqlite3_free(err_msg);
sqlite3_close(db);
return -1;
}
// Create blob_time_stats view for time-based statistics
const char *create_time_view =
"CREATE VIEW IF NOT EXISTS blob_time_stats AS "
"SELECT "
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-1 day') THEN 1 END) as blobs_24h, "
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-7 days') THEN 1 END) as blobs_7d, "
" COUNT(CASE WHEN uploaded_at >= strftime('%s', 'now', '-30 days') THEN 1 END) as blobs_30d "
"FROM blobs;";
rc = sqlite3_exec(db, create_time_view, NULL, NULL, &err_msg);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to create blob_time_stats view: %s\n", err_msg);
sqlite3_free(err_msg);
sqlite3_close(db);
return -1;
}
// Create top_uploaders view for pubkey statistics
const char *create_uploaders_view =
"CREATE VIEW IF NOT EXISTS top_uploaders AS "
"SELECT "
" uploader_pubkey, "
" COUNT(*) as blob_count, "
" SUM(size) as total_bytes, "
" ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM blobs), 2) as percentage, "
" MIN(uploaded_at) as first_upload, "
" MAX(uploaded_at) as last_upload "
"FROM blobs "
"WHERE uploader_pubkey IS NOT NULL "
"GROUP BY uploader_pubkey "
"ORDER BY blob_count DESC "
"LIMIT 20;";
rc = sqlite3_exec(db, create_uploaders_view, NULL, NULL, &err_msg);
if (rc != SQLITE_OK) {
fprintf(stderr, "Failed to create top_uploaders view: %s\n", err_msg);
sqlite3_free(err_msg);
sqlite3_close(db);
return -1;
}
fprintf(stderr, "Database schema initialized successfully\n");
}
@@ -2263,6 +2343,16 @@ if (!config_loaded /* && !initialize_server_config() */) {
}
}
// Initialize admin commands system
app_log(LOG_INFO, "Initializing admin commands system...");
int admin_cmd_result = admin_commands_init(g_db_path);
if (admin_cmd_result != 0) {
app_log(LOG_WARN, "Failed to initialize admin commands system (result: %d)", admin_cmd_result);
app_log(LOG_WARN, "Continuing without admin commands functionality");
} else {
app_log(LOG_INFO, "Admin commands system initialized successfully");
}
/////////////////////////////////////////////////////////////////////
// THIS IS WHERE THE REQUESTS ENTER THE FastCGI
/////////////////////////////////////////////////////////////////////
@@ -2303,13 +2393,14 @@ if (!config_loaded /* && !initialize_server_config() */) {
// Special case: Root endpoint is public and doesn't require authentication
if (strcmp(request_method, "GET") == 0 && strcmp(request_uri, "/") == 0) {
// Handle GET / requests - Server info endpoint
// Handle GET / requests - Server info endpoint (NIP-11)
printf("Status: 200 OK\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("Content-Type: application/nostr+json\r\n\r\n");
printf("{\n");
printf(" \"server\": \"ginxsom\",\n");
printf(" \"version\": \"%s\",\n", VERSION);
printf(" \"description\": \"Ginxsom Blossom Server\",\n");
printf(" \"pubkey\": \"%s\",\n", g_blossom_pubkey);
printf(" \"endpoints\": {\n");
printf(" \"blob_get\": \"GET /<sha256>\",\n");
printf(" \"blob_head\": \"HEAD /<sha256>\",\n");
@@ -2371,12 +2462,28 @@ if (!config_loaded /* && !initialize_server_config() */) {
operation = "mirror";
} else if (strcmp(request_method, "PUT") == 0 && strcmp(request_uri, "/report") == 0) {
operation = "report";
} else if (strncmp(request_uri, "/admin", 6) == 0) {
operation = "admin_interface"; // Public static files - no auth required
} else if (strncmp(request_uri, "/api/", 5) == 0) {
operation = "admin";
// Special case: POST /api/admin uses Kind 23456 events for authentication
// Skip centralized validation for these requests
if (strcmp(request_method, "POST") == 0 && strcmp(request_uri, "/api/admin") == 0) {
operation = "admin_event"; // Mark as special case
// Check if this is a static file request or API request
const char *path = request_uri + 5; // Skip "/api/"
int is_static_file = 0;
// Check for static file extensions or root /api path
if (strstr(path, ".html") || strstr(path, ".css") || strstr(path, ".js") ||
strlen(path) == 0 || strcmp(path, "/") == 0) {
is_static_file = 1;
}
if (is_static_file) {
operation = "admin_interface"; // Public static files - no auth required
} else {
operation = "admin";
// Special case: POST /api/admin uses Kind 23458 events for authentication
// Skip centralized validation for these requests
if (strcmp(request_method, "POST") == 0 && strcmp(request_uri, "/api/admin") == 0) {
operation = "admin_event"; // Mark as special case
}
}
} else if (strcmp(request_method, "GET") == 0 && strncmp(request_uri, "/list/", 6) == 0) {
operation = "list";
@@ -2413,6 +2520,8 @@ if (!config_loaded /* && !initialize_server_config() */) {
// Special case: challenge generation failure should be handled by the endpoint
if (strcmp(operation, "challenge") == 0) {
// Let the /auth endpoint handle this - it will generate its own error response
} else if (strcmp(operation, "admin_interface") == 0) {
// Admin interface serves public static files - no auth required
} else if (strcmp(operation, "head") == 0 || strcmp(operation, "head_upload") == 0) {
// HEAD requests might not require auth depending on config - let handler decide
} else if (strcmp(operation, "list") == 0) {
@@ -2420,7 +2529,7 @@ if (!config_loaded /* && !initialize_server_config() */) {
} else if (strcmp(operation, "admin") == 0 && strcmp(request_uri, "/api/health") == 0) {
// Health endpoint is public and doesn't require authentication - let handler decide
} else if (strcmp(operation, "admin_event") == 0) {
// POST /api/admin uses Kind 23456 events - authentication handled by admin_event.c
// POST /api/admin uses Kind 23458 events - authentication handled by admin_event.c
// Skip centralized validation and let the handler validate the event
} else {
// For other operations, validation failure means auth failure
@@ -2517,10 +2626,34 @@ if (!config_loaded /* && !initialize_server_config() */) {
} else if (strcmp(request_method, "POST") == 0 &&
strcmp(request_uri, "/api/admin") == 0) {
// Handle POST /api/admin requests (Kind 23458 admin events)
handle_admin_event_request();
} else if (strncmp(request_uri, "/admin", 6) == 0) {
// Handle admin web interface requests (embedded files)
handle_admin_interface_request(request_uri);
} else if (strncmp(request_uri, "/api/", 5) == 0) {
// Handle admin API requests with pre-validated auth
const char *validated_pubkey = (result.valid && strlen(result.pubkey) == 64) ? result.pubkey : NULL;
handle_admin_api_request(request_method, request_uri, validated_pubkey, result.valid);
// Check if this is a static file request (no auth required) or API request (auth required)
const char *path = request_uri + 5; // Skip "/api/"
int is_static_file = 0;
// Check for static file extensions
if (strstr(path, ".html") || strstr(path, ".css") || strstr(path, ".js") ||
strcmp(request_uri, "/api") == 0 || strcmp(request_uri, "/api/") == 0) {
is_static_file = 1;
}
if (is_static_file) {
// Serve static files without authentication
handle_admin_interface_request(request_uri);
} else {
// Handle admin API requests with pre-validated auth
const char *validated_pubkey = (result.valid && strlen(result.pubkey) == 64) ? result.pubkey : NULL;
handle_admin_api_request(request_method, request_uri, validated_pubkey, result.valid);
}
} else if (strcmp(request_method, "GET") == 0 &&
@@ -2551,13 +2684,14 @@ if (!config_loaded /* && !initialize_server_config() */) {
}
} else if (strcmp(request_method, "GET") == 0 &&
strcmp(request_uri, "/") == 0) {
// Handle GET / requests - Server info endpoint
// Handle GET / requests - Server info endpoint (NIP-11)
printf("Status: 200 OK\r\n");
printf("Content-Type: application/json\r\n\r\n");
printf("Content-Type: application/nostr+json\r\n\r\n");
printf("{\n");
printf(" \"server\": \"ginxsom\",\n");
printf(" \"version\": \"%s\",\n", VERSION);
printf(" \"description\": \"Ginxsom Blossom Server\",\n");
printf(" \"pubkey\": \"%s\",\n", g_blossom_pubkey);
printf(" \"endpoints\": {\n");
printf(" \"blob_get\": \"GET /<sha256>\",\n");
printf(" \"blob_head\": \"HEAD /<sha256>\",\n");

View File

@@ -5,6 +5,7 @@
*/
#include "relay_client.h"
#include "admin_commands.h"
#include "../nostr_core_lib/nostr_core/nostr_core.h"
#include <sqlite3.h>
#include <stdio.h>
@@ -529,7 +530,7 @@ int relay_client_publish_kind10002(void) {
}
}
// Send Kind 23457 admin response event
// Send Kind 23459 admin response event
int relay_client_send_admin_response(const char *recipient_pubkey, const char *response_content) {
if (!g_relay_state.enabled || !g_relay_state.running || !g_relay_state.pool) {
return -1;
@@ -539,7 +540,7 @@ int relay_client_send_admin_response(const char *recipient_pubkey, const char *r
return -1;
}
app_log(LOG_INFO, "Sending Kind 23457 admin response to %s", recipient_pubkey);
app_log(LOG_INFO, "Sending Kind 23459 admin response to %s", recipient_pubkey);
// TODO: Encrypt response_content using NIP-44
// For now, use plaintext (stub implementation)
@@ -560,9 +561,9 @@ int relay_client_send_admin_response(const char *recipient_pubkey, const char *r
return -1;
}
// Create and sign Kind 23457 event
// Create and sign Kind 23459 event
cJSON* event = nostr_create_and_sign_event(
23457, // kind
23459, // kind
encrypted_content, // content
tags, // tags
privkey_bytes, // private key
@@ -572,7 +573,7 @@ int relay_client_send_admin_response(const char *recipient_pubkey, const char *r
cJSON_Delete(tags);
if (!event) {
app_log(LOG_ERROR, "Failed to create Kind 23457 event");
app_log(LOG_ERROR, "Failed to create Kind 23459 event");
return -1;
}
@@ -583,16 +584,16 @@ int relay_client_send_admin_response(const char *recipient_pubkey, const char *r
g_relay_state.relay_count,
event,
on_publish_response,
(void*)"Kind 23457" // user_data to identify event type
(void*)"Kind 23459" // user_data to identify event type
);
cJSON_Delete(event);
if (result == 0) {
app_log(LOG_INFO, "Kind 23457 admin response publish initiated");
app_log(LOG_INFO, "Kind 23459 admin response publish initiated");
return 0;
} else {
app_log(LOG_ERROR, "Failed to initiate Kind 23457 admin response publish");
app_log(LOG_ERROR, "Failed to initiate Kind 23459 admin response publish");
return -1;
}
}
@@ -610,11 +611,11 @@ static void on_publish_response(const char* relay_url, const char* event_id, int
}
}
// Callback for received Kind 23456 admin command events
// Callback for received Kind 23458 admin command events
static void on_admin_command_event(cJSON* event, const char* relay_url, void* user_data) {
(void)user_data;
app_log(LOG_INFO, "Received Kind 23456 admin command from relay: %s", relay_url);
app_log(LOG_INFO, "Received Kind 23458 admin command from relay: %s", relay_url);
// Extract event fields
cJSON* kind_json = cJSON_GetObjectItem(event, "kind");
@@ -632,7 +633,7 @@ static void on_admin_command_event(cJSON* event, const char* relay_url, void* us
const char* encrypted_content = cJSON_GetStringValue(content_json);
const char* event_id = cJSON_GetStringValue(id_json);
if (kind != 23456) {
if (kind != 23458) {
app_log(LOG_WARN, "Unexpected event kind: %d", kind);
return;
}
@@ -645,12 +646,98 @@ static void on_admin_command_event(cJSON* event, const char* relay_url, void* us
app_log(LOG_INFO, "Processing admin command (event ID: %s)", event_id);
// TODO: Decrypt content using NIP-44
// For now, log the encrypted content
app_log(LOG_DEBUG, "Encrypted command content: %s", encrypted_content);
// Convert keys from hex to bytes
unsigned char server_privkey[32];
unsigned char admin_pubkey_bytes[32];
// TODO: Parse and execute command
// TODO: Send response using relay_client_send_admin_response()
if (nostr_hex_to_bytes(g_blossom_seckey, server_privkey, 32) != 0) {
app_log(LOG_ERROR, "Failed to convert server private key from hex");
return;
}
if (nostr_hex_to_bytes(sender_pubkey, admin_pubkey_bytes, 32) != 0) {
app_log(LOG_ERROR, "Failed to convert admin public key from hex");
return;
}
// Decrypt command content using NIP-44
char decrypted_command[4096];
if (admin_decrypt_command(server_privkey, admin_pubkey_bytes, encrypted_content,
decrypted_command, sizeof(decrypted_command)) != 0) {
app_log(LOG_ERROR, "Failed to decrypt admin command");
// Send error response
cJSON* error_response = cJSON_CreateObject();
cJSON_AddStringToObject(error_response, "status", "error");
cJSON_AddStringToObject(error_response, "message", "Failed to decrypt command");
char* error_json = cJSON_PrintUnformatted(error_response);
cJSON_Delete(error_response);
char encrypted_response[4096];
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, error_json,
encrypted_response, sizeof(encrypted_response)) == 0) {
relay_client_send_admin_response(sender_pubkey, encrypted_response);
}
free(error_json);
return;
}
app_log(LOG_DEBUG, "Decrypted command: %s", decrypted_command);
// Parse command JSON
cJSON* command_json = cJSON_Parse(decrypted_command);
if (!command_json) {
app_log(LOG_ERROR, "Failed to parse command JSON");
cJSON* error_response = cJSON_CreateObject();
cJSON_AddStringToObject(error_response, "status", "error");
cJSON_AddStringToObject(error_response, "message", "Invalid JSON format");
char* error_json = cJSON_PrintUnformatted(error_response);
cJSON_Delete(error_response);
char encrypted_response[4096];
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, error_json,
encrypted_response, sizeof(encrypted_response)) == 0) {
relay_client_send_admin_response(sender_pubkey, encrypted_response);
}
free(error_json);
return;
}
// Process command and get response
cJSON* response_json = admin_commands_process(command_json, event_id);
cJSON_Delete(command_json);
if (!response_json) {
app_log(LOG_ERROR, "Failed to process admin command");
response_json = cJSON_CreateObject();
cJSON_AddStringToObject(response_json, "status", "error");
cJSON_AddStringToObject(response_json, "message", "Failed to process command");
}
// Convert response to JSON string
char* response_str = cJSON_PrintUnformatted(response_json);
cJSON_Delete(response_json);
if (!response_str) {
app_log(LOG_ERROR, "Failed to serialize response JSON");
return;
}
// Encrypt and send response
char encrypted_response[4096];
if (admin_encrypt_response(server_privkey, admin_pubkey_bytes, response_str,
encrypted_response, sizeof(encrypted_response)) != 0) {
app_log(LOG_ERROR, "Failed to encrypt admin response");
free(response_str);
return;
}
free(response_str);
if (relay_client_send_admin_response(sender_pubkey, encrypted_response) != 0) {
app_log(LOG_ERROR, "Failed to send admin response");
}
}
// Callback for EOSE (End Of Stored Events) - new signature
@@ -661,18 +748,18 @@ static void on_admin_subscription_eose(cJSON** events, int event_count, void* us
app_log(LOG_INFO, "Received EOSE for admin command subscription");
}
// Subscribe to admin commands (Kind 23456)
// Subscribe to admin commands (Kind 23458)
static int subscribe_to_admin_commands(void) {
if (!g_relay_state.pool) {
return -1;
}
app_log(LOG_INFO, "Subscribing to Kind 23456 admin commands...");
app_log(LOG_INFO, "Subscribing to Kind 23458 admin commands...");
// Create subscription filter for Kind 23456 events addressed to us
// Create subscription filter for Kind 23458 events addressed to us
cJSON* filter = cJSON_CreateObject();
cJSON* kinds = cJSON_CreateArray();
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(23456));
cJSON_AddItemToArray(kinds, cJSON_CreateNumber(23458));
cJSON_AddItemToObject(filter, "kinds", kinds);
cJSON* p_tags = cJSON_CreateArray();

199
tests/23458_test.sh Executable file
View File

@@ -0,0 +1,199 @@
#!/bin/bash
# Simple test for Kind 23458 relay-based admin commands
# Tests config_query command via Nostr relay subscription
set -e
# Configuration
TEST_KEYS_FILE=".test_keys"
RELAY_URL="wss://relay.laantungir.net"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Load test keys
if [[ ! -f "$TEST_KEYS_FILE" ]]; then
log_error "$TEST_KEYS_FILE not found"
exit 1
fi
source "$TEST_KEYS_FILE"
# Check dependencies
for cmd in nak jq websocat; do
if ! command -v $cmd &> /dev/null; then
log_error "$cmd is not installed"
exit 1
fi
done
echo "=== Kind 23458 Admin Command Test ==="
echo ""
log_info "Configuration:"
log_info " Admin Privkey: ${ADMIN_PRIVKEY:0:16}..."
log_info " Server Pubkey: $SERVER_PUBKEY"
log_info " Relay URL: $RELAY_URL"
echo ""
# Test 1: Send config_query command
log_info "Test: Sending config_query command"
echo ""
# Encrypt command with NIP-44
# Command format: ["config_query"]
PLAINTEXT_COMMAND='["config_query"]'
log_info "Encrypting command with NIP-44..."
ENCRYPTED_COMMAND=$(nak encrypt --sec "$ADMIN_PRIVKEY" -p "$SERVER_PUBKEY" "$PLAINTEXT_COMMAND")
if [[ -z "$ENCRYPTED_COMMAND" ]]; then
log_error "Failed to encrypt command"
exit 1
fi
log_success "Command encrypted"
log_info "Encrypted content: ${ENCRYPTED_COMMAND:0:50}..."
echo ""
log_info "Creating Kind 23458 event..."
EVENT=$(nak event -k 23458 \
-c "$ENCRYPTED_COMMAND" \
--tag p="$SERVER_PUBKEY" \
--sec "$ADMIN_PRIVKEY")
if [[ -z "$EVENT" ]]; then
log_error "Failed to create event"
exit 1
fi
log_success "Event created"
echo "$EVENT" | jq .
echo ""
# Step 1: Create pipes for bidirectional communication
log_info "Step 1: Setting up websocat connection..."
SINCE=$(date +%s)
# Create named pipes for input and output
INPUT_PIPE=$(mktemp -u)
OUTPUT_PIPE=$(mktemp -u)
mkfifo "$INPUT_PIPE"
mkfifo "$OUTPUT_PIPE"
# Start websocat in background with bidirectional communication
(websocat "$RELAY_URL" < "$INPUT_PIPE" > "$OUTPUT_PIPE" 2>/dev/null) &
WEBSOCAT_PID=$!
# Open pipes for writing and reading
exec 3>"$INPUT_PIPE" # File descriptor 3 for writing
exec 4<"$OUTPUT_PIPE" # File descriptor 4 for reading
# Give connection time to establish
sleep 1
log_success "WebSocket connection established"
echo ""
# Step 2: Subscribe to Kind 23459 responses
log_info "Step 2: Subscribing to Kind 23459 responses..."
# Create subscription filter
SUBSCRIPTION_FILTER='["REQ","admin-response",{"kinds":[23459],"authors":["'$SERVER_PUBKEY'"],"#p":["'$ADMIN_PUBKEY'"],"since":'$SINCE'}]'
# Send subscription
echo "$SUBSCRIPTION_FILTER" >&3
sleep 1
log_success "Subscription sent"
echo ""
# Step 3: Publish the command event
log_info "Step 3: Publishing Kind 23458 command event..."
# Create EVENT message
EVENT_MSG='["EVENT",'$EVENT']'
# Send event
echo "$EVENT_MSG" >&3
sleep 1
log_success "Event published"
echo ""
# Step 4: Wait for response
log_info "Step 4: Waiting for Kind 23459 response (timeout: 15s)..."
RESPONSE_RECEIVED=0
TIMEOUT=15
START_TIME=$(date +%s)
while [[ $(($(date +%s) - START_TIME)) -lt $TIMEOUT ]]; do
if read -t 1 -r line <&4; then
if [[ -n "$line" ]]; then
# Parse the relay message
MSG_TYPE=$(echo "$line" | jq -r '.[0] // empty' 2>/dev/null)
if [[ "$MSG_TYPE" == "EVENT" ]]; then
# Extract the event (third element in array)
EVENT_DATA=$(echo "$line" | jq '.[2]' 2>/dev/null)
if [[ -n "$EVENT_DATA" ]]; then
log_success "Received Kind 23459 response!"
echo "$EVENT_DATA" | jq .
echo ""
# Extract and decrypt content
ENCRYPTED_CONTENT=$(echo "$EVENT_DATA" | jq -r '.content // empty')
SENDER_PUBKEY=$(echo "$EVENT_DATA" | jq -r '.pubkey // empty')
if [[ -n "$ENCRYPTED_CONTENT" ]] && [[ -n "$SENDER_PUBKEY" ]]; then
log_info "Encrypted response: ${ENCRYPTED_CONTENT:0:50}..."
log_info "Sender pubkey: $SENDER_PUBKEY"
log_info "Decrypting response..."
# Try decryption with error output and timeout
DECRYPT_OUTPUT=$(timeout 5s nak decrypt --sec "$ADMIN_PRIVKEY" -p "$SENDER_PUBKEY" "$ENCRYPTED_CONTENT" 2>&1)
DECRYPT_EXIT=$?
if [[ $DECRYPT_EXIT -eq 0 ]] && [[ -n "$DECRYPT_OUTPUT" ]]; then
log_success "Response decrypted successfully:"
echo "$DECRYPT_OUTPUT" | jq . 2>/dev/null || echo "$DECRYPT_OUTPUT"
RESPONSE_RECEIVED=1
else
log_error "Failed to decrypt response (exit code: $DECRYPT_EXIT)"
if [[ -n "$DECRYPT_OUTPUT" ]]; then
log_error "Decryption error: $DECRYPT_OUTPUT"
fi
fi
fi
break
fi
fi
fi
fi
done
# Cleanup
exec 3>&- # Close write pipe
exec 4<&- # Close read pipe
kill $WEBSOCAT_PID 2>/dev/null
rm -f "$INPUT_PIPE" "$OUTPUT_PIPE"
if [[ $RESPONSE_RECEIVED -eq 0 ]]; then
log_error "No response received within timeout period"
log_info "This could mean:"
log_info " 1. The server didn't receive the command"
log_info " 2. The server received but didn't process the command"
log_info " 3. The response was sent but not received by subscription"
exit 1
fi
echo ""
log_success "Test complete!"
echo ""
log_info "This test uses full NIP-44 encryption for both commands and responses."

View File

@@ -1,7 +1,7 @@
#!/bin/bash
# Ginxsom Admin Event Test Script
# Tests Kind 23456/23457 admin command system with NIP-44 encryption
# Tests Kind 23458/23459 admin command system with NIP-44 encryption
#
# Prerequisites:
# - nak: https://github.com/fiatjaf/nak
@@ -72,12 +72,12 @@ check_dependencies() {
log_success "All dependencies found"
}
# Create NIP-44 encrypted admin command event (Kind 23456)
# Create NIP-44 encrypted admin command event (Kind 23458)
create_admin_command_event() {
local command="$1"
local expiration=$(($(date +%s) + 3600)) # 1 hour from now
log_info "Creating Kind 23456 admin command event..."
log_info "Creating Kind 23458 admin command event..."
log_info "Command: $command"
# For now, we'll create the event structure manually since nak may not support NIP-44 encryption yet
@@ -87,9 +87,9 @@ create_admin_command_event() {
local content="[\"$command\"]"
# Create event with nak
# Kind 23456 = admin command
# Kind 23458 = admin command
# Tags: p = server pubkey, expiration
local event=$(nak event -k 23456 \
local event=$(nak event -k 23458 \
-c "$content" \
--tag p="$SERVER_PUBKEY" \
--tag expiration="$expiration" \
@@ -104,7 +104,7 @@ send_admin_command() {
log_info "=== Testing Admin Command: $command ==="
# Create Kind 23456 event
# Create Kind 23458 event
local event=$(create_admin_command_event "$command")
if [[ -z "$event" ]]; then
@@ -132,10 +132,10 @@ send_admin_command() {
log_success "HTTP $http_code - Response received"
echo "$body" | jq . 2>/dev/null || echo "$body"
# Try to parse as Kind 23457 event
# Try to parse as Kind 23459 event
local kind=$(echo "$body" | jq -r '.kind // empty' 2>/dev/null)
if [[ "$kind" == "23457" ]]; then
log_success "Received Kind 23457 response event"
if [[ "$kind" == "23459" ]]; then
log_success "Received Kind 23459 response event"
local response_content=$(echo "$body" | jq -r '.content // empty' 2>/dev/null)
log_info "Response content (encrypted): $response_content"
# TODO: Decrypt NIP-44 content to see actual response
@@ -174,7 +174,7 @@ test_server_health() {
main() {
echo "=== Ginxsom Admin Event Test Suite ==="
echo "Testing Kind 23456/23457 admin command system"
echo "Testing Kind 23458/23459 admin command system"
echo ""
log_info "Test Configuration:"