# 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
```c
#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
```c
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
```c
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
```c
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:
```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**:
```json
{
"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**:
```json
{
"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**:
```json
{
"max_file_size": "209715200",
"require_auth": "true",
"nip94_enabled": "true",
"cdn_origin": "https://cdn.example.com"
}
```
**Response**:
```json
{
"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**:
```json
{
"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**:
```json
{
"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**
```html
Ginxsom Admin
Ginxsom Admin Dashboard
Loading...
Server Configuration
```
## nginx Configuration
### Updated local-nginx.conf additions:
```nginx
# 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:
```sql
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`](src/admin_api.h) header file
2. Implement [`admin_api.c`](src/admin_api.c) with all endpoint handlers
3. Add minimal routing code to [`main.c`](src/main.c)
4. Create [`admin_test.sh`](tests/admin_test.sh) script with nak/curl testing
5. Update [`local-nginx.conf`](config/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:**
```bash
#!/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:**
```bash
chmod +x tests/admin_test.sh
./tests/admin_test.sh
```
**2. Use existing admin key:**
```bash
export ADMIN_PRIVKEY="your_existing_admin_private_key_here"
./tests/admin_test.sh
```
**3. Manual testing with nak and curl:**
```bash
# 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:**
```bash
# 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.