Files
ginxsom/WEB_ADMIN_SPECIFICATION.md
Your Name 67154164f1 tests
2025-09-07 10:59:43 -04:00

36 KiB

Ginxsom Web Admin Interface - Technical Specification

Overview

A minimal single-page admin interface for ginxsom server management, built with vanilla JavaScript and C-based API endpoints. Uses Nostr-compliant authentication with admin pubkey verification.

Architecture

┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
│   Web Browser   │───▶│     nginx       │───▶│ ginxsom FastCGI │
│   (admin.html)  │    │  (static files  │    │   + admin_api   │
│                 │    │   + API proxy)  │    │                 │
└─────────────────┘    └─────────────────┘    └─────────────────┘
                                                        │
                                                        ▼
                                              ┌─────────────────┐
                                              │   SQLite DB     │
                                              │ (ginxsom.db)    │
                                              └─────────────────┘

File Structure

src/
├── main.c              # Existing main FastCGI application
├── admin_api.c         # NEW: Admin API endpoint handlers
├── admin_api.h         # NEW: Admin API function declarations
└── ginxsom.h           # Existing shared headers

admin/
├── admin.html          # Single-page admin interface (with inline CSS/JS)
└── README.md           # Setup and usage guide

config/
└── local-nginx.conf    # Updated with admin interface routes

Backend Implementation Details

1. admin_api.h Header File

#ifndef ADMIN_API_H
#define ADMIN_API_H

#include "ginxsom.h"

// Main API request handler
void handle_admin_api_request(const char* method, const char* uri);

// Individual endpoint handlers
void handle_stats_api(void);
void handle_config_get_api(void);
void handle_config_put_api(void);
void handle_files_api(void);
void handle_health_api(void);

// Admin authentication functions
int authenticate_admin_request(const char* auth_header);
int is_admin_enabled(void);
int verify_admin_pubkey(const char* event_pubkey);

// Utility functions
void send_json_response(int status, const char* json_content);
void send_json_error(int status, const char* error, const char* message);
int parse_query_params(const char* query_string, char params[][256], int max_params);

#endif

2. admin_api.c Implementation Structure

API Router Function

void handle_admin_api_request(const char* method, const char* uri) {
    const char* path = uri + 4; // Skip "/api"
    
    // Check if admin interface is enabled
    if (!is_admin_enabled()) {
        send_json_error(503, "admin_disabled", "Admin interface is disabled");
        return;
    }
    
    // Authentication required for all admin operations except health check
    if (strcmp(path, "/health") != 0) {
        const char* auth_header = getenv("HTTP_AUTHORIZATION");
        if (!authenticate_admin_request(auth_header)) {
            send_json_error(401, "admin_auth_required", "Valid admin authentication required");
            return;
        }
    }
    
    if (strcmp(method, "GET") == 0) {
        if (strcmp(path, "/stats") == 0) {
            handle_stats_api();
        } else if (strcmp(path, "/config") == 0) {
            handle_config_get_api();
        } else if (strncmp(path, "/files", 6) == 0) {
            handle_files_api();
        } else if (strcmp(path, "/health") == 0) {
            handle_health_api();
        } else {
            send_json_error(404, "not_found", "API endpoint not found");
        }
    } else if (strcmp(method, "PUT") == 0) {
        if (strcmp(path, "/config") == 0) {
            handle_config_put_api();
        } else {
            send_json_error(405, "method_not_allowed", "Method not allowed");
        }
    } else {
        send_json_error(405, "method_not_allowed", "Method not allowed");
    }
}

Admin Authentication Functions

