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 returnoffset(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 statisticsserver_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
- Nostr Authentication: All admin operations require valid Nostr event signatures
- Admin Pubkey Verification: Only events signed by configured admin pubkey are accepted
- Event Validation: Full Nostr event structure and signature verification
- Expiration Enforcement: Admin events must include expiration timestamps
- Input Validation: All config updates validated before DB storage
- SQL Injection Prevention: Prepared statements only
- CORS: Controlled CORS headers for API endpoints
- Rate Limiting: Consider nginx rate limiting for API endpoints
Testing Strategy
- Unit Tests: Test each API endpoint individually
- Integration Tests: Test frontend-backend communication
- Manual Testing: Browser-based UI testing
- Performance Tests: API response times under load
Implementation Steps - API First Approach
- Create
admin_api.hheader file - Implement
admin_api.cwith all endpoint handlers - Add minimal routing code to
main.c - Create
admin_test.shscript with nak/curl testing - Update
local-nginx.conffor API routing - Test each API endpoint with command-line tools
- 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.