1088 lines
36 KiB
Markdown
1088 lines
36 KiB
Markdown
# 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
|
|
<!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:
|
|
```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. |