int authenticate_admin_request(const char* auth_header) {
    if (!auth_header) {
        return 0; // No auth header
    }
    
    // Use existing authentication system with "admin" method
    int auth_result = authenticate_request(auth_header, "admin", NULL);
    if (auth_result != NOSTR_SUCCESS) {
        return 0; // Invalid Nostr event
    }
    
    // Extract pubkey from validated event using existing parser
    char event_json[4096];
    int parse_result = parse_authorization_header(auth_header, event_json, sizeof(event_json));
    if (parse_result != NOSTR_SUCCESS) {
        return 0;
    }
    
    cJSON* event = cJSON_Parse(event_json);
    if (!event) {
        return 0;
    }
    
    cJSON* pubkey_json = cJSON_GetObjectItem(event, "pubkey");
    if (!pubkey_json || !cJSON_IsString(pubkey_json)) {
        cJSON_Delete(event);
        return 0;
    }
    
    const char* event_pubkey = cJSON_GetStringValue(pubkey_json);
    int is_admin = verify_admin_pubkey(event_pubkey);
    
    cJSON_Delete(event);
    return is_admin;
}

int verify_admin_pubkey(const char* event_pubkey) {
    if (!event_pubkey) {
        return 0;
    }
    
    sqlite3* db;
    sqlite3_stmt* stmt;
    int rc, is_admin = 0;
    
    rc = sqlite3_open_v2(DB_PATH, &db, SQLITE_OPEN_READONLY, NULL);
    if (rc) {
        return 0;
    }
    
    const char* sql = "SELECT value FROM server_config WHERE key = 'admin_pubkey'";
    rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
    if (rc == SQLITE_OK) {
        rc = sqlite3_step(stmt);
        if (rc == SQLITE_ROW) {
            const char* admin_pubkey = (const char*)sqlite3_column_text(stmt, 0);
            if (admin_pubkey && strcmp(event_pubkey, admin_pubkey) == 0) {
                is_admin = 1;
            }
        }
        sqlite3_finalize(stmt);
    }
    sqlite3_close(db);
    
    return is_admin;
}

int is_admin_enabled(void) {
    sqlite3* db;
    sqlite3_stmt* stmt;
    int rc, enabled = 0;
    
    rc = sqlite3_open_v2(DB_PATH, &db, SQLITE_OPEN_READONLY, NULL);
    if (rc) {
        return 0; // Default disabled if can't access DB
    }
    
    const char* sql = "SELECT value FROM server_config WHERE key = 'admin_enabled'";
    rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
    if (rc == SQLITE_OK) {
        rc = sqlite3_step(stmt);
        if (rc == SQLITE_ROW) {
            const char* value = (const char*)sqlite3_column_text(stmt, 0);
            enabled = (value && strcmp(value, "true") == 0) ? 1 : 0;
        }
        sqlite3_finalize(stmt);
    }
    sqlite3_close(db);
    
    return enabled;
}

Statistics API Handler

void handle_stats_api(void) {
    sqlite3* db;
    sqlite3_stmt* stmt;
    int rc;
    
    rc = sqlite3_open_v2(DB_PATH, &db, SQLITE_OPEN_READONLY, NULL);
    if (rc) {
        send_json_error(500, "database_error", "Failed to open database");
        return;
    }
    
    // Query storage_stats view
    const char* sql = "SELECT total_blobs, total_bytes, avg_blob_size, "
                      "unique_uploaders, first_upload, last_upload FROM storage_stats";
    
    // ... SQLite query implementation
    // ... JSON response generation
    
    sqlite3_close(db);
}

3. main.c Integration Points

Minimal changes to main.c:

#include "admin_api.h"  // Add at top

// In main() request routing section, add this condition:
} else if (strncmp(request_uri, "/api/", 5) == 0) {
    // Route API calls to admin handlers
    handle_admin_api_request(request_method, request_uri);

API Endpoint Specifications

GET /api/stats

Purpose: Retrieve server statistics and metrics Response:

{
  "status": "success",
  "data": {
    "total_files": 1234,
    "total_bytes": 104857600,
    "total_size_mb": 100.0,
    "unique_uploaders": 56,
    "first_upload": 1693929600,
    "last_upload": 1704067200,
    "avg_file_size": 85049,
    "file_types": {
      "image/png": 45,
      "image/jpeg": 32,
      "application/pdf": 12,
      "other": 8
    }
  }
}

GET /api/config

Purpose: Retrieve current server configuration Response:

{
  "status": "success",
  "data": {
    "max_file_size": "104857600",
    "require_auth": "false",
    "server_name": "ginxsom",
    "nip94_enabled": "true",
    "cdn_origin": "http://localhost:9001",
    "auth_rules_enabled": "false",
    "auth_cache_ttl": "300"
  }
}

PUT /api/config

Purpose: Update server configuration Request Body:

{
  "max_file_size": "209715200",
  "require_auth": "true",
  "nip94_enabled": "true",
  "cdn_origin": "https://cdn.example.com"
}

Response:

{
  "status": "success",
  "message": "Configuration updated successfully",
  "updated_keys": ["max_file_size", "require_auth"]
}

GET /api/files?limit=50&offset=0

Purpose: Retrieve recent files with pagination Parameters:

  • limit (default: 50): Number of files to return
  • offset (default: 0): Pagination offset Response:
{
  "status": "success",
  "data": {
    "files": [
      {
        "sha256": "b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553",
        "size": 184292,
        "type": "application/pdf",
        "uploaded_at": 1725105921,
        "uploader_pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
        "filename": "document.pdf",
        "url": "http://localhost:9001/b1674191a88ec5cdd733e4240a81803105dc412d6c6708d53ab94fc248f4f553.pdf"
      }
    ],
    "total": 1234,
    "limit": 50,
    "offset": 0
  }
}

GET /api/health

Purpose: System health check Response:

{
  "status": "success",
  "data": {
    "database": "connected",
    "blob_directory": "accessible",
    "disk_usage": {
      "total_bytes": 1073741824,
      "used_bytes": 536870912,
      "available_bytes": 536870912,
      "usage_percent": 50.0
    },
    "server_time": 1704067200,
    "uptime": 3600
  }
}

Frontend Implementation Details

Single File Structure (admin.html)

Complete self-contained HTML file with inline CSS and JavaScript

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Ginxsom Admin</title>
    <style>
        /* Inline CSS - clean, minimal styling */
        body { font-family: Arial, sans-serif; margin: 0; padding: 20px; background: #f8f9fa; }
        .stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
        .stat-card { border: 1px solid #ddd; padding: 15px; border-radius: 5px; background: white; }
        .stat-value { font-size: 24px; font-weight: bold; color: #2c3e50; }
        section { margin-bottom: 30px; background: white; padding: 20px; border-radius: 5px; }
        button { padding: 10px 15px; background: #3498db; color: white; border: none; border-radius: 3px; cursor: pointer; }
        button:hover { background: #2980b9; }
        input, select { padding: 8px; margin: 5px 0; border: 1px solid #ddd; border-radius: 3px; width: 100%; max-width: 300px; }
        
        /* Configuration form styles */
        .config-item { margin-bottom: 15px; }
        .config-item label { display: block; font-weight: bold; margin-bottom: 5px; }
        .config-item input[type="checkbox"] { width: auto; margin-right: 8px; }
        
        /* Files list styles */
        .file-item { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
        .file-info { flex-grow: 1; }
        .file-info strong { display: block; margin-bottom: 5px; }
        .file-meta { color: #666; font-size: 12px; }
        .file-actions { display: flex; gap: 10px; }
        .file-actions a { color: #3498db; text-decoration: none; }
        .file-actions button { padding: 5px 10px; font-size: 12px; }
        
        /* Header styles */
        header { margin-bottom: 30px; padding: 20px; background: white; border-radius: 5px; }
        header h1 { margin: 0 0 10px 0; color: #2c3e50; }
        #server-status { color: #666; font-size: 14px; }
        
        /* Notification styles */
        .notification { animation: slideIn 0.3s ease; }
        @keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
    </style>
</head>
<body>
    <header>
        <h1>Ginxsom Admin Dashboard</h1>
        <div id="server-status">Loading...</div>
    </header>
    
    <main>
        <!-- Statistics Section -->
        <section id="stats-section">
            <h2>Server Statistics</h2>
            <div class="stats-grid">
                <div class="stat-card">
                    <h3>Total Files</h3>
                    <div id="total-files" class="stat-value">-</div>
                </div>
                <div class="stat-card">
                    <h3>Storage Used</h3>
                    <div id="storage-used" class="stat-value">-</div>
                </div>
                <div class="stat-card">
                    <h3>Unique Users</h3>
                    <div id="unique-users" class="stat-value">-</div>
                </div>
            </div>
        </section>

        <!-- Configuration Section -->
        <section id="config-section">
            <h2>Server Configuration</h2>
            <div id="config-form">
                <!-- Dynamic configuration form -->
            </div>
            <button id="save-config">Save Configuration</button>
        </section>

        <!-- Recent Files Section -->
        <section id="files-section">
            <h2>Recent Files</h2>
            <div id="files-list">
                <!-- Dynamic file list -->
            </div>
            <button id="load-more-files">Load More</button>
        </section>
    </main>
    
    <script>
        // Inline JavaScript - complete admin functionality
        class GinxsomAdmin {
            constructor() {
                this.apiBase = '/api';
                this.adminPrivKey = null;
                this.adminPubKey = null;
                this.filesOffset = 0;
                this.filesLimit = 50;
                this.init();
            }
            
            async init() {
                // Initialize Nostr key pair for admin authentication
                this.generateNostrKeys();
                
                // Load initial data
                await this.loadHealth();
                await this.loadStatistics();
                await this.loadConfiguration();
                await this.loadRecentFiles();
                
                this.setupEventListeners();
                this.startPeriodicRefresh();
            }
            
            // Nostr cryptography methods
            generateNostrKeys() {
                // Generate secp256k1 key pair for admin operations
                // In production, this should be loaded from secure storage
                const privKey = this.generateRandomHex(32);
                this.adminPrivKey = privKey;
                this.adminPubKey = this.getPublicKey(privKey);
                
                // Display pubkey for admin configuration
                console.log('Admin Public Key:', this.adminPubKey);
                document.getElementById('server-status').innerHTML =
                    `Admin PubKey: ${this.adminPubKey.substring(0, 16)}...`;
            }
            
            generateRandomHex(length) {
                const array = new Uint8Array(length);
                crypto.getRandomValues(array);
                return Array.from(array, b => b.toString(16).padStart(2, '0')).join('');
            }
            
            getPublicKey(privateKey) {
                // Simplified - in production use proper secp256k1 library
                // This is a placeholder for demonstration
                return 'pubkey_' + privateKey.substring(0, 58);
            }
            
            async createNostrEvent(kind, content, tags = []) {
                const event = {
                    kind: kind,
                    created_at: Math.floor(Date.now() / 1000),
                    tags: tags,
                    content: content,
                    pubkey: this.adminPubKey
                };
                
                // Create event ID (SHA256 of serialized event)
                const serialized = JSON.stringify([
                    0, event.pubkey, event.created_at, event.kind,
                    event.tags, event.content
                ]);
                event.id = await this.sha256(serialized);
                
                // Sign event (placeholder - use proper secp256k1 in production)
                event.sig = 'signature_placeholder_' + this.adminPrivKey.substring(0, 40);
                
                return event;
            }
            
            async sha256(message) {
                const msgUint8 = new TextEncoder().encode(message);
                const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);
                const hashArray = Array.from(new Uint8Array(hashBuffer));
                return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
            }
            
            // API communication methods
            async apiCall(endpoint, method = 'GET', data = null) {
                const url = `${this.apiBase}${endpoint}`;
                const options = {
                    method: method,
                    headers: {
                        'Content-Type': 'application/json',
                    }
                };
                
                // Add Nostr authentication for non-health endpoints using exact same format as existing system
                if (endpoint !== '/health') {
                    const authEvent = await this.createNostrEvent(24242, 'admin_request', [
                        ['t', method],           // Method tag (consistent with blossom spec)
                        ['expiration', String(Math.floor(Date.now() / 1000) + 3600)]
                    ]);
                    
                    // Use exact same format as existing ginxsom auth: "Nostr <base64-encoded-event>"
                    options.headers['Authorization'] = `Nostr ${btoa(JSON.stringify(authEvent))}`;
                }
                
                if (data) {
                    options.body = JSON.stringify(data);
                }
                
                try {
                    const response = await fetch(url, options);
                    const result = await response.json();
                    
                    if (!response.ok) {
                        throw new Error(result.message || `HTTP ${response.status}`);
                    }
                    
                    return result;
                } catch (error) {
                    console.error(`API call failed: ${method} ${endpoint}`, error);
                    this.showError(`API Error: ${error.message}`);
                    throw error;
                }
            }
            
            // Data loading methods
            async loadHealth() {
                try {
                    const result = await this.apiCall('/health');
                    document.getElementById('server-status').innerHTML =
                        `Server: ${result.data.database} | Uptime: ${result.data.uptime}s`;
                } catch (error) {
                    document.getElementById('server-status').innerHTML = 'Server: Offline';
                }
            }
            
            async loadStatistics() {
                try {
                    const result = await this.apiCall('/stats');
                    this.updateStatistics(result.data);
                } catch (error) {
                    this.showError('Failed to load statistics');
                }
            }
            
            async loadConfiguration() {
                try {
                    const result = await this.apiCall('/config');
                    this.renderConfigurationForm(result.data);
                } catch (error) {
                    this.showError('Failed to load configuration');
                }
            }
            
            async loadRecentFiles(offset = 0) {
                try {
                    const result = await this.apiCall(`/files?limit=${this.filesLimit}&offset=${offset}`);
                    this.renderFilesList(result.data, offset === 0);
                    this.filesOffset = offset;
                } catch (error) {
                    this.showError('Failed to load recent files');
                }
            }
            
            // UI update methods
            updateStatistics(stats) {
                document.getElementById('total-files').textContent = stats.total_files.toLocaleString();
                document.getElementById('storage-used').textContent =
                    `${stats.total_size_mb.toFixed(1)} MB`;
                document.getElementById('unique-users').textContent = stats.unique_uploaders;
            }
            
            renderConfigurationForm(config) {
                const formHtml = Object.entries(config).map(([key, value]) => {
                    if (key === 'admin_pubkey' || key === 'admin_enabled') {
                        return ''; // Skip admin-only config in UI
                    }
                    
                    const inputType = key.includes('size') ? 'number' : 'text';
                    const isBoolean = value === 'true' || value === 'false';
                    
                    if (isBoolean) {
                        return `
                            <div class="config-item">
                                <label>
                                    <input type="checkbox" name="${key}" ${value === 'true' ? 'checked' : ''}>
                                    ${key.replace(/_/g, ' ').toUpperCase()}
                                </label>
                            </div>
                        `;
                    } else {
                        return `
                            <div class="config-item">
                                <label for="${key}">${key.replace(/_/g, ' ').toUpperCase()}:</label>
                                <input type="${inputType}" name="${key}" value="${value}">
                            </div>
                        `;
                    }
                }).join('');
                
                document.getElementById('config-form').innerHTML = formHtml;
            }
            
            renderFilesList(filesData, replace = true) {
                const filesHtml = filesData.files.map(file => `
                    <div class="file-item">
                        <div class="file-info">
                            <strong>${file.filename || 'unnamed'}</strong>
                            <span class="file-meta">
                                ${(file.size / 1024).toFixed(1)}KB |
                                ${file.type} |
                                ${new Date(file.uploaded_at * 1000).toLocaleDateString()}
                            </span>
                        </div>
                        <div class="file-actions">
                            <a href="${file.url}" target="_blank">View</a>
                            <button onclick="admin.copyToClipboard('${file.sha256}')">Copy Hash</button>
                        </div>
                    </div>
                `).join('');
                
                const container = document.getElementById('files-list');
                if (replace) {
                    container.innerHTML = filesHtml;
                } else {
                    container.innerHTML += filesHtml;
                }
                
                // Show/hide load more button
                const loadMoreBtn = document.getElementById('load-more-files');
                loadMoreBtn.style.display =
                    filesData.files.length === this.filesLimit ? 'block' : 'none';
            }
            
            // Event handlers
            setupEventListeners() {
                // Save configuration
                document.getElementById('save-config').addEventListener('click', async () => {
                    await this.saveConfiguration();
                });
                
                // Load more files
                document.getElementById('load-more-files').addEventListener('click', async () => {
                    await this.loadRecentFiles(this.filesOffset + this.filesLimit);
                });
                
                // Global reference for inline handlers
                window.admin = this;
            }
            
            async saveConfiguration() {
                const form = document.getElementById('config-form');
                const formData = new FormData(form);
                const config = {};
                
                // Collect form data
                for (const [key, value] of formData.entries()) {
                    const input = form.querySelector(`[name="${key}"]`);
                    if (input.type === 'checkbox') {
                        config[key] = input.checked ? 'true' : 'false';
                    } else {
                        config[key] = value;
                    }
                }
                
                try {
                    await this.apiCall('/config', 'PUT', config);
                    this.showSuccess('Configuration updated successfully');
                } catch (error) {
                    this.showError('Failed to save configuration');
                }
            }
            
            // Utility methods
            copyToClipboard(text) {
                navigator.clipboard.writeText(text).then(() => {
                    this.showSuccess('Copied to clipboard');
                });
            }
            
            showError(message) {
                this.showNotification(message, 'error');
            }
            
            showSuccess(message) {
                this.showNotification(message, 'success');
            }
            
            showNotification(message, type) {
                // Create temporary notification
                const notification = document.createElement('div');
                notification.className = `notification ${type}`;
                notification.textContent = message;
                notification.style.cssText = `
                    position: fixed; top: 20px; right: 20px; z-index: 1000;
                    padding: 10px 15px; border-radius: 5px; color: white;
                    background: ${type === 'error' ? '#e74c3c' : '#27ae60'};
                `;
                
                document.body.appendChild(notification);
                
                setTimeout(() => {
                    notification.remove();
                }, 5000);
            }
            
            startPeriodicRefresh() {
                // Refresh statistics every 30 seconds
                setInterval(async () => {
                    await this.loadHealth();
                    await this.loadStatistics();
                }, 30000);
            }
        }

        // Initialize admin interface
        document.addEventListener('DOMContentLoaded', () => {
            new GinxsomAdmin();
        });
    </script>
</body>
</html>

nginx Configuration

Updated local-nginx.conf additions:

# Admin interface (single file)
location /admin {
    alias admin/;
    try_files $uri /admin.html;
}

# Admin API endpoints
location /api/ {
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root/ginxsom.fcgi;
    fastcgi_pass fastcgi_backend;
    
    # CORS headers for admin interface
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods "GET, PUT, OPTIONS";
    add_header Access-Control-Allow-Headers "Content-Type";
}

Database Schema Requirements

Existing tables used:

  • blobs - File metadata and statistics
  • server_config - Configuration key-value pairs (ENHANCED)
  • storage_stats - Aggregated statistics view

Required server_config additions:

INSERT OR IGNORE INTO server_config (key, value, description) VALUES
    ('admin_pubkey', '', 'Nostr public key authorized for admin operations'),
    ('admin_enabled', 'false', 'Enable admin interface (requires admin_pubkey)');

Security Considerations

  1. Nostr Authentication: All admin operations require valid Nostr event signatures
  2. Admin Pubkey Verification: Only events signed by configured admin pubkey are accepted
  3. Event Validation: Full Nostr event structure and signature verification
  4. Expiration Enforcement: Admin events must include expiration timestamps
  5. Input Validation: All config updates validated before DB storage
  6. SQL Injection Prevention: Prepared statements only
  7. CORS: Controlled CORS headers for API endpoints
  8. Rate Limiting: Consider nginx rate limiting for API endpoints

Testing Strategy

  1. Unit Tests: Test each API endpoint individually
  2. Integration Tests: Test frontend-backend communication
  3. Manual Testing: Browser-based UI testing
  4. Performance Tests: API response times under load

Implementation Steps - API First Approach

  1. Create admin_api.h header file
  2. Implement admin_api.c with all endpoint handlers
  3. Add minimal routing code to main.c
  4. Create admin_test.sh script with nak/curl testing
  5. Update local-nginx.conf for API routing
  6. Test each API endpoint with command-line tools
  7. Document admin API usage with practical examples

Testing Strategy - Command Line First

Admin Test Script (admin_test.sh)

Complete test script for admin API using nak and curl:

#!/bin/bash

# Ginxsom Admin API Test Script
# Tests admin API endpoints using nak (for Nostr events) and curl
#
# Prerequisites:
# - nak: https://github.com/fiatjaf/nak
# - curl, jq
# - Admin pubkey configured in ginxsom server_config

set -e

# Configuration
GINXSOM_URL="http://localhost:9001"
ADMIN_PRIVKEY="${ADMIN_PRIVKEY:-}"
ADMIN_PUBKEY=""

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Helper functions
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }

check_dependencies() {
    log_info "Checking dependencies..."
    for cmd in nak curl jq; do
        if ! command -v $cmd &> /dev/null; then
            log_error "$cmd is not installed"
            exit 1
        fi
    done
    log_success "All dependencies found"
}

generate_admin_keys() {
    if [[ -z "$ADMIN_PRIVKEY" ]]; then
        log_info "Generating new admin key pair..."
        ADMIN_PRIVKEY=$(nak key generate)
        log_warning "Generated admin private key: $ADMIN_PRIVKEY"
        log_warning "Save this key: export ADMIN_PRIVKEY='$ADMIN_PRIVKEY'"
    fi
    
    ADMIN_PUBKEY=$(echo "$ADMIN_PRIVKEY" | nak key public)
    log_info "Admin public key: $ADMIN_PUBKEY"
}

create_admin_event() {
    local method="$1"
    local content="admin_request"
    local expiration=$(($(date +%s) + 3600))  # 1 hour from now
    
    # Create Nostr event with nak
    local event=$(nak event -k 24242 -c "$content" \
        --tag t="$method" \
        --tag expiration="$expiration" \
        --sec "$ADMIN_PRIVKEY")
    
    echo "$event"
}

send_admin_request() {
    local method="$1"
    local endpoint="$2"
    local data="$3"
    
    log_info "Testing $method $endpoint"
    
    # Create authenticated Nostr event
    local event=$(create_admin_event "$method")
    local auth_header="Nostr $(echo "$event" | base64 -w 0)"
    
    # Send request with curl
    local curl_args=(-s -w "%{http_code}" -H "Authorization: $auth_header")
    
    if [[ "$method" == "PUT" && -n "$data" ]]; then
        curl_args+=(-H "Content-Type: application/json" -d "$data")
    fi
    
    local response=$(curl "${curl_args[@]}" -X "$method" "$GINXSOM_URL$endpoint")
    local http_code="${response: -3}"
    local body="${response%???}"
    
    if [[ "$http_code" =~ ^2 ]]; then
        log_success "$method $endpoint - HTTP $http_code"
        if [[ -n "$body" ]]; then
            echo "$body" | jq . 2>/dev/null || echo "$body"
        fi
    else
        log_error "$method $endpoint - HTTP $http_code"
        echo "$body" | jq . 2>/dev/null || echo "$body"
    fi
    
    return $([[ "$http_code" =~ ^2 ]])
}

test_health_endpoint() {
    log_info "=== Testing Health Endpoint (no auth required) ==="
    local response=$(curl -s -w "%{http_code}" "$GINXSOM_URL/api/health")
    local http_code="${response: -3}"
    local body="${response%???}"
    
    if [[ "$http_code" =~ ^2 ]]; then
        log_success "GET /api/health - HTTP $http_code"
        echo "$body" | jq .
    else
        log_error "GET /api/health - HTTP $http_code"
        echo "$body"
    fi
}

test_stats_endpoint() {
    log_info "=== Testing Statistics Endpoint ==="
    send_admin_request "GET" "/api/stats"
}

test_config_endpoints() {
    log_info "=== Testing Configuration Endpoints ==="
    
    # Get current config
    send_admin_request "GET" "/api/config"
    
    # Update config
    local config_update='{
        "max_file_size": "209715200",
        "require_auth": "true",
        "nip94_enabled": "true"
    }'
    
    send_admin_request "PUT" "/api/config" "$config_update"
    
    # Get config again to verify
    send_admin_request "GET" "/api/config"
}

test_files_endpoint() {
    log_info "=== Testing Files Endpoint ==="
    send_admin_request "GET" "/api/files?limit=10&offset=0"
}

configure_server_admin() {
    log_warning "=== Server Configuration Required ==="
    log_warning "Add the following to your ginxsom database:"
    log_warning ""
    log_warning "sqlite3 db/ginxsom.db << EOF"
    log_warning "INSERT OR REPLACE INTO server_config (key, value, description) VALUES"
    log_warning "    ('admin_pubkey', '$ADMIN_PUBKEY', 'Admin authorized pubkey'),"
    log_warning "    ('admin_enabled', 'true', 'Enable admin interface');"
    log_warning "EOF"
    log_warning ""
    log_warning "Then restart ginxsom server."
}

main() {
    echo "=== Ginxsom Admin API Test Suite ==="
    echo ""
    
    check_dependencies
    generate_admin_keys
    configure_server_admin
    
    echo ""
    read -p "Press Enter after configuring the server..."
    echo ""
    
    # Test endpoints
    test_health_endpoint
    echo ""
    test_stats_endpoint
    echo ""
    test_config_endpoints
    echo ""
    test_files_endpoint
    echo ""
    
    log_success "Admin API testing complete!"
    log_info "Admin pubkey for server config: $ADMIN_PUBKEY"
}

# Allow sourcing for individual function testing
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
    main "$@"
fi

Usage Examples

1. Run complete test suite:

chmod +x tests/admin_test.sh
./tests/admin_test.sh

2. Use existing admin key:

export ADMIN_PRIVKEY="your_existing_admin_private_key_here"
./tests/admin_test.sh

3. Manual testing with nak and curl:

# Generate admin event
ADMIN_PRIVKEY="your_private_key"
EVENT=$(nak event -k 24242 -c "admin_request" \
    --tag t="GET" \
    --tag expiration="$(date -d '+1 hour' +%s)" \
    --sec "$ADMIN_PRIVKEY")

# Send authenticated request
AUTH_HEADER="Nostr $(echo "$EVENT" | base64 -w 0)"
curl -H "Authorization: $AUTH_HEADER" http://localhost:9001/api/stats

4. Configure server for admin access:

# Add admin pubkey to database
ADMIN_PUBKEY="your_admin_public_key"
sqlite3 db/ginxsom.db << EOF
INSERT OR REPLACE INTO server_config (key, value, description) VALUES
    ('admin_pubkey', '$ADMIN_PUBKEY', 'Admin authorized pubkey'),
    ('admin_enabled', 'true', 'Enable admin interface');
EOF

Benefits of API-First Approach

  • Immediate testing capability with command-line tools
  • Foundation for Nostr relay integration in the future
  • Proper authentication testing using real Nostr events
  • Easy debugging with verbose curl output
  • Reusable components for future web interface
  • Command-line administration without web dependencies

This specification provides complete implementation details focused on API-first development with robust command-line testing tools.