Implemented comprehensive database query tracking to identify clients causing high CPU usage through excessive database queries. The relay now tracks and displays query statistics per WebSocket connection in the admin UI. Features Added: - Track db_queries_executed and db_rows_returned per connection - Calculate query rate (queries/minute) and row rate (rows/minute) - Display stats in admin UI grouped by IP address and WebSocket - Show: IP, Subscriptions, Queries, Rows, Query Rate, Duration Implementation: - Added tracking fields to per_session_data structure - Increment counters in handle_req_message() and handle_count_message() - Extract stats from pss in query_subscription_details() - Updated admin UI to display IP address and query metrics Use Case: Admins can now identify abusive clients by monitoring: - High query rates (>50 queries/min indicates polling abuse) - High row counts (>10K rows/min indicates broad filter abuse) - Query patterns (high queries + low rows = targeted, high both = crawler) This enables informed decisions about which IPs to blacklist based on actual resource consumption rather than just connection count.
5769 lines
212 KiB
JavaScript
5769 lines
212 KiB
JavaScript
// Global error handler to prevent page refreshes
|
|
window.addEventListener('error', function (e) {
|
|
console.error('Global error caught:', e.error);
|
|
console.error('Error message:', e.message);
|
|
console.error('Error filename:', e.filename);
|
|
console.error('Error line:', e.lineno);
|
|
e.preventDefault(); // Prevent default browser error handling
|
|
return true; // Prevent page refresh
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', function (e) {
|
|
console.error('Unhandled promise rejection:', e.reason);
|
|
e.preventDefault(); // Prevent default browser error handling
|
|
return true; // Prevent page refresh
|
|
});
|
|
|
|
// Global state
|
|
let nlLite = null;
|
|
let userPubkey = null;
|
|
let isLoggedIn = false;
|
|
let currentConfig = null;
|
|
// Global subscription state
|
|
let relayPool = null;
|
|
let subscriptionId = null;
|
|
let isSubscribed = false; // Flag to prevent multiple simultaneous subscriptions
|
|
let isSubscribing = false; // Flag to prevent re-entry during subscription setup
|
|
// Relay connection state
|
|
let relayInfo = null;
|
|
let isRelayConnected = false;
|
|
let relayPubkey = null;
|
|
// Simple relay URL object (replaces DOM element)
|
|
let relayConnectionUrl = { value: '' };
|
|
// Database statistics auto-refresh
|
|
let statsAutoRefreshInterval = null;
|
|
let countdownInterval = null;
|
|
let countdownSeconds = 10;
|
|
|
|
// Side navigation state
|
|
let currentPage = 'statistics'; // Default page
|
|
let sideNavOpen = false;
|
|
|
|
// SQL Query state
|
|
let pendingSqlQueries = new Map();
|
|
|
|
// Real-time event rate chart
|
|
let eventRateChart = null;
|
|
let previousTotalEvents = 0; // Track previous total for rate calculation
|
|
|
|
// Relay Events state - now handled by main subscription
|
|
|
|
// DOM elements
|
|
const loginModal = document.getElementById('login-modal');
|
|
const loginModalContainer = document.getElementById('login-modal-container');
|
|
const profileArea = document.getElementById('profile-area');
|
|
const headerUserImage = document.getElementById('header-user-image');
|
|
const headerUserName = document.getElementById('header-user-name');
|
|
|
|
// Legacy elements (kept for backward compatibility)
|
|
const persistentUserName = document.getElementById('persistent-user-name');
|
|
const persistentUserPubkey = document.getElementById('persistent-user-pubkey');
|
|
const persistentUserAbout = document.getElementById('persistent-user-about');
|
|
const persistentUserDetails = document.getElementById('persistent-user-details');
|
|
const fetchConfigBtn = document.getElementById('fetch-config-btn');
|
|
const configDisplay = document.getElementById('config-display');
|
|
const configTableBody = document.getElementById('config-table-body');
|
|
|
|
// NIP-17 DM elements
|
|
const dmOutbox = document.getElementById('dm-outbox');
|
|
const dmInbox = document.getElementById('dm-inbox');
|
|
const sendDmBtn = document.getElementById('send-dm-btn');
|
|
|
|
// Utility functions
|
|
function log(message, type = 'INFO') {
|
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
const logMessage = `${timestamp} [${type}]: ${message}`;
|
|
|
|
// Always log to browser console so we don't lose logs on refresh
|
|
console.log(logMessage);
|
|
|
|
// UI logging removed - using console only
|
|
}
|
|
|
|
// Utility functions
|
|
function log(message, type = 'INFO') {
|
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
const logMessage = `${timestamp} [${type}]: ${message}`;
|
|
|
|
// Always log to browser console so we don't lose logs on refresh
|
|
console.log(logMessage);
|
|
|
|
// UI logging removed - using console only
|
|
}
|
|
|
|
|
|
// NIP-59 helper: randomize created_at to thwart time-analysis (past 2 days)
|
|
// TEMPORARILY DISABLED: Using current timestamp for debugging
|
|
function randomNow() {
|
|
// const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
|
|
const now = Math.round(Date.now() / 1000);
|
|
return now; // Math.round(now - Math.random() * TWO_DAYS);
|
|
}
|
|
|
|
// Safe JSON parse with error handling
|
|
function safeJsonParse(jsonString) {
|
|
try {
|
|
return JSON.parse(jsonString);
|
|
} catch (error) {
|
|
console.error('JSON parse error:', error);
|
|
return null;
|
|
}
|
|
}
|
|
// ================================
|
|
// NIP-11 RELAY CONNECTION FUNCTIONS
|
|
// ================================
|
|
|
|
// Convert WebSocket URL to HTTP URL for NIP-11
|
|
function wsToHttpUrl(wsUrl) {
|
|
if (wsUrl.startsWith('ws://')) {
|
|
return wsUrl.replace('ws://', 'http://');
|
|
} else if (wsUrl.startsWith('wss://')) {
|
|
return wsUrl.replace('wss://', 'https://');
|
|
}
|
|
return wsUrl;
|
|
}
|
|
|
|
// Fetch relay information using NIP-11
|
|
async function fetchRelayInfo(relayUrl) {
|
|
try {
|
|
log(`Fetching NIP-11 relay info from: ${relayUrl}`, 'INFO');
|
|
|
|
// Convert WebSocket URL to HTTP URL
|
|
const httpUrl = wsToHttpUrl(relayUrl);
|
|
|
|
// Make HTTP request with NIP-11 headers
|
|
const response = await fetch(httpUrl, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/nostr+json',
|
|
'User-Agent': 'C-Relay-Admin-API/1.0'
|
|
},
|
|
timeout: 10000 // 10 second timeout
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const contentType = response.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/nostr+json')) {
|
|
throw new Error(`Invalid content type: ${contentType}. Expected application/nostr+json`);
|
|
}
|
|
|
|
const relayInfo = await response.json();
|
|
|
|
// Log if relay info is empty (not configured yet) but don't throw error
|
|
if (!relayInfo || Object.keys(relayInfo).length === 0) {
|
|
log('Relay returned empty NIP-11 info - relay not configured yet, will use manual pubkey if provided', 'INFO');
|
|
// Return empty object - this is valid, caller will handle manual pubkey fallback
|
|
return {};
|
|
}
|
|
|
|
// Validate pubkey if present
|
|
if (relayInfo.pubkey && !/^[0-9a-fA-F]{64}$/.test(relayInfo.pubkey)) {
|
|
throw new Error(`Invalid relay pubkey format: ${relayInfo.pubkey}`);
|
|
}
|
|
|
|
log(`Successfully fetched relay info. Pubkey: ${relayInfo.pubkey ? relayInfo.pubkey.substring(0, 16) + '...' : 'not set'}`, 'INFO');
|
|
return relayInfo;
|
|
|
|
} catch (error) {
|
|
log(`Failed to fetch relay info: ${error.message}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Test WebSocket connection to relay
|
|
async function testWebSocketConnection(wsUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
log(`Testing WebSocket connection to: ${wsUrl}`, 'INFO');
|
|
|
|
const ws = new WebSocket(wsUrl);
|
|
const timeout = setTimeout(() => {
|
|
ws.close();
|
|
reject(new Error('WebSocket connection timeout (10s)'));
|
|
}, 10000);
|
|
|
|
ws.onopen = () => {
|
|
clearTimeout(timeout);
|
|
log('WebSocket connection successful', 'INFO');
|
|
ws.close();
|
|
resolve(true);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
clearTimeout(timeout);
|
|
log(`WebSocket connection failed: ${error.message || 'Unknown error'}`, 'ERROR');
|
|
reject(new Error('WebSocket connection failed'));
|
|
};
|
|
|
|
ws.onclose = (event) => {
|
|
if (event.code !== 1000) { // 1000 = normal closure
|
|
clearTimeout(timeout);
|
|
reject(new Error(`WebSocket closed unexpectedly: ${event.code} ${event.reason}`));
|
|
}
|
|
};
|
|
|
|
} catch (error) {
|
|
log(`WebSocket test error: ${error.message}`, 'ERROR');
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check for existing authentication state with multiple API methods and retry logic
|
|
async function checkExistingAuthWithRetries() {
|
|
console.log('Starting authentication state detection with retry logic...');
|
|
|
|
const maxAttempts = 3;
|
|
const delay = 200; // ms between attempts (reduced from 500ms)
|
|
|
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
console.log(`Authentication detection attempt ${attempt}/${maxAttempts}`);
|
|
|
|
try {
|
|
// Method 1: Try window.NOSTR_LOGIN_LITE.getAuthState()
|
|
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.getAuthState === 'function') {
|
|
console.log('Trying window.NOSTR_LOGIN_LITE.getAuthState()...');
|
|
const authState = window.NOSTR_LOGIN_LITE.getAuthState();
|
|
if (authState && authState.pubkey) {
|
|
console.log('✅ Auth state found via NOSTR_LOGIN_LITE.getAuthState():', authState.pubkey);
|
|
await restoreAuthenticationState(authState.pubkey);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Method 2: Try nlLite.getPublicKey()
|
|
if (nlLite && typeof nlLite.getPublicKey === 'function') {
|
|
console.log('Trying nlLite.getPublicKey()...');
|
|
const pubkey = await nlLite.getPublicKey();
|
|
if (pubkey && pubkey.length === 64) {
|
|
console.log('✅ Pubkey found via nlLite.getPublicKey():', pubkey);
|
|
await restoreAuthenticationState(pubkey);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Method 3: Try window.nostr.getPublicKey() (NIP-07)
|
|
if (window.nostr && typeof window.nostr.getPublicKey === 'function') {
|
|
console.log('Trying window.nostr.getPublicKey()...');
|
|
const pubkey = await window.nostr.getPublicKey();
|
|
if (pubkey && pubkey.length === 64) {
|
|
console.log('✅ Pubkey found via window.nostr.getPublicKey():', pubkey);
|
|
await restoreAuthenticationState(pubkey);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Method 4: Check localStorage directly for NOSTR_LOGIN_LITE data
|
|
const localStorageData = localStorage.getItem('NOSTR_LOGIN_LITE_DATA');
|
|
if (localStorageData) {
|
|
try {
|
|
const parsedData = JSON.parse(localStorageData);
|
|
if (parsedData.pubkey) {
|
|
console.log('✅ Pubkey found in localStorage:', parsedData.pubkey);
|
|
await restoreAuthenticationState(parsedData.pubkey);
|
|
return true;
|
|
}
|
|
} catch (parseError) {
|
|
console.log('Failed to parse localStorage data:', parseError.message);
|
|
}
|
|
}
|
|
|
|
console.log(`❌ Attempt ${attempt}: No authentication found via any method`);
|
|
|
|
// Wait before next attempt (except for last attempt)
|
|
if (attempt < maxAttempts) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`❌ Attempt ${attempt} failed:`, error.message);
|
|
if (attempt < maxAttempts) {
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('🔍 Authentication detection completed - no existing auth found after all attempts');
|
|
return false;
|
|
}
|
|
|
|
// Helper function to restore authentication state
|
|
async function restoreAuthenticationState(pubkey) {
|
|
console.log('🔄 Restoring authentication state for pubkey:', pubkey);
|
|
|
|
userPubkey = pubkey;
|
|
isLoggedIn = true;
|
|
|
|
// Show main interface and profile in header
|
|
showProfileInHeader();
|
|
loadUserProfile();
|
|
|
|
// Automatically set up relay connection (but don't show admin sections yet)
|
|
await setupAutomaticRelayConnection();
|
|
|
|
console.log('✅ Authentication state restored successfully');
|
|
}
|
|
|
|
// Automatically set up relay connection based on current page URL
|
|
async function setupAutomaticRelayConnection(showSections = false) {
|
|
console.log('=== SETUP AUTOMATIC RELAY CONNECTION CALLED ===');
|
|
console.log('Call stack:', new Error().stack);
|
|
console.log('showSections:', showSections);
|
|
console.log('Current isRelayConnected:', isRelayConnected);
|
|
console.log('Current relayPool:', relayPool ? 'EXISTS' : 'NULL');
|
|
console.log('Current isSubscribed:', isSubscribed);
|
|
|
|
try {
|
|
// Get the current page URL and convert to WebSocket URL
|
|
const currentUrl = window.location.href;
|
|
let relayUrl = '';
|
|
|
|
if (currentUrl.startsWith('https://')) {
|
|
relayUrl = currentUrl.replace('https://', 'wss://');
|
|
} else if (currentUrl.startsWith('http://')) {
|
|
relayUrl = currentUrl.replace('http://', 'ws://');
|
|
} else {
|
|
// Fallback for development
|
|
relayUrl = 'ws://localhost:8888';
|
|
}
|
|
|
|
// Remove any path components to get just the base URL
|
|
// CRITICAL: Always add trailing slash for consistent URL format
|
|
const url = new URL(relayUrl);
|
|
relayUrl = `${url.protocol}//${url.host}/`;
|
|
|
|
// Set the relay URL
|
|
relayConnectionUrl.value = relayUrl;
|
|
|
|
console.log('🔗 Auto-setting relay URL to:', relayUrl);
|
|
|
|
// Fetch relay info to get pubkey
|
|
try {
|
|
const httpUrl = relayUrl.replace('ws', 'http').replace('wss', 'https');
|
|
const relayInfo = await fetchRelayInfo(httpUrl);
|
|
|
|
if (relayInfo && relayInfo.pubkey) {
|
|
relayPubkey = relayInfo.pubkey;
|
|
console.log('🔑 Auto-fetched relay pubkey:', relayPubkey.substring(0, 16) + '...');
|
|
} else {
|
|
// Use fallback pubkey
|
|
relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
|
|
console.log('⚠️ Using fallback relay pubkey');
|
|
}
|
|
} catch (error) {
|
|
console.log('⚠️ Could not fetch relay info, using fallback pubkey:', error.message);
|
|
relayPubkey = '4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa';
|
|
}
|
|
|
|
// Set up subscription to receive admin API responses
|
|
// Note: subscribeToConfiguration() will create the SimplePool internally
|
|
await subscribeToConfiguration();
|
|
console.log('📡 Subscription established for admin API responses');
|
|
|
|
// Mark as connected
|
|
isRelayConnected = true;
|
|
|
|
// Update relay info in header
|
|
updateRelayInfoInHeader();
|
|
|
|
// Only show admin sections if explicitly requested
|
|
if (showSections) {
|
|
updateAdminSectionsVisibility();
|
|
}
|
|
|
|
console.log('✅ Automatic relay connection setup complete');
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to setup automatic relay connection:', error);
|
|
// Still mark as connected to allow basic functionality
|
|
isRelayConnected = true;
|
|
if (showSections) {
|
|
updateAdminSectionsVisibility();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Legacy function for backward compatibility
|
|
async function checkExistingAuth() {
|
|
return await checkExistingAuthWithRetries();
|
|
}
|
|
|
|
// Initialize NOSTR_LOGIN_LITE
|
|
async function initializeApp() {
|
|
try {
|
|
await window.NOSTR_LOGIN_LITE.init({
|
|
theme: 'default',
|
|
methods: {
|
|
extension: true,
|
|
local: true,
|
|
seedphrase: true,
|
|
readonly: true,
|
|
connect: true,
|
|
remote: true,
|
|
otp: false
|
|
},
|
|
floatingTab: {
|
|
enabled: false
|
|
}
|
|
});
|
|
|
|
nlLite = window.NOSTR_LOGIN_LITE;
|
|
console.log('Nostr login system initialized');
|
|
|
|
// Check for existing authentication state after initialization
|
|
const wasAlreadyLoggedIn = await checkExistingAuth();
|
|
if (wasAlreadyLoggedIn) {
|
|
console.log('User was already logged in, showing profile in header');
|
|
showProfileInHeader();
|
|
// Show admin sections since user is already authenticated and relay is connected
|
|
updateAdminSectionsVisibility();
|
|
} else {
|
|
console.log('No existing authentication found, showing login modal');
|
|
showLoginModal();
|
|
}
|
|
|
|
// Listen for authentication events
|
|
window.addEventListener('nlMethodSelected', handleAuthEvent);
|
|
window.addEventListener('nlLogout', handleLogoutEvent);
|
|
|
|
} catch (error) {
|
|
console.log('Failed to initialize Nostr login: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Handle authentication events
|
|
function handleAuthEvent(event) {
|
|
const { pubkey, method, error } = event.detail;
|
|
|
|
if (method && pubkey) {
|
|
userPubkey = pubkey;
|
|
isLoggedIn = true;
|
|
console.log(`Login successful! Method: ${method}`);
|
|
console.log(`Public key: ${pubkey}`);
|
|
|
|
// Hide login modal and show profile in header
|
|
hideLoginModal();
|
|
showProfileInHeader();
|
|
loadUserProfile();
|
|
|
|
// Automatically set up relay connection and show admin sections
|
|
setupAutomaticRelayConnection(true);
|
|
|
|
// Auto-enable monitoring when admin logs in
|
|
autoEnableMonitoring();
|
|
|
|
} else if (error) {
|
|
console.log(`Authentication error: ${error}`);
|
|
}
|
|
}
|
|
|
|
// Handle logout events
|
|
function handleLogoutEvent() {
|
|
console.log('Logout event received');
|
|
|
|
userPubkey = null;
|
|
isLoggedIn = false;
|
|
currentConfig = null;
|
|
|
|
// Reset relay connection state
|
|
isRelayConnected = false;
|
|
relayPubkey = null;
|
|
|
|
// Reset UI - hide profile and show login modal
|
|
hideProfileFromHeader();
|
|
showLoginModal();
|
|
|
|
updateConfigStatus(false);
|
|
updateAdminSectionsVisibility();
|
|
|
|
console.log('Logout event handled successfully');
|
|
}
|
|
|
|
|
|
// Update visibility of admin sections based on login and relay connection status
|
|
function updateAdminSectionsVisibility() {
|
|
const shouldShow = isLoggedIn && isRelayConnected;
|
|
|
|
// If logged in and connected, show the current page, otherwise hide all sections
|
|
if (shouldShow) {
|
|
// Show the current page
|
|
switchPage(currentPage);
|
|
|
|
// Load data for the current page
|
|
loadCurrentPageData();
|
|
} else {
|
|
// Hide all sections when not logged in or not connected
|
|
const sections = [
|
|
'databaseStatisticsSection',
|
|
'subscriptionDetailsSection',
|
|
'div_config',
|
|
'authRulesSection',
|
|
'nip17DMSection',
|
|
'sqlQuerySection'
|
|
];
|
|
|
|
sections.forEach(sectionId => {
|
|
const section = document.getElementById(sectionId);
|
|
if (section) {
|
|
section.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
stopStatsAutoRefresh();
|
|
}
|
|
|
|
// Update countdown display when visibility changes
|
|
updateCountdownDisplay();
|
|
}
|
|
|
|
// Load data for the current page
|
|
function loadCurrentPageData() {
|
|
switch (currentPage) {
|
|
case 'statistics':
|
|
// Load statistics immediately (no auto-refresh - using real-time monitoring events)
|
|
sendStatsQuery().catch(error => {
|
|
console.log('Auto-fetch statistics failed: ' + error.message);
|
|
});
|
|
break;
|
|
case 'configuration':
|
|
// Load configuration
|
|
fetchConfiguration().catch(error => {
|
|
console.log('Auto-fetch configuration failed: ' + error.message);
|
|
});
|
|
break;
|
|
case 'authorization':
|
|
// Load auth rules
|
|
loadAuthRules().catch(error => {
|
|
console.log('Auto-load auth rules failed: ' + error.message);
|
|
});
|
|
break;
|
|
// Other pages don't need initial data loading
|
|
}
|
|
}
|
|
|
|
// Show login modal
|
|
function showLoginModal() {
|
|
if (loginModal && loginModalContainer) {
|
|
// Initialize the login UI in the modal
|
|
if (window.NOSTR_LOGIN_LITE && typeof window.NOSTR_LOGIN_LITE.embed === 'function') {
|
|
window.NOSTR_LOGIN_LITE.embed('#login-modal-container', {
|
|
seamless: true
|
|
});
|
|
}
|
|
loginModal.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// Hide login modal
|
|
function hideLoginModal() {
|
|
if (loginModal) {
|
|
loginModal.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Show profile in header
|
|
function showProfileInHeader() {
|
|
if (profileArea) {
|
|
profileArea.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// Hide profile from header
|
|
function hideProfileFromHeader() {
|
|
if (profileArea) {
|
|
profileArea.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Update login/logout UI visibility (legacy function - kept for backward compatibility)
|
|
function updateLoginLogoutUI() {
|
|
// This function is now handled by showProfileInHeader() and hideProfileFromHeader()
|
|
// Kept for backward compatibility with any existing code that might call it
|
|
}
|
|
|
|
// Show main interface after login (legacy function - kept for backward compatibility)
|
|
function showMainInterface() {
|
|
// This function is now handled by showProfileInHeader() and updateAdminSectionsVisibility()
|
|
// Kept for backward compatibility with any existing code that might call it
|
|
updateAdminSectionsVisibility();
|
|
}
|
|
|
|
// Load user profile using nostr-tools pool
|
|
async function loadUserProfile() {
|
|
if (!userPubkey) return;
|
|
|
|
console.log('Loading user profile...');
|
|
|
|
// Update header display (new system)
|
|
if (headerUserName) {
|
|
headerUserName.textContent = 'Loading...';
|
|
}
|
|
|
|
// Update legacy elements if they exist (backward compatibility)
|
|
if (persistentUserName) {
|
|
persistentUserName.textContent = 'Loading...';
|
|
}
|
|
if (persistentUserAbout) {
|
|
persistentUserAbout.textContent = 'Loading...';
|
|
}
|
|
|
|
// Convert hex pubkey to npub for initial display
|
|
let displayPubkey = userPubkey;
|
|
let npubLink = '';
|
|
try {
|
|
if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) {
|
|
const npub = window.NostrTools.nip19.npubEncode(userPubkey);
|
|
displayPubkey = npub;
|
|
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to encode user pubkey to npub:', error.message);
|
|
}
|
|
|
|
if (persistentUserPubkey) {
|
|
if (npubLink) {
|
|
persistentUserPubkey.innerHTML = npubLink;
|
|
} else {
|
|
persistentUserPubkey.textContent = displayPubkey;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Create a SimplePool instance for profile loading
|
|
const profilePool = new window.NostrTools.SimplePool();
|
|
const relays = ['wss://relay.damus.io',
|
|
'wss://relay.nostr.band',
|
|
'wss://nos.lol',
|
|
'wss://relay.primal.net',
|
|
'wss://relay.snort.social'
|
|
];
|
|
|
|
// Get profile event (kind 0) for the user with timeout
|
|
const timeoutPromise = new Promise((_, reject) =>
|
|
setTimeout(() => reject(new Error('Profile query timeout')), 5000)
|
|
);
|
|
|
|
const queryPromise = profilePool.querySync(relays, {
|
|
kinds: [0],
|
|
authors: [userPubkey],
|
|
limit: 1
|
|
});
|
|
|
|
const events = await Promise.race([queryPromise, timeoutPromise]);
|
|
|
|
if (events.length > 0) {
|
|
console.log('Profile event found:', events[0]);
|
|
const profile = JSON.parse(events[0].content);
|
|
console.log('Parsed profile:', profile);
|
|
displayProfile(profile);
|
|
} else {
|
|
console.log('No profile events found for pubkey:', userPubkey);
|
|
|
|
// Update header display (new system)
|
|
if (headerUserName) {
|
|
headerUserName.textContent = 'Anonymous User';
|
|
}
|
|
|
|
// Update legacy elements if they exist (backward compatibility)
|
|
if (persistentUserName) {
|
|
persistentUserName.textContent = 'Anonymous User';
|
|
}
|
|
if (persistentUserAbout) {
|
|
persistentUserAbout.textContent = 'No profile found';
|
|
}
|
|
// Keep the npub display
|
|
}
|
|
|
|
// Properly close the profile pool with error handling
|
|
try {
|
|
await profilePool.close(relays);
|
|
// Give time for cleanup
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
} catch (closeError) {
|
|
console.log('Profile pool close error (non-critical):', closeError.message);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log('Profile loading failed: ' + error.message);
|
|
|
|
// Update header display (new system)
|
|
if (headerUserName) {
|
|
headerUserName.textContent = 'Error loading profile';
|
|
}
|
|
|
|
// Update legacy elements if they exist (backward compatibility)
|
|
if (persistentUserName) {
|
|
persistentUserName.textContent = 'Error loading profile';
|
|
}
|
|
if (persistentUserAbout) {
|
|
persistentUserAbout.textContent = error.message;
|
|
}
|
|
// Keep the npub display
|
|
}
|
|
}
|
|
|
|
// Display profile data
|
|
function displayProfile(profile) {
|
|
const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User';
|
|
const about = profile.about || 'No description provided';
|
|
const picture = profile.picture || profile.image || null;
|
|
|
|
// Convert hex pubkey to npub for display
|
|
let displayPubkey = userPubkey;
|
|
let npubLink = '';
|
|
try {
|
|
if (userPubkey && userPubkey.length === 64 && /^[0-9a-fA-F]+$/.test(userPubkey)) {
|
|
const npub = window.NostrTools.nip19.npubEncode(userPubkey);
|
|
displayPubkey = npub;
|
|
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to encode user pubkey to npub:', error.message);
|
|
}
|
|
|
|
// Update header profile display
|
|
if (headerUserName) {
|
|
headerUserName.textContent = name;
|
|
}
|
|
|
|
// Handle header profile picture
|
|
if (headerUserImage) {
|
|
if (picture && typeof picture === 'string' && (picture.startsWith('http') || picture.startsWith('https'))) {
|
|
headerUserImage.src = picture;
|
|
headerUserImage.style.display = 'block';
|
|
headerUserImage.onerror = function() {
|
|
// Hide image on error
|
|
this.style.display = 'none';
|
|
console.log('Profile image failed to load:', picture);
|
|
};
|
|
} else {
|
|
headerUserImage.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// Update legacy persistent user details (kept for backward compatibility)
|
|
if (persistentUserName) persistentUserName.textContent = name;
|
|
if (persistentUserPubkey && npubLink) {
|
|
persistentUserPubkey.innerHTML = npubLink;
|
|
} else if (persistentUserPubkey) {
|
|
persistentUserPubkey.textContent = displayPubkey;
|
|
}
|
|
if (persistentUserAbout) persistentUserAbout.textContent = about;
|
|
|
|
// Handle legacy profile picture
|
|
const userImageContainer = document.getElementById('persistent-user-image');
|
|
if (userImageContainer) {
|
|
if (picture && typeof picture === 'string' && picture.startsWith('http')) {
|
|
// Create or update image element
|
|
let img = userImageContainer.querySelector('img');
|
|
if (!img) {
|
|
img = document.createElement('img');
|
|
img.className = 'user-profile-image';
|
|
img.alt = `${name}'s profile picture`;
|
|
img.onerror = function() {
|
|
// Hide image on error
|
|
this.style.display = 'none';
|
|
};
|
|
userImageContainer.appendChild(img);
|
|
}
|
|
img.src = picture;
|
|
img.style.display = 'block';
|
|
} else {
|
|
// Hide image if no valid picture
|
|
const img = userImageContainer.querySelector('img');
|
|
if (img) {
|
|
img.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`Profile loaded for: ${name} with pubkey: ${userPubkey}`);
|
|
}
|
|
|
|
// Logout function
|
|
async function logout() {
|
|
log('Logging out...', 'INFO');
|
|
try {
|
|
// Stop auto-refresh before disconnecting
|
|
stopStatsAutoRefresh();
|
|
|
|
// Clean up relay pool
|
|
if (relayPool) {
|
|
log('Closing relay pool...', 'INFO');
|
|
const url = relayConnectionUrl.value.trim();
|
|
if (url) {
|
|
try {
|
|
await relayPool.close([url]);
|
|
} catch (e) {
|
|
console.log('Pool close error (non-critical):', e.message);
|
|
}
|
|
}
|
|
relayPool = null;
|
|
subscriptionId = null;
|
|
}
|
|
|
|
// Reset subscription flags
|
|
isSubscribed = false;
|
|
isSubscribing = false;
|
|
|
|
await nlLite.logout();
|
|
|
|
userPubkey = null;
|
|
isLoggedIn = false;
|
|
currentConfig = null;
|
|
|
|
// Reset relay connection state
|
|
isRelayConnected = false;
|
|
relayPubkey = null;
|
|
|
|
// Reset UI - hide profile and show login modal
|
|
hideProfileFromHeader();
|
|
|
|
updateConfigStatus(false);
|
|
updateAdminSectionsVisibility();
|
|
|
|
log('Logged out successfully', 'INFO');
|
|
} catch (error) {
|
|
log('Logout failed: ' + error.message, 'ERROR');
|
|
}
|
|
}
|
|
|
|
function updateConfigStatus(loaded) {
|
|
if (loaded) {
|
|
configDisplay.classList.remove('hidden');
|
|
} else {
|
|
configDisplay.classList.add('hidden');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Generate random subscription ID (avoiding colons which are rejected by relay)
|
|
function generateSubId() {
|
|
// Use only alphanumeric characters, underscores, hyphens, and commas
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-,';
|
|
let result = '';
|
|
for (let i = 0; i < 12; i++) {
|
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// WebSocket monitoring function to attach to SimplePool connections
|
|
function attachWebSocketMonitoring(relayPool, url) {
|
|
console.log('🔍 Attaching WebSocket monitoring to SimplePool...');
|
|
|
|
// SimplePool stores connections in _conn object
|
|
if (relayPool && relayPool._conn) {
|
|
// Monitor when connections are created
|
|
const originalGetConnection = relayPool._conn[url];
|
|
if (originalGetConnection) {
|
|
console.log('📡 Found existing connection for URL:', url);
|
|
|
|
// Try to access the WebSocket if it's available
|
|
const conn = relayPool._conn[url];
|
|
if (conn && conn.ws) {
|
|
attachWebSocketEventListeners(conn.ws, url);
|
|
}
|
|
}
|
|
|
|
// Override the connection getter to monitor new connections
|
|
const originalConn = relayPool._conn;
|
|
relayPool._conn = new Proxy(originalConn, {
|
|
get(target, prop) {
|
|
const conn = target[prop];
|
|
if (conn && conn.ws && !conn.ws._monitored) {
|
|
console.log('🔗 New WebSocket connection detected for:', prop);
|
|
attachWebSocketEventListeners(conn.ws, prop);
|
|
conn.ws._monitored = true;
|
|
}
|
|
return conn;
|
|
},
|
|
set(target, prop, value) {
|
|
if (value && value.ws && !value.ws._monitored) {
|
|
console.log('🔗 WebSocket connection being set for:', prop);
|
|
attachWebSocketEventListeners(value.ws, prop);
|
|
value.ws._monitored = true;
|
|
}
|
|
target[prop] = value;
|
|
return true;
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('✅ WebSocket monitoring attached');
|
|
}
|
|
|
|
function attachWebSocketEventListeners(ws, url) {
|
|
console.log(`🎯 Attaching event listeners to WebSocket for ${url}`);
|
|
|
|
// Log connection open
|
|
ws.addEventListener('open', (event) => {
|
|
console.log(`🔓 WebSocket OPEN for ${url}:`, {
|
|
readyState: ws.readyState,
|
|
url: ws.url,
|
|
protocol: ws.protocol,
|
|
extensions: ws.extensions
|
|
});
|
|
});
|
|
|
|
// Log incoming messages with full details
|
|
ws.addEventListener('message', (event) => {
|
|
try {
|
|
const data = event.data;
|
|
console.log(`📨 WebSocket MESSAGE from ${url}:`, {
|
|
type: event.type,
|
|
data: data,
|
|
dataLength: data.length,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Try to parse as JSON for Nostr messages
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
if (Array.isArray(parsed)) {
|
|
const [type, ...args] = parsed;
|
|
console.log(`📨 Parsed Nostr message [${type}]:`, args);
|
|
} else {
|
|
console.log(`📨 Parsed JSON:`, parsed);
|
|
}
|
|
} catch (parseError) {
|
|
console.log(`📨 Raw message (not JSON):`, data);
|
|
}
|
|
} catch (error) {
|
|
console.error(`❌ Error processing WebSocket message from ${url}:`, error);
|
|
}
|
|
});
|
|
|
|
// Log connection close with details
|
|
ws.addEventListener('close', (event) => {
|
|
console.log(`🔒 WebSocket CLOSE for ${url}:`, {
|
|
code: event.code,
|
|
reason: event.reason,
|
|
wasClean: event.wasClean,
|
|
readyState: ws.readyState,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
});
|
|
|
|
// Log errors with full details
|
|
ws.addEventListener('error', (event) => {
|
|
console.error(`❌ WebSocket ERROR for ${url}:`, {
|
|
type: event.type,
|
|
target: event.target,
|
|
readyState: ws.readyState,
|
|
url: ws.url,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Log additional WebSocket state
|
|
console.error(`❌ WebSocket state details:`, {
|
|
readyState: ws.readyState,
|
|
bufferedAmount: ws.bufferedAmount,
|
|
protocol: ws.protocol,
|
|
extensions: ws.extensions,
|
|
binaryType: ws.binaryType
|
|
});
|
|
});
|
|
|
|
// Override send method to log outgoing messages
|
|
const originalSend = ws.send;
|
|
ws.send = function(data) {
|
|
console.log(`📤 WebSocket SEND to ${url}:`, {
|
|
data: data,
|
|
dataLength: data.length,
|
|
readyState: ws.readyState,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
// Try to parse outgoing Nostr messages
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
if (Array.isArray(parsed)) {
|
|
const [type, ...args] = parsed;
|
|
console.log(`📤 Outgoing Nostr message [${type}]:`, args);
|
|
} else {
|
|
console.log(`📤 Outgoing JSON:`, parsed);
|
|
}
|
|
} catch (parseError) {
|
|
console.log(`📤 Outgoing raw message (not JSON):`, data);
|
|
}
|
|
|
|
return originalSend.call(this, data);
|
|
};
|
|
|
|
console.log(`✅ Event listeners attached to WebSocket for ${url}`);
|
|
}
|
|
|
|
// Configuration subscription using nostr-tools SimplePool
|
|
async function subscribeToConfiguration() {
|
|
try {
|
|
console.log('=== SUBSCRIBE TO CONFIGURATION ===');
|
|
console.log('Call stack:', new Error().stack);
|
|
|
|
// If pool already exists and subscribed, we're done
|
|
if (relayPool && isSubscribed) {
|
|
console.log('✅ Already subscribed, reusing existing pool');
|
|
return true;
|
|
}
|
|
|
|
// Prevent concurrent subscription attempts
|
|
if (isSubscribing) {
|
|
console.log('⚠️ Subscription already in progress');
|
|
return false;
|
|
}
|
|
|
|
isSubscribing = true;
|
|
|
|
const url = relayConnectionUrl.value.trim();
|
|
if (!url) {
|
|
console.error('No relay URL configured');
|
|
isSubscribing = false;
|
|
return false;
|
|
}
|
|
|
|
console.log(`🔌 Connecting to relay: ${url}`);
|
|
|
|
// Create pool ONLY if it doesn't exist
|
|
if (!relayPool) {
|
|
console.log('✨ Creating NEW SimplePool for admin operations');
|
|
relayPool = new window.NostrTools.SimplePool();
|
|
|
|
// Attach WebSocket monitoring to the new pool
|
|
attachWebSocketMonitoring(relayPool, url);
|
|
} else {
|
|
console.log('♻️ Reusing existing SimplePool');
|
|
}
|
|
|
|
subscriptionId = generateSubId();
|
|
|
|
console.log(`📝 Generated subscription ID: ${subscriptionId}`);
|
|
console.log(`👤 User pubkey: ${userPubkey}`);
|
|
console.log(`🎯 About to call relayPool.subscribeMany with URL: ${url}`);
|
|
console.log(`📊 relayPool._conn before subscribeMany:`, Object.keys(relayPool._conn || {}));
|
|
|
|
// Mark as subscribed BEFORE calling subscribeMany to prevent race conditions
|
|
isSubscribed = true;
|
|
|
|
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), kind 1059 (NIP-17 GiftWrap), kind 24567 (ephemeral monitoring events), and relay events (kinds 0, 10050, 10002)
|
|
console.log('🔔 Calling relayPool.subscribeMany with all filters...');
|
|
const subscription = relayPool.subscribeMany([url], [{
|
|
since: Math.floor(Date.now() / 1000) - 5, // Look back 5 seconds to avoid race condition
|
|
kinds: [23457],
|
|
authors: [getRelayPubkey()], // Only listen to responses from the relay
|
|
"#p": [userPubkey], // Only responses directed to this user
|
|
limit: 50
|
|
}, {
|
|
since: Math.floor(Date.now() / 1000),
|
|
kinds: [4], // NIP-04 Direct Messages
|
|
authors: [getRelayPubkey()], // Only listen to DMs from the relay
|
|
"#p": [userPubkey], // Only DMs directed to this user
|
|
limit: 50
|
|
}, {
|
|
since: Math.floor(Date.now() / 1000) - (2 * 24 * 60 * 60), // Look back 2 days for NIP-59 randomized timestamps
|
|
kinds: [1059], // NIP-17 GiftWrap events
|
|
"#p": [userPubkey], // Only GiftWrap events addressed to this user
|
|
limit: 50
|
|
}, {
|
|
since: Math.floor(Date.now() / 1000), // Start from current time
|
|
kinds: [24567], // Real-time ephemeral monitoring events
|
|
authors: [getRelayPubkey()], // Only listen to monitoring events from the relay
|
|
"#d": isLoggedIn ? ["event_kinds", "time_stats", "top_pubkeys", "subscription_details", "cpu_metrics"] : ["event_kinds", "time_stats", "top_pubkeys", "cpu_metrics"], // Include subscription_details only when authenticated, cpu_metrics available to all
|
|
limit: 50
|
|
}, {
|
|
since: Math.floor(Date.now() / 1000) - (24 * 60 * 60), // Look back 24 hours for relay events
|
|
kinds: [0, 10050, 10002], // Relay events: metadata, DM relays, relay list
|
|
authors: [getRelayPubkey()], // Only listen to relay's own events
|
|
limit: 10
|
|
}], {
|
|
async onevent(event) {
|
|
// Simplified logging - one line per event
|
|
if (event.kind === 24567) {
|
|
const dTag = event.tags.find(tag => tag[0] === 'd');
|
|
const dataType = dTag ? dTag[1] : 'unknown';
|
|
// console.log(`📊 Monitoring event: ${dataType}`);
|
|
} else {
|
|
console.log(`📨 Event received: kind ${event.kind}`);
|
|
}
|
|
|
|
// Handle NIP-04 DMs
|
|
if (event.kind === 4) {
|
|
try {
|
|
// Decrypt the DM content
|
|
const decryptedContent = await window.nostr.nip04.decrypt(event.pubkey, event.content);
|
|
log(`Received NIP-04 DM from relay: ${decryptedContent.substring(0, 50)}...`, 'INFO');
|
|
|
|
// Add to inbox
|
|
const timestamp = new Date(event.created_at * 1000).toLocaleString();
|
|
addMessageToInbox('received', decryptedContent, timestamp, event.pubkey);
|
|
|
|
// Log for testing
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `NIP-04 DM: ${decryptedContent}`, 'DM');
|
|
}
|
|
} catch (decryptError) {
|
|
log(`Failed to decrypt NIP-04 DM: ${decryptError.message}`, 'ERROR');
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Failed to decrypt DM: ${decryptError.message}`, 'DM');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle NIP-17 GiftWrap DMs
|
|
if (event.kind === 1059) {
|
|
console.log(`📨 RECEIVED KIND 1059 EVENT:`, {
|
|
id: event.id,
|
|
pubkey: event.pubkey,
|
|
created_at: event.created_at,
|
|
content: event.content.substring(0, 100) + '...',
|
|
tags: event.tags
|
|
});
|
|
|
|
try {
|
|
// Step 1: Unwrap gift wrap to get seal
|
|
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
|
|
console.log(`🔓 STEP 1 - Unwrapped gift wrap:`, sealJson.substring(0, 100) + '...');
|
|
const seal = safeJsonParse(sealJson);
|
|
if (!seal || seal.kind !== 13) {
|
|
throw new Error('Unwrapped content is not a valid seal (kind 13)');
|
|
}
|
|
console.log(`✅ Seal validated:`, { kind: seal.kind, pubkey: seal.pubkey.substring(0, 16) + '...' });
|
|
|
|
// Step 2: Unseal to get rumor
|
|
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
|
|
console.log(`🔓 STEP 2 - Unsealed rumor:`, rumorJson.substring(0, 100) + '...');
|
|
const rumor = safeJsonParse(rumorJson);
|
|
if (!rumor || rumor.kind !== 14) {
|
|
throw new Error('Unsealed content is not a valid rumor (kind 14)');
|
|
}
|
|
console.log(`✅ Rumor validated:`, { kind: rumor.kind, pubkey: rumor.pubkey.substring(0, 16) + '...', content: rumor.content.substring(0, 50) + '...' });
|
|
|
|
log(`Received NIP-17 DM from relay: ${rumor.content.substring(0, 50)}...`, 'INFO');
|
|
|
|
// Add to inbox
|
|
const timestamp = new Date(event.created_at * 1000).toLocaleString();
|
|
addMessageToInbox('received', rumor.content, timestamp, rumor.pubkey);
|
|
|
|
// Log for testing
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `NIP-17 DM: ${rumor.content}`, 'DM');
|
|
}
|
|
} catch (unwrapError) {
|
|
console.error(`❌ NIP-17 DM UNWRAP FAILED:`, unwrapError);
|
|
log(`Failed to unwrap NIP-17 DM: ${unwrapError.message}`, 'ERROR');
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Failed to unwrap DM: ${unwrapError.message}`, 'DM');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle admin response events (kind 23457)
|
|
if (event.kind === 23457) {
|
|
// Log all received messages for testing
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Admin response event: ${JSON.stringify(event)}`, 'EVENT');
|
|
}
|
|
|
|
// Process admin response event
|
|
processAdminResponse(event);
|
|
}
|
|
|
|
// Handle monitoring events (kind 24567 - ephemeral)
|
|
if (event.kind === 24567) {
|
|
// Process monitoring event (logging done above)
|
|
processMonitoringEvent(event);
|
|
}
|
|
|
|
// Handle relay events (kinds 0, 10050, 10002)
|
|
if ([0, 10050, 10002].includes(event.kind)) {
|
|
handleRelayEventReceived(event);
|
|
}
|
|
},
|
|
oneose() {
|
|
console.log('EOSE received - End of stored events');
|
|
console.log('Current config after EOSE:', currentConfig);
|
|
|
|
if (!currentConfig) {
|
|
console.log('No configuration events were received');
|
|
}
|
|
},
|
|
onclose(reason) {
|
|
console.log('Subscription closed:', reason);
|
|
// Reset subscription state to allow re-subscription
|
|
isSubscribed = false;
|
|
isSubscribing = false;
|
|
isRelayConnected = false;
|
|
updateConfigStatus(false);
|
|
log('WebSocket connection closed - subscription state reset', 'WARNING');
|
|
}
|
|
});
|
|
|
|
// Store subscription for cleanup
|
|
relayPool.currentSubscription = subscription;
|
|
|
|
// Mark as subscribed
|
|
isSubscribed = true;
|
|
isSubscribing = false;
|
|
|
|
console.log('✅ Subscription established successfully');
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error('Configuration subscription failed:', error.message);
|
|
console.error('Configuration subscription failed:', error);
|
|
console.error('Error stack:', error.stack);
|
|
isSubscribing = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Process admin response events (kind 23457)
|
|
async function processAdminResponse(event) {
|
|
try {
|
|
console.log('=== PROCESSING ADMIN RESPONSE ===');
|
|
console.log('Response event:', event);
|
|
|
|
// Verify this is a kind 23457 admin response event
|
|
if (event.kind !== 23457) {
|
|
console.log('Ignoring non-admin response event, kind:', event.kind);
|
|
return;
|
|
}
|
|
|
|
// Verify the event is from the relay
|
|
const expectedRelayPubkey = getRelayPubkey();
|
|
if (event.pubkey !== expectedRelayPubkey) {
|
|
console.log('Ignoring response from unknown pubkey:', event.pubkey);
|
|
return;
|
|
}
|
|
|
|
// Decrypt the NIP-44 encrypted content
|
|
const decryptedContent = await decryptFromRelay(event.content);
|
|
if (!decryptedContent) {
|
|
throw new Error('Failed to decrypt admin response content');
|
|
}
|
|
|
|
// console.log('Decrypted admin response:', decryptedContent);
|
|
|
|
// Try to parse as JSON first, if it fails treat as plain text
|
|
let responseData;
|
|
try {
|
|
responseData = JSON.parse(decryptedContent);
|
|
console.log('Parsed response data:', responseData);
|
|
} catch (parseError) {
|
|
// Not JSON - treat as plain text response
|
|
console.log('Response is plain text, not JSON');
|
|
responseData = {
|
|
plain_text: true,
|
|
message: decryptedContent
|
|
};
|
|
}
|
|
|
|
// Log the response for testing
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Decrypted response: ${JSON.stringify(responseData)}`, 'RESPONSE');
|
|
}
|
|
|
|
// Handle different types of admin responses
|
|
handleAdminResponseData(responseData);
|
|
|
|
} catch (error) {
|
|
console.error('Error processing admin response:', error);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Failed to process admin response: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize real-time event rate chart
|
|
function initializeEventRateChart() {
|
|
try {
|
|
console.log('=== INITIALIZING EVENT RATE CHART ===');
|
|
|
|
const chartContainer = document.getElementById('event-rate-chart');
|
|
console.log('Chart container found:', chartContainer);
|
|
|
|
if (!chartContainer) {
|
|
console.log('Event rate chart container not found');
|
|
return;
|
|
}
|
|
|
|
// Show immediate placeholder content
|
|
chartContainer.textContent = 'Initializing event rate chart...';
|
|
console.log('Set placeholder content');
|
|
|
|
// Check if ASCIIBarChart is available
|
|
console.log('Checking ASCIIBarChart availability...');
|
|
console.log('typeof ASCIIBarChart:', typeof ASCIIBarChart);
|
|
console.log('window.ASCIIBarChart:', window.ASCIIBarChart);
|
|
|
|
if (typeof ASCIIBarChart === 'undefined') {
|
|
console.log('ASCIIBarChart not available - text_graph.js may not be loaded');
|
|
// Show a more detailed error message
|
|
chartContainer.innerHTML = `
|
|
<div style="color: var(--accent-color); font-family: var(--font-family); padding: 10px;">
|
|
⚠️ Chart library not loaded<br>
|
|
Check: /text_graph/text_graph.js<br>
|
|
<small>Real-time event visualization unavailable</small>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// Create stub elements that the chart expects for info display
|
|
createChartStubElements();
|
|
|
|
console.log('Creating ASCIIBarChart instance...');
|
|
|
|
// Initialize the chart with correct parameters based on text_graph.js API
|
|
eventRateChart = new ASCIIBarChart('event-rate-chart', {
|
|
maxHeight: 11, // Chart height in lines
|
|
maxDataPoints: 76, // Show last 76 bins (5+ minutes of history)
|
|
title: 'New Events', // Chart title
|
|
xAxisLabel: '', // No X-axis label
|
|
yAxisLabel: '', // No Y-axis label
|
|
autoFitWidth: true, // Enable responsive font sizing
|
|
useBinMode: true, // Enable time bin aggregation
|
|
binDuration: 4000, // 4-second time bins
|
|
xAxisLabelFormat: 'elapsed', // Show elapsed time labels
|
|
debug: false // Disable debug logging
|
|
});
|
|
|
|
console.log('ASCIIBarChart instance created:', eventRateChart);
|
|
console.log('Chart container content after init:', chartContainer.textContent);
|
|
console.log('Chart container innerHTML after init:', chartContainer.innerHTML);
|
|
|
|
// Force an initial render
|
|
if (eventRateChart && typeof eventRateChart.render === 'function') {
|
|
console.log('Forcing initial render...');
|
|
eventRateChart.render();
|
|
console.log('Chart container content after render:', chartContainer.textContent);
|
|
}
|
|
|
|
console.log('Event rate chart initialized successfully');
|
|
log('Real-time event rate chart initialized', 'INFO');
|
|
|
|
} catch (error) {
|
|
console.error('Failed to initialize event rate chart:', error);
|
|
console.error('Error stack:', error.stack);
|
|
log(`Failed to initialize event rate chart: ${error.message}`, 'ERROR');
|
|
|
|
// Show detailed error message in the container
|
|
const chartContainer = document.getElementById('event-rate-chart');
|
|
if (chartContainer) {
|
|
chartContainer.innerHTML = `
|
|
<div style="color: var(--error-color, #ff6b6b); font-family: var(--font-family); padding: 10px;">
|
|
❌ Chart initialization failed<br>
|
|
<small>${error.message}</small><br>
|
|
<small>Check browser console for details</small>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create stub elements that the ASCIIBarChart expects for info display
|
|
function createChartStubElements() {
|
|
const stubIds = ['values', 'max-value', 'scale', 'count'];
|
|
|
|
stubIds.forEach(id => {
|
|
if (!document.getElementById(id)) {
|
|
const stubElement = document.createElement('div');
|
|
stubElement.id = id;
|
|
stubElement.style.display = 'none'; // Hide stub elements
|
|
document.body.appendChild(stubElement);
|
|
}
|
|
});
|
|
|
|
console.log('Chart stub elements created');
|
|
}
|
|
|
|
// Handle monitoring events (kind 24567 - ephemeral)
|
|
async function processMonitoringEvent(event) {
|
|
try {
|
|
// Verify this is a kind 24567 ephemeral monitoring event
|
|
if (event.kind !== 24567) {
|
|
return;
|
|
}
|
|
|
|
// Verify the event is from the relay
|
|
const expectedRelayPubkey = getRelayPubkey();
|
|
if (event.pubkey !== expectedRelayPubkey) {
|
|
return;
|
|
}
|
|
|
|
// Check the d-tag to determine which type of monitoring event this is
|
|
const dTag = event.tags.find(tag => tag[0] === 'd');
|
|
if (!dTag) {
|
|
return;
|
|
}
|
|
|
|
// Parse the monitoring data (content is JSON, not encrypted for monitoring events)
|
|
const monitoringData = JSON.parse(event.content);
|
|
|
|
// Route to appropriate handler based on d-tag (no verbose logging)
|
|
switch (dTag[1]) {
|
|
case 'event_kinds':
|
|
updateStatsFromMonitoringEvent(monitoringData);
|
|
break;
|
|
|
|
case 'time_stats':
|
|
updateStatsFromTimeMonitoringEvent(monitoringData);
|
|
break;
|
|
|
|
case 'top_pubkeys':
|
|
updateStatsFromTopPubkeysMonitoringEvent(monitoringData);
|
|
break;
|
|
|
|
|
|
case 'subscription_details':
|
|
// Only process subscription details if user is authenticated
|
|
if (isLoggedIn) {
|
|
updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData);
|
|
// Also update the active subscriptions count from this data
|
|
if (monitoringData.data && monitoringData.data.subscriptions) {
|
|
updateStatsCell('active-subscriptions', monitoringData.data.subscriptions.length.toString());
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'cpu_metrics':
|
|
updateStatsFromCpuMonitoringEvent(monitoringData);
|
|
break;
|
|
|
|
default:
|
|
return;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error processing monitoring event:', error);
|
|
log(`Failed to process monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Handle different types of admin response data
|
|
function handleAdminResponseData(responseData) {
|
|
try {
|
|
console.log('=== HANDLING ADMIN RESPONSE DATA ===');
|
|
console.log('Response data:', responseData);
|
|
console.log('Response query_type:', responseData.query_type);
|
|
|
|
// Handle plain text responses (from create_relay_event and other commands)
|
|
if (responseData.plain_text) {
|
|
console.log('Handling plain text response');
|
|
log(responseData.message, 'INFO');
|
|
|
|
// Show the message in relay events status if we're on that page
|
|
if (currentPage === 'relay-events') {
|
|
// Try to determine which kind based on message content
|
|
if (responseData.message.includes('Kind: 0')) {
|
|
showStatus('kind0-status', responseData.message, 'success');
|
|
} else if (responseData.message.includes('Kind: 10050')) {
|
|
showStatus('kind10050-status', responseData.message, 'success');
|
|
} else if (responseData.message.includes('Kind: 10002')) {
|
|
showStatus('kind10002-status', responseData.message, 'success');
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle auth query responses - updated to match backend response types
|
|
if (responseData.query_type &&
|
|
(responseData.query_type.includes('auth_rules') ||
|
|
responseData.query_type.includes('auth'))) {
|
|
console.log('Routing to auth query handler');
|
|
handleAuthQueryResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle config update responses specifically
|
|
if (responseData.query_type === 'config_update') {
|
|
console.log('Routing to config update handler');
|
|
handleConfigUpdateResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle config query responses - updated to match backend response types
|
|
if (responseData.query_type &&
|
|
(responseData.query_type.includes('config') ||
|
|
responseData.query_type.startsWith('config_'))) {
|
|
console.log('Routing to config query handler');
|
|
handleConfigQueryResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle system command responses
|
|
if (responseData.command) {
|
|
console.log('Routing to system command handler');
|
|
handleSystemCommandResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle auth rule modification responses
|
|
if (responseData.operation || responseData.rules_processed !== undefined) {
|
|
console.log('Routing to auth rule modification handler');
|
|
handleAuthRuleResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle stats query responses
|
|
if (responseData.query_type === 'stats_query') {
|
|
console.log('Routing to stats query handler');
|
|
handleStatsQueryResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Handle SQL query responses
|
|
if (responseData.query_type === 'sql_query') {
|
|
console.log('Routing to SQL query handler');
|
|
handleSqlQueryResponse(responseData);
|
|
return;
|
|
}
|
|
|
|
// Generic response handling
|
|
console.log('Using generic response handler');
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Generic admin response: ${JSON.stringify(responseData)}`, 'RESPONSE');
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('Error handling admin response data:', error);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Failed to handle response data: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle config query responses
|
|
function handleConfigQueryResponse(responseData) {
|
|
console.log('=== CONFIG QUERY RESPONSE ===');
|
|
console.log('Query type:', responseData.query_type);
|
|
console.log('Total results:', responseData.total_results);
|
|
console.log('Data:', responseData.data);
|
|
|
|
// Convert the config response data to the format expected by displayConfiguration
|
|
if (responseData.data && responseData.data.length > 0) {
|
|
console.log('Converting config response to display format...');
|
|
|
|
// Create a synthetic event structure for displayConfiguration
|
|
const syntheticEvent = {
|
|
id: 'config_response_' + Date.now(),
|
|
pubkey: getRelayPubkey(),
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
kind: 'config_response',
|
|
content: 'Configuration from admin API',
|
|
tags: []
|
|
};
|
|
|
|
// Convert config data to tags format
|
|
responseData.data.forEach(config => {
|
|
const key = config.key || config.config_key;
|
|
const value = config.value || config.config_value;
|
|
if (key && value !== undefined) {
|
|
syntheticEvent.tags.push([key, value]);
|
|
}
|
|
});
|
|
|
|
console.log('Synthetic event created:', syntheticEvent);
|
|
console.log('Calling displayConfiguration with synthetic event...');
|
|
|
|
// Display the configuration using the original display function
|
|
displayConfiguration(syntheticEvent);
|
|
|
|
// Update relay info in header with config data
|
|
updateStoredRelayInfo(responseData);
|
|
|
|
// Initialize toggle buttons with config data
|
|
initializeToggleButtonsFromConfig(responseData);
|
|
|
|
log(`Configuration loaded: ${responseData.total_results} parameters`, 'INFO');
|
|
} else {
|
|
console.log('No configuration data received');
|
|
updateConfigStatus(false);
|
|
}
|
|
|
|
// Also log to test interface for debugging
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Config query response: ${responseData.query_type}, ${responseData.total_results} results`, 'CONFIG_QUERY');
|
|
|
|
if (responseData.data && responseData.data.length > 0) {
|
|
logTestEvent('RECV', '=== CONFIGURATION VALUES ===', 'CONFIG');
|
|
responseData.data.forEach((config, index) => {
|
|
const key = config.key || config.config_key || `config_${index}`;
|
|
const value = config.value || config.config_value || 'undefined';
|
|
const category = config.category || 'general';
|
|
const dataType = config.data_type || 'string';
|
|
|
|
logTestEvent('RECV', `${key}: ${value} (${dataType}, ${category})`, 'CONFIG');
|
|
});
|
|
logTestEvent('RECV', '=== END CONFIGURATION VALUES ===', 'CONFIG');
|
|
} else {
|
|
logTestEvent('RECV', 'No configuration values found', 'CONFIG_QUERY');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle config update responses
|
|
function handleConfigUpdateResponse(responseData) {
|
|
console.log('=== CONFIG UPDATE RESPONSE ===');
|
|
console.log('Query type:', responseData.query_type);
|
|
console.log('Status:', responseData.status);
|
|
console.log('Data:', responseData.data);
|
|
|
|
if (responseData.status === 'success') {
|
|
const updatesApplied = responseData.updates_applied || 0;
|
|
log(`Configuration updated successfully: ${updatesApplied} parameters changed`, 'INFO');
|
|
|
|
// Show success message with details
|
|
if (responseData.data && Array.isArray(responseData.data)) {
|
|
responseData.data.forEach((config, index) => {
|
|
if (config.status === 'success') {
|
|
log(`✓ ${config.key}: ${config.value} (${config.data_type})`, 'INFO');
|
|
} else {
|
|
log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Configuration updated successfully - user can manually refresh using Fetch Config button
|
|
log('Configuration updated successfully. Click "Fetch Config" to refresh the display.', 'INFO');
|
|
|
|
} else {
|
|
const errorMessage = responseData.message || responseData.error || 'Unknown error';
|
|
log(`Configuration update failed: ${errorMessage}`, 'ERROR');
|
|
|
|
// Show detailed error information if available
|
|
if (responseData.data && Array.isArray(responseData.data)) {
|
|
responseData.data.forEach((config, index) => {
|
|
if (config.status === 'error') {
|
|
log(`✗ ${config.key}: ${config.error || 'Failed to update'}`, 'ERROR');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Log to test interface for debugging
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Config update response: ${responseData.status}`, 'CONFIG_UPDATE');
|
|
|
|
if (responseData.data && responseData.data.length > 0) {
|
|
responseData.data.forEach((config, index) => {
|
|
const status = config.status === 'success' ? '✓' : '✗';
|
|
const message = config.status === 'success' ?
|
|
`${config.key} = ${config.value}` :
|
|
`${config.key}: ${config.error || 'Failed'}`;
|
|
logTestEvent('RECV', `${status} ${message}`, 'CONFIG_UPDATE');
|
|
});
|
|
} else {
|
|
logTestEvent('RECV', 'No configuration update details received', 'CONFIG_UPDATE');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle auth query responses
|
|
function handleAuthQueryResponse(responseData) {
|
|
console.log('=== AUTH QUERY RESPONSE ===');
|
|
console.log('Query type:', responseData.query_type);
|
|
console.log('Total results:', responseData.total_results);
|
|
console.log('Data:', responseData.data);
|
|
|
|
// Update the current auth rules with the response data
|
|
if (responseData.data && Array.isArray(responseData.data)) {
|
|
currentAuthRules = responseData.data;
|
|
console.log('Updated currentAuthRules with', currentAuthRules.length, 'rules');
|
|
|
|
// Always show the auth rules table when we receive data (no VIEW RULES button anymore)
|
|
console.log('Auto-showing auth rules table since we received data...');
|
|
showAuthRulesTable();
|
|
|
|
updateAuthRulesStatus('loaded');
|
|
log(`Loaded ${responseData.total_results} auth rules from relay`, 'INFO');
|
|
} else {
|
|
currentAuthRules = [];
|
|
console.log('No auth rules data received, cleared currentAuthRules');
|
|
|
|
// Show empty table (no VIEW RULES button anymore)
|
|
console.log('Auto-showing auth rules table with empty data...');
|
|
showAuthRulesTable();
|
|
|
|
updateAuthRulesStatus('loaded');
|
|
log('No auth rules found on relay', 'INFO');
|
|
}
|
|
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Auth query response: ${responseData.query_type}, ${responseData.total_results} results`, 'AUTH_QUERY');
|
|
|
|
if (responseData.data && responseData.data.length > 0) {
|
|
responseData.data.forEach((rule, index) => {
|
|
logTestEvent('RECV', `Rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
|
|
});
|
|
} else {
|
|
logTestEvent('RECV', 'No auth rules found', 'AUTH_QUERY');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle system command responses
|
|
function handleSystemCommandResponse(responseData) {
|
|
console.log('=== SYSTEM COMMAND RESPONSE ===');
|
|
console.log('Command:', responseData.command);
|
|
console.log('Status:', responseData.status);
|
|
|
|
// Handle delete auth rule responses
|
|
if (responseData.command === 'delete_auth_rule') {
|
|
if (responseData.status === 'success') {
|
|
log('Auth rule deleted successfully', 'INFO');
|
|
// Refresh the auth rules display
|
|
loadAuthRules();
|
|
} else {
|
|
log(`Failed to delete auth rule: ${responseData.message || 'Unknown error'}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Handle clear all auth rules responses
|
|
if (responseData.command === 'clear_all_auth_rules') {
|
|
if (responseData.status === 'success') {
|
|
const rulesCleared = responseData.rules_cleared || 0;
|
|
log(`Successfully cleared ${rulesCleared} auth rules`, 'INFO');
|
|
// Clear local auth rules and refresh display
|
|
currentAuthRules = [];
|
|
displayAuthRules(currentAuthRules);
|
|
} else {
|
|
log(`Failed to clear auth rules: ${responseData.message || 'Unknown error'}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
|
|
}
|
|
}
|
|
|
|
// Handle auth rule modification responses
|
|
function handleAuthRuleResponse(responseData) {
|
|
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
|
|
console.log('Operation:', responseData.operation);
|
|
console.log('Status:', responseData.status);
|
|
|
|
// Handle auth rule addition/modification responses
|
|
if (responseData.status === 'success') {
|
|
const rulesProcessed = responseData.rules_processed || 0;
|
|
log(`Successfully processed ${rulesProcessed} auth rule modifications`, 'INFO');
|
|
|
|
// Refresh the auth rules display to show the new rules
|
|
if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') {
|
|
loadAuthRules();
|
|
}
|
|
} else {
|
|
log(`Failed to process auth rule modifications: ${responseData.message || 'Unknown error'}`, 'ERROR');
|
|
}
|
|
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('RECV', `Auth rule response: ${responseData.operation} - ${responseData.status}`, 'AUTH_RULE');
|
|
|
|
if (responseData.processed_rules) {
|
|
responseData.processed_rules.forEach((rule, index) => {
|
|
logTestEvent('RECV', `Processed rule ${index + 1}: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'AUTH_RULE');
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper function to decrypt content from relay using NIP-44
|
|
async function decryptFromRelay(encryptedContent) {
|
|
try {
|
|
console.log('Decrypting content from relay...');
|
|
|
|
// Get the relay public key for decryption
|
|
const relayPubkey = getRelayPubkey();
|
|
|
|
// Use NIP-07 extension's NIP-44 decrypt method
|
|
if (!window.nostr || !window.nostr.nip44) {
|
|
throw new Error('NIP-44 decryption not available via NIP-07 extension');
|
|
}
|
|
|
|
const decryptedContent = await window.nostr.nip44.decrypt(relayPubkey, encryptedContent);
|
|
|
|
if (!decryptedContent) {
|
|
throw new Error('NIP-44 decryption returned empty result');
|
|
}
|
|
|
|
console.log('Successfully decrypted content from relay');
|
|
return decryptedContent;
|
|
|
|
} catch (error) {
|
|
console.error('NIP-44 decryption failed:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Fetch configuration using admin API
|
|
async function fetchConfiguration() {
|
|
try {
|
|
console.log('=== FETCHING CONFIGURATION VIA ADMIN API ===');
|
|
|
|
// Require both login and relay connection
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to fetch configuration');
|
|
}
|
|
|
|
if (!isRelayConnected || !relayPubkey) {
|
|
throw new Error('Must be connected to relay to fetch configuration. Please use the Relay Connection section first.');
|
|
}
|
|
|
|
// First establish subscription to receive responses (only if not already subscribed)
|
|
const subscriptionResult = await subscribeToConfiguration();
|
|
if (!subscriptionResult) {
|
|
throw new Error('Failed to establish admin response subscription');
|
|
}
|
|
|
|
// Wait a moment for subscription to be established (only if we just created it)
|
|
if (!isSubscribed) {
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
|
|
// Send config query command if logged in
|
|
if (isLoggedIn && userPubkey && relayPool) {
|
|
console.log('Sending config query command...');
|
|
|
|
// Create command array for getting configuration
|
|
const command_array = ["config_query", "all"];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const configEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(configEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
console.log('Config query event signed, publishing...');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Relay ${index} (${url}): Event published successfully`);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('INFO', `Relay ${index} publish success`, 'PUBLISH');
|
|
}
|
|
} else {
|
|
console.error(`❌ Relay ${index} (${url}): Publish failed:`, result.reason);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected the event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
console.log('Config query command sent successfully - waiting for response...');
|
|
|
|
} else {
|
|
console.log('Not logged in - only subscription established for testing');
|
|
}
|
|
|
|
return true;
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch configuration:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function displayConfiguration(event) {
|
|
try {
|
|
console.log('=== DISPLAYING CONFIGURATION EVENT ===');
|
|
console.log('Event received for display:', event);
|
|
|
|
currentConfig = event;
|
|
|
|
// Clear existing table
|
|
configTableBody.innerHTML = '';
|
|
|
|
// Display tags (editable configuration parameters only)
|
|
console.log(`Processing ${event.tags.length} configuration parameters`);
|
|
event.tags.forEach((tag, index) => {
|
|
if (tag.length >= 2) {
|
|
const row = document.createElement('tr');
|
|
const key = tag[0];
|
|
const value = tag[1];
|
|
|
|
// Create editable input for value
|
|
const valueInput = document.createElement('input');
|
|
valueInput.type = 'text';
|
|
valueInput.value = value;
|
|
valueInput.className = 'config-value-input';
|
|
valueInput.dataset.key = key;
|
|
valueInput.dataset.originalValue = value;
|
|
valueInput.dataset.rowIndex = index;
|
|
|
|
// Create clickable Actions cell
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.className = 'config-actions-cell';
|
|
actionsCell.textContent = 'SAVE';
|
|
actionsCell.dataset.key = key;
|
|
actionsCell.dataset.originalValue = value;
|
|
actionsCell.dataset.rowIndex = index;
|
|
|
|
// Initially hide the SAVE text
|
|
actionsCell.style.color = 'transparent';
|
|
|
|
// Show SAVE text and make clickable when value changes
|
|
valueInput.addEventListener('input', function () {
|
|
if (this.value !== this.dataset.originalValue) {
|
|
actionsCell.style.color = 'var(--primary-color)';
|
|
actionsCell.style.cursor = 'pointer';
|
|
actionsCell.onclick = () => saveIndividualConfig(key, valueInput.value, valueInput.dataset.originalValue, actionsCell);
|
|
} else {
|
|
actionsCell.style.color = 'transparent';
|
|
actionsCell.style.cursor = 'default';
|
|
actionsCell.onclick = null;
|
|
}
|
|
});
|
|
|
|
row.innerHTML = `<td>${key}</td><td></td>`;
|
|
row.cells[1].appendChild(valueInput);
|
|
row.appendChild(actionsCell);
|
|
configTableBody.appendChild(row);
|
|
}
|
|
});
|
|
|
|
// Show message if no configuration parameters found
|
|
if (event.tags.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `<td colspan="3" style="text-align: center; font-style: italic;">No configuration parameters found</td>`;
|
|
configTableBody.appendChild(row);
|
|
}
|
|
|
|
console.log('Configuration display completed successfully');
|
|
updateConfigStatus(true);
|
|
|
|
} catch (error) {
|
|
console.error('Error in displayConfiguration:', error.message);
|
|
console.error('Display configuration error:', error);
|
|
}
|
|
}
|
|
|
|
// Save individual configuration parameter
|
|
async function saveIndividualConfig(key, newValue, originalValue, actionsCell) {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
log('Must be logged in to save configuration', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!currentConfig) {
|
|
log('No current configuration to update', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Don't save if value hasn't changed
|
|
if (newValue === originalValue) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
log(`Saving individual config: ${key} = ${newValue}`, 'INFO');
|
|
|
|
// Determine data type based on key name
|
|
let dataType = 'string';
|
|
if (['max_connections', 'pow_min_difficulty', 'nip42_challenge_timeout', 'max_subscriptions_per_client', 'max_event_tags', 'max_content_length'].includes(key)) {
|
|
dataType = 'integer';
|
|
} else if (['auth_enabled', 'nip42_auth_required', 'nip40_expiration_enabled'].includes(key)) {
|
|
dataType = 'boolean';
|
|
}
|
|
|
|
// Determine category based on key name
|
|
let category = 'general';
|
|
if (key.startsWith('relay_')) {
|
|
category = 'relay';
|
|
} else if (key.startsWith('nip40_')) {
|
|
category = 'expiration';
|
|
} else if (key.startsWith('nip42_') || key.startsWith('auth_')) {
|
|
category = 'authentication';
|
|
} else if (key.startsWith('pow_')) {
|
|
category = 'proof_of_work';
|
|
} else if (key.startsWith('max_')) {
|
|
category = 'limits';
|
|
}
|
|
|
|
const configObj = {
|
|
key: key,
|
|
value: newValue,
|
|
data_type: dataType,
|
|
category: category
|
|
};
|
|
|
|
// Update cell during save
|
|
actionsCell.textContent = 'SAVING...';
|
|
actionsCell.style.color = 'var(--accent-color)';
|
|
actionsCell.style.cursor = 'not-allowed';
|
|
actionsCell.onclick = null;
|
|
|
|
// Send single config update
|
|
await sendConfigUpdateCommand([configObj]);
|
|
|
|
// Update the original value on success
|
|
const input = actionsCell.parentElement.cells[1].querySelector('input');
|
|
if (input) {
|
|
input.dataset.originalValue = newValue;
|
|
// Hide SAVE text since value now matches original
|
|
actionsCell.style.color = 'transparent';
|
|
actionsCell.style.cursor = 'default';
|
|
actionsCell.onclick = null;
|
|
}
|
|
|
|
actionsCell.textContent = 'SAVED';
|
|
actionsCell.style.color = 'var(--accent-color)';
|
|
setTimeout(() => {
|
|
actionsCell.textContent = 'SAVE';
|
|
// Keep transparent if value matches original
|
|
if (input && input.value === input.dataset.originalValue) {
|
|
actionsCell.style.color = 'transparent';
|
|
}
|
|
}, 2000);
|
|
|
|
log(`Successfully saved config: ${key} = ${newValue}`, 'INFO');
|
|
|
|
} catch (error) {
|
|
log(`Failed to save individual config ${key}: ${error.message}`, 'ERROR');
|
|
actionsCell.textContent = 'SAVE';
|
|
actionsCell.style.color = 'var(--primary-color)';
|
|
actionsCell.style.cursor = 'pointer';
|
|
actionsCell.onclick = () => saveIndividualConfig(key, actionsCell.parentElement.cells[1].querySelector('input').value, originalValue, actionsCell);
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
// Send config update command using kind 23456 with Administrator API (inner events)
|
|
async function sendConfigUpdateCommand(configObjects) {
|
|
try {
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
console.log(`Sending config_update command with ${configObjects.length} configuration object(s)`);
|
|
|
|
// Create command array for config update
|
|
const command_array = ["config_update", configObjects];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const configEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(configEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
console.log(`Config update event signed with ${configObjects.length} object(s)`);
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Config Update Relay ${index} (${url}): Event published successfully`);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('INFO', `Config update relay ${index} publish success`, 'PUBLISH');
|
|
}
|
|
} else {
|
|
console.error(`❌ Config Update Relay ${index} (${url}): Publish failed:`, result.reason);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Config update relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected config update event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
console.log(`Config update command sent successfully with ${configObjects.length} configuration object(s)`);
|
|
|
|
// Log for testing
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('SENT', `Config update command: ${configObjects.length} object(s)`, 'CONFIG_UPDATE');
|
|
configObjects.forEach((config, index) => {
|
|
logTestEvent('SENT', `Config ${index + 1}: ${config.key} = ${config.value} (${config.data_type})`, 'CONFIG');
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`Failed to send config_update command:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Profile area click handler removed - dropdown moved to sidebar
|
|
// Logout and dark mode buttons are now in the sidebar footer
|
|
|
|
// Initialize relay pubkey container click handler for clipboard copy
|
|
const relayPubkeyContainer = document.getElementById('relay-pubkey-container');
|
|
if (relayPubkeyContainer) {
|
|
relayPubkeyContainer.addEventListener('click', async function() {
|
|
const relayPubkeyElement = document.getElementById('relay-pubkey');
|
|
if (relayPubkeyElement && relayPubkeyElement.textContent !== 'Loading...') {
|
|
try {
|
|
// Get the full npub (remove all whitespace for continuous string)
|
|
const fullNpub = relayPubkeyElement.textContent.replace(/\s/g, '');
|
|
|
|
await navigator.clipboard.writeText(fullNpub);
|
|
|
|
// Add copied class for visual feedback
|
|
relayPubkeyContainer.classList.add('copied');
|
|
|
|
// Remove the class after animation completes
|
|
setTimeout(() => {
|
|
relayPubkeyContainer.classList.remove('copied');
|
|
}, 500);
|
|
|
|
log('Relay npub copied to clipboard', 'INFO');
|
|
} catch (error) {
|
|
log('Failed to copy relay npub to clipboard', 'ERROR');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Event handlers
|
|
fetchConfigBtn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
fetchConfiguration().catch(error => {
|
|
console.log('Manual fetch configuration failed: ' + error.message);
|
|
});
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ================================
|
|
// AUTH RULES MANAGEMENT FUNCTIONS
|
|
// ================================
|
|
|
|
// Global auth rules state
|
|
let currentAuthRules = [];
|
|
let editingAuthRule = null;
|
|
|
|
// DOM elements for auth rules
|
|
const authRulesSection = document.getElementById('authRulesSection');
|
|
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
|
|
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
|
|
const authRulesTableBody = document.getElementById('authRulesTableBody');
|
|
const authRuleFormContainer = document.getElementById('authRuleFormContainer');
|
|
const authRuleForm = document.getElementById('authRuleForm');
|
|
const authRuleFormTitle = document.getElementById('authRuleFormTitle');
|
|
const saveAuthRuleBtn = document.getElementById('saveAuthRuleBtn');
|
|
const cancelAuthRuleBtn = document.getElementById('cancelAuthRuleBtn');
|
|
|
|
// Show auth rules section after login
|
|
function showAuthRulesSection() {
|
|
if (authRulesSection) {
|
|
authRulesSection.style.display = 'block';
|
|
updateAuthRulesStatus('ready');
|
|
log('Auth rules section is now available', 'INFO');
|
|
}
|
|
}
|
|
|
|
// Hide auth rules section on logout
|
|
function hideAuthRulesSection() {
|
|
if (authRulesSection) {
|
|
authRulesSection.style.display = 'none';
|
|
|
|
// Add null checks for all elements
|
|
if (authRulesTableContainer) {
|
|
authRulesTableContainer.style.display = 'none';
|
|
}
|
|
if (authRuleFormContainer) {
|
|
authRuleFormContainer.style.display = 'none';
|
|
}
|
|
|
|
currentAuthRules = [];
|
|
editingAuthRule = null;
|
|
log('Auth rules section hidden', 'INFO');
|
|
}
|
|
}
|
|
|
|
// Update auth rules status indicator (removed - no status element)
|
|
function updateAuthRulesStatus(status) {
|
|
// Status element removed - no-op
|
|
}
|
|
|
|
// Load auth rules from relay using admin API
|
|
async function loadAuthRules() {
|
|
try {
|
|
log('Loading auth rules via admin API...', 'INFO');
|
|
updateAuthRulesStatus('loading');
|
|
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to load auth rules');
|
|
}
|
|
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
// Create command array for getting all auth rules
|
|
const command_array = ["auth_query", "all"];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
log('Sending auth rules query to relay...', 'INFO');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Auth Rules Query Relay ${index} (${url}): Event published successfully`);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('INFO', `Auth rules query relay ${index} publish success`, 'PUBLISH');
|
|
}
|
|
} else {
|
|
console.error(`❌ Auth Rules Query Relay ${index} (${url}): Publish failed:`, result.reason);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Auth rules query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected auth rules query event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Auth rules query sent successfully - waiting for response...', 'INFO');
|
|
updateAuthRulesStatus('loaded');
|
|
|
|
} catch (error) {
|
|
log(`Failed to load auth rules: ${error.message}`, 'ERROR');
|
|
updateAuthRulesStatus('error');
|
|
currentAuthRules = [];
|
|
displayAuthRules(currentAuthRules);
|
|
}
|
|
}
|
|
|
|
// Display auth rules in the table
|
|
function displayAuthRules(rules) {
|
|
console.log('=== DISPLAY AUTH RULES DEBUG ===');
|
|
console.log('authRulesTableBody element:', authRulesTableBody);
|
|
console.log('Rules to display:', rules);
|
|
console.log('Rules length:', rules ? rules.length : 'undefined');
|
|
console.log('authRulesTableContainer display:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found');
|
|
|
|
if (!authRulesTableBody) {
|
|
console.log('ERROR: authRulesTableBody element not found');
|
|
return;
|
|
}
|
|
|
|
authRulesTableBody.innerHTML = '';
|
|
console.log('Cleared existing table content');
|
|
|
|
if (!rules || rules.length === 0) {
|
|
console.log('No rules to display, showing empty message');
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `<td colspan="6" style="text-align: center; font-style: italic;">No auth rules configured</td>`;
|
|
authRulesTableBody.appendChild(row);
|
|
console.log('Added empty rules message row');
|
|
return;
|
|
}
|
|
|
|
console.log(`Displaying ${rules.length} auth rules`);
|
|
rules.forEach((rule, index) => {
|
|
console.log(`Adding rule ${index + 1}:`, rule);
|
|
const row = document.createElement('tr');
|
|
|
|
// Convert hex pubkey to npub for display in pattern_value
|
|
let displayPatternValue = rule.pattern_value || rule.rule_target || '-';
|
|
let patternValueLink = displayPatternValue;
|
|
try {
|
|
if (rule.pattern_value && rule.pattern_value.length === 64 && /^[0-9a-fA-F]+$/.test(rule.pattern_value)) {
|
|
const npub = window.NostrTools.nip19.npubEncode(rule.pattern_value);
|
|
displayPatternValue = npub;
|
|
patternValueLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to encode pattern_value to npub:', error.message);
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<td>${rule.rule_type}</td>
|
|
<td>${rule.pattern_type || rule.operation || '-'}</td>
|
|
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all; max-width: 200px;">${patternValueLink}</td>
|
|
<td>${rule.enabled !== false ? 'Active' : 'Inactive'}</td>
|
|
<td>
|
|
<div class="inline-buttons">
|
|
<button onclick="editAuthRule(${index})" style="margin: 2px; padding: 4px 8px; font-size: 12px;">EDIT</button>
|
|
<button onclick="deleteAuthRule(${index})" style="margin: 2px; padding: 4px 8px; font-size: 12px;">DELETE</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
authRulesTableBody.appendChild(row);
|
|
});
|
|
|
|
// Update status display
|
|
console.log(`Total Rules: ${rules.length}, Active Rules: ${rules.filter(r => r.enabled !== false).length}`);
|
|
|
|
console.log('=== END DISPLAY AUTH RULES DEBUG ===');
|
|
}
|
|
|
|
// Show auth rules table (automatically called when auth rules are loaded)
|
|
function showAuthRulesTable() {
|
|
console.log('=== SHOW AUTH RULES TABLE DEBUG ===');
|
|
console.log('authRulesTableContainer element:', authRulesTableContainer);
|
|
console.log('Current display style:', authRulesTableContainer ? authRulesTableContainer.style.display : 'element not found');
|
|
|
|
if (authRulesTableContainer) {
|
|
authRulesTableContainer.style.display = 'block';
|
|
console.log('Set authRulesTableContainer display to block');
|
|
|
|
// If we already have cached auth rules, display them immediately
|
|
if (currentAuthRules && currentAuthRules.length >= 0) {
|
|
console.log('Displaying cached auth rules:', currentAuthRules.length, 'rules');
|
|
displayAuthRules(currentAuthRules);
|
|
updateAuthRulesStatus('loaded');
|
|
log(`Auth rules table displayed with ${currentAuthRules.length} cached rules`, 'INFO');
|
|
} else {
|
|
// No cached rules, load from relay
|
|
console.log('No cached auth rules, loading from relay...');
|
|
loadAuthRules();
|
|
log('Auth rules table displayed - loading from relay', 'INFO');
|
|
}
|
|
} else {
|
|
console.log('ERROR: authRulesTableContainer element not found');
|
|
}
|
|
console.log('=== END SHOW AUTH RULES TABLE DEBUG ===');
|
|
}
|
|
|
|
// Show add auth rule form
|
|
function showAddAuthRuleForm() {
|
|
if (authRuleFormContainer && authRuleFormTitle) {
|
|
editingAuthRule = null;
|
|
authRuleFormTitle.textContent = 'Add Auth Rule';
|
|
authRuleForm.reset();
|
|
authRuleFormContainer.style.display = 'block';
|
|
log('Opened add auth rule form', 'INFO');
|
|
}
|
|
}
|
|
|
|
// Show edit auth rule form
|
|
function editAuthRule(index) {
|
|
if (index < 0 || index >= currentAuthRules.length) return;
|
|
|
|
const rule = currentAuthRules[index];
|
|
editingAuthRule = { ...rule, index: index };
|
|
|
|
if (authRuleFormTitle && authRuleForm) {
|
|
authRuleFormTitle.textContent = 'Edit Auth Rule';
|
|
|
|
// Populate form fields
|
|
document.getElementById('authRuleType').value = rule.rule_type || '';
|
|
document.getElementById('authPatternType').value = rule.pattern_type || rule.operation || '';
|
|
document.getElementById('authPatternValue').value = rule.pattern_value || rule.rule_target || '';
|
|
document.getElementById('authRuleAction').value = rule.action || 'allow';
|
|
document.getElementById('authRuleDescription').value = rule.description || '';
|
|
|
|
authRuleFormContainer.style.display = 'block';
|
|
log(`Editing auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO');
|
|
}
|
|
}
|
|
|
|
// Delete auth rule using Administrator API (inner events)
|
|
async function deleteAuthRule(index) {
|
|
if (index < 0 || index >= currentAuthRules.length) return;
|
|
|
|
const rule = currentAuthRules[index];
|
|
const confirmMsg = `Delete auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}?`;
|
|
|
|
if (!confirm(confirmMsg)) return;
|
|
|
|
try {
|
|
log(`Deleting auth rule: ${rule.rule_type} - ${rule.pattern_value || rule.rule_target}`, 'INFO');
|
|
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to delete auth rules');
|
|
}
|
|
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
// Create command array for deleting auth rule
|
|
// Format: ["system_command", "delete_auth_rule", rule_type, pattern_type, pattern_value]
|
|
const rule_type = rule.rule_type;
|
|
const pattern_type = rule.pattern_type || 'pubkey';
|
|
const pattern_value = rule.pattern_value || rule.rule_target;
|
|
|
|
const command_array = ["system_command", "delete_auth_rule", rule_type, pattern_type, pattern_value];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
log('Sending delete auth rule command to relay...', 'INFO');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Delete Auth Rule Relay ${index} (${url}): Event published successfully`);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('INFO', `Delete auth rule relay ${index} publish success`, 'PUBLISH');
|
|
}
|
|
} else {
|
|
console.error(`❌ Delete Auth Rule Relay ${index} (${url}): Publish failed:`, result.reason);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Delete auth rule relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected delete auth rule event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Delete auth rule command sent successfully - waiting for response...', 'INFO');
|
|
|
|
// Remove from local array immediately for UI responsiveness
|
|
currentAuthRules.splice(index, 1);
|
|
displayAuthRules(currentAuthRules);
|
|
|
|
} catch (error) {
|
|
log(`Failed to delete auth rule: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Hide auth rule form
|
|
function hideAuthRuleForm() {
|
|
if (authRuleFormContainer) {
|
|
authRuleFormContainer.style.display = 'none';
|
|
editingAuthRule = null;
|
|
log('Auth rule form hidden', 'INFO');
|
|
}
|
|
}
|
|
|
|
// Validate auth rule form
|
|
function validateAuthRuleForm() {
|
|
const ruleType = document.getElementById('authRuleType').value;
|
|
const patternType = document.getElementById('authPatternType').value;
|
|
const patternValue = document.getElementById('authPatternValue').value.trim();
|
|
const action = document.getElementById('authRuleAction').value;
|
|
|
|
if (!ruleType) {
|
|
alert('Please select a rule type');
|
|
return false;
|
|
}
|
|
|
|
if (!patternType) {
|
|
alert('Please select a pattern type');
|
|
return false;
|
|
}
|
|
|
|
if (!patternValue) {
|
|
alert('Please enter a pattern value');
|
|
return false;
|
|
}
|
|
|
|
if (!action) {
|
|
alert('Please select an action');
|
|
return false;
|
|
}
|
|
|
|
// Validate pubkey format for pubkey rules
|
|
if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist') &&
|
|
patternValue.length !== 64) {
|
|
alert('Pubkey must be exactly 64 hex characters');
|
|
return false;
|
|
}
|
|
|
|
// Validate hex format for pubkey rules
|
|
if ((ruleType === 'pubkey_whitelist' || ruleType === 'pubkey_blacklist')) {
|
|
const hexPattern = /^[0-9a-fA-F]+$/;
|
|
if (!hexPattern.test(patternValue)) {
|
|
alert('Pubkey must contain only hex characters (0-9, a-f, A-F)');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Save auth rule (add or update)
|
|
async function saveAuthRule(event) {
|
|
event.preventDefault();
|
|
|
|
if (!validateAuthRuleForm()) return;
|
|
|
|
try {
|
|
const ruleData = {
|
|
rule_type: document.getElementById('authRuleType').value,
|
|
pattern_type: document.getElementById('authPatternType').value,
|
|
pattern_value: document.getElementById('authPatternValue').value.trim(),
|
|
action: document.getElementById('authRuleAction').value,
|
|
description: document.getElementById('authRuleDescription').value.trim() || null,
|
|
enabled: true
|
|
};
|
|
|
|
if (editingAuthRule) {
|
|
log(`Updating auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO');
|
|
|
|
// TODO: Implement actual rule update via WebSocket kind 23456 event
|
|
// For now, just update local array
|
|
currentAuthRules[editingAuthRule.index] = { ...ruleData, id: editingAuthRule.id || Date.now() };
|
|
|
|
log('Auth rule updated (placeholder implementation)', 'INFO');
|
|
} else {
|
|
log(`Adding new auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value}`, 'INFO');
|
|
|
|
// TODO: Implement actual rule creation via WebSocket kind 23456 event
|
|
// For now, just add to local array
|
|
currentAuthRules.push({ ...ruleData, id: Date.now() });
|
|
|
|
log('Auth rule added (placeholder implementation)', 'INFO');
|
|
}
|
|
|
|
displayAuthRules(currentAuthRules);
|
|
hideAuthRuleForm();
|
|
|
|
} catch (error) {
|
|
log(`Failed to save auth rule: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Monitoring is now subscription-based - no auto-enable needed
|
|
// Monitoring automatically activates when someone subscribes to kind 24567 events
|
|
async function autoEnableMonitoring() {
|
|
log('Monitoring system is subscription-based - no manual enable needed', 'INFO');
|
|
log('Subscribe to kind 24567 events to receive real-time monitoring data', 'INFO');
|
|
}
|
|
|
|
// Update existing logout and showMainInterface functions to handle auth rules and NIP-17 DMs
|
|
const originalLogout = logout;
|
|
logout = async function () {
|
|
hideAuthRulesSection();
|
|
// Clear DM inbox and outbox on logout
|
|
if (dmInbox) {
|
|
dmInbox.innerHTML = '<div class="log-entry">No messages received yet.</div>';
|
|
}
|
|
if (dmOutbox) {
|
|
dmOutbox.value = '';
|
|
}
|
|
await originalLogout();
|
|
};
|
|
|
|
const originalShowMainInterface = showMainInterface;
|
|
showMainInterface = function () {
|
|
originalShowMainInterface();
|
|
// Removed showAuthRulesSection() call - visibility now handled by updateAdminSectionsVisibility()
|
|
};
|
|
|
|
// Auth rules event handlers
|
|
if (refreshAuthRulesBtn) {
|
|
refreshAuthRulesBtn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
loadAuthRules();
|
|
});
|
|
}
|
|
|
|
if (authRuleForm) {
|
|
authRuleForm.addEventListener('submit', saveAuthRule);
|
|
}
|
|
|
|
if (cancelAuthRuleBtn) {
|
|
cancelAuthRuleBtn.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
hideAuthRuleForm();
|
|
});
|
|
}
|
|
|
|
// ================================
|
|
// STREAMLINED AUTH RULE FUNCTIONS
|
|
// ================================
|
|
|
|
// Utility function to convert nsec to hex pubkey or npub to hex pubkey
|
|
function nsecToHex(input) {
|
|
if (!input || input.trim().length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const trimmed = input.trim();
|
|
|
|
// If it's already 64-char hex, return as-is
|
|
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
// If it starts with nsec1, try to decode
|
|
if (trimmed.startsWith('nsec1')) {
|
|
try {
|
|
if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) {
|
|
const decoded = window.NostrTools.nip19.decode(trimmed);
|
|
if (decoded.type === 'nsec') {
|
|
// Handle different versions of nostr-tools
|
|
if (typeof decoded.data === 'string') {
|
|
// v1 style - data is already hex
|
|
return decoded.data;
|
|
} else {
|
|
// v2 style - data is Uint8Array
|
|
const hexPubkey = Array.from(decoded.data)
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
return hexPubkey;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to decode nsec:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// If it starts with npub1, try to decode to hex
|
|
if (trimmed.startsWith('npub1')) {
|
|
try {
|
|
if (window.NostrTools && window.NostrTools.nip19 && window.NostrTools.nip19.decode) {
|
|
const decoded = window.NostrTools.nip19.decode(trimmed);
|
|
if (decoded.type === 'npub') {
|
|
// Handle different versions of nostr-tools
|
|
if (typeof decoded.data === 'string') {
|
|
// v1 style - data is already hex
|
|
return decoded.data;
|
|
} else {
|
|
// v2 style - data is Uint8Array
|
|
const hexPubkey = Array.from(decoded.data)
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
return hexPubkey;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to decode npub:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null; // Invalid format
|
|
}
|
|
|
|
// Add blacklist rule (updated to use combined input)
|
|
function addBlacklistRule() {
|
|
const input = document.getElementById('authRulePubkey');
|
|
|
|
if (!input) return;
|
|
|
|
const inputValue = input.value.trim();
|
|
if (!inputValue) {
|
|
log('Please enter a pubkey or nsec', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Convert nsec or npub to hex if needed
|
|
const hexPubkey = nsecToHex(inputValue);
|
|
if (!hexPubkey) {
|
|
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Validate hex length
|
|
if (hexPubkey.length !== 64) {
|
|
log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
log('Adding to blacklist...', 'INFO');
|
|
|
|
// Create auth rule data
|
|
const ruleData = {
|
|
rule_type: 'pubkey_blacklist',
|
|
pattern_type: 'Global',
|
|
pattern_value: hexPubkey,
|
|
action: 'deny'
|
|
};
|
|
|
|
// Add to WebSocket queue for processing
|
|
addAuthRuleViaWebSocket(ruleData)
|
|
.then(() => {
|
|
log(`Pubkey ${hexPubkey.substring(0, 16)}... added to blacklist`, 'INFO');
|
|
input.value = '';
|
|
|
|
// Refresh auth rules display if visible
|
|
if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') {
|
|
loadAuthRules();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
log(`Failed to add rule: ${error.message}`, 'ERROR');
|
|
});
|
|
}
|
|
|
|
// Add whitelist rule (updated to use combined input)
|
|
function addWhitelistRule() {
|
|
const input = document.getElementById('authRulePubkey');
|
|
const warningDiv = document.getElementById('whitelistWarning');
|
|
|
|
if (!input) return;
|
|
|
|
const inputValue = input.value.trim();
|
|
if (!inputValue) {
|
|
log('Please enter a pubkey or nsec', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Convert nsec or npub to hex if needed
|
|
const hexPubkey = nsecToHex(inputValue);
|
|
if (!hexPubkey) {
|
|
log('Invalid pubkey format. Please enter nsec1..., npub1..., or 64-character hex', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Validate hex length
|
|
if (hexPubkey.length !== 64) {
|
|
log('Invalid pubkey length. Must be exactly 64 characters', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Show whitelist warning
|
|
if (warningDiv) {
|
|
warningDiv.style.display = 'block';
|
|
}
|
|
|
|
log('Adding to whitelist...', 'INFO');
|
|
|
|
// Create auth rule data
|
|
const ruleData = {
|
|
rule_type: 'pubkey_whitelist',
|
|
pattern_type: 'Global',
|
|
pattern_value: hexPubkey,
|
|
action: 'allow'
|
|
};
|
|
|
|
// Add to WebSocket queue for processing
|
|
addAuthRuleViaWebSocket(ruleData)
|
|
.then(() => {
|
|
log(`Pubkey ${hexPubkey.substring(0, 16)}... added to whitelist`, 'INFO');
|
|
input.value = '';
|
|
|
|
// Refresh auth rules display if visible
|
|
if (authRulesTableContainer && authRulesTableContainer.style.display !== 'none') {
|
|
loadAuthRules();
|
|
}
|
|
})
|
|
.catch(error => {
|
|
log(`Failed to add rule: ${error.message}`, 'ERROR');
|
|
});
|
|
}
|
|
|
|
// Add auth rule via SimplePool (kind 23456 event) - FIXED to match working test pattern
|
|
async function addAuthRuleViaWebSocket(ruleData) {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to add auth rules');
|
|
}
|
|
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
try {
|
|
log(`Adding auth rule: ${ruleData.rule_type} - ${ruleData.pattern_value.substring(0, 16)}...`, 'INFO');
|
|
|
|
// Map client-side rule types to command array format (matching working tests)
|
|
let commandRuleType, commandPatternType;
|
|
|
|
switch (ruleData.rule_type) {
|
|
case 'pubkey_blacklist':
|
|
commandRuleType = 'blacklist';
|
|
commandPatternType = 'pubkey';
|
|
break;
|
|
case 'pubkey_whitelist':
|
|
commandRuleType = 'whitelist';
|
|
commandPatternType = 'pubkey';
|
|
break;
|
|
case 'hash_blacklist':
|
|
commandRuleType = 'blacklist';
|
|
commandPatternType = 'hash';
|
|
break;
|
|
default:
|
|
throw new Error(`Unknown rule type: ${ruleData.rule_type}`);
|
|
}
|
|
|
|
// Create command array in the same format as working tests
|
|
// Format: ["blacklist", "pubkey", "abc123..."] or ["whitelist", "pubkey", "def456..."]
|
|
const command_array = [commandRuleType, commandPatternType, ruleData.pattern_value];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// DEBUG: Log the complete event structure being sent
|
|
console.log('=== AUTH RULE EVENT DEBUG (Administrator API) ===');
|
|
console.log('Original Rule Data:', ruleData);
|
|
console.log('Command Array:', command_array);
|
|
console.log('Encrypted Content:', encrypted_content.substring(0, 50) + '...');
|
|
console.log('Auth Event (before signing):', JSON.stringify(authEvent, null, 2));
|
|
console.log('=== END AUTH RULE EVENT DEBUG ===');
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Add Auth Rule Relay ${index} (${url}): Event published successfully`);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('INFO', `Add auth rule relay ${index} publish success`, 'PUBLISH');
|
|
}
|
|
} else {
|
|
console.error(`❌ Add Auth Rule Relay ${index} (${url}): Publish failed:`, result.reason);
|
|
if (typeof logTestEvent === 'function') {
|
|
logTestEvent('ERROR', `Add auth rule relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected add auth rule event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Auth rule added successfully', 'INFO');
|
|
|
|
} catch (error) {
|
|
log(`Failed to add auth rule: ${error.message}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ================================
|
|
// TEST FUNCTIONS FOR ADMIN API
|
|
// ================================
|
|
|
|
// Test event logging function
|
|
function logTestEvent(direction, message, type = 'INFO') {
|
|
const testLog = document.getElementById('test-event-log');
|
|
if (!testLog) return;
|
|
|
|
const timestamp = new Date().toISOString().split('T')[1].split('.')[0];
|
|
const logEntry = document.createElement('div');
|
|
logEntry.className = 'log-entry';
|
|
|
|
const directionColor = direction === 'SENT' ? '#007bff' : '#28a745';
|
|
logEntry.innerHTML = `
|
|
<span class="log-timestamp">${timestamp}</span>
|
|
<span style="color: ${directionColor}; font-weight: bold;">[${direction}]</span>
|
|
<span style="color: #666;">[${type}]</span>
|
|
${message}
|
|
`;
|
|
|
|
testLog.appendChild(logEntry);
|
|
testLog.scrollTop = testLog.scrollHeight;
|
|
}
|
|
|
|
// Test: Get Auth Rules
|
|
async function testGetAuthRules() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', 'Testing Get Auth Rules command...', 'TEST');
|
|
|
|
// Create command array for getting auth rules
|
|
const command_array = '["auth_query", "all"]';
|
|
|
|
// Encrypt the command content using NIP-44
|
|
const encrypted_content = await encryptForRelay(command_array);
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt auth query command');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["p", getRelayPubkey()]
|
|
],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Get Auth Rules event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Add Blacklist relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Add Blacklist relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test add blacklist event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Get Auth Rules command sent successfully', 'SUCCESS');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Get Auth Rules test failed: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Test: Clear All Auth Rules
|
|
async function testClearAuthRules() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', 'Testing Clear All Auth Rules command...', 'TEST');
|
|
|
|
// Create command array for clearing auth rules
|
|
const command_array = '["system_command", "clear_all_auth_rules"]';
|
|
|
|
// Encrypt the command content using NIP-44
|
|
const encrypted_content = await encryptForRelay(command_array);
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt clear auth rules command');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["p", getRelayPubkey()]
|
|
],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Clear Auth Rules event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Add Whitelist relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Add Whitelist relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test add whitelist event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Clear Auth Rules command sent successfully', 'SUCCESS');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Clear Auth Rules test failed: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Test: Add Blacklist
|
|
async function testAddBlacklist() {
|
|
const testPubkeyInput = document.getElementById('test-pubkey-input');
|
|
let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : '';
|
|
|
|
// Use a default test pubkey if none provided
|
|
if (!testPubkey) {
|
|
testPubkey = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
|
|
logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO');
|
|
}
|
|
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', `Testing Add Blacklist for pubkey: ${testPubkey.substring(0, 16)}...`, 'TEST');
|
|
|
|
// Create command array for adding blacklist rule
|
|
const command_array = `["blacklist", "pubkey", "${testPubkey}"]`;
|
|
|
|
// Encrypt the command content using NIP-44
|
|
const encrypted_content = await encryptForRelay(command_array);
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt blacklist command');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["p", getRelayPubkey()]
|
|
],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Add Blacklist event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Config Query relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Config Query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test config query event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Add Blacklist command sent successfully', 'SUCCESS');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Add Blacklist test failed: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Test: Add Whitelist
|
|
async function testAddWhitelist() {
|
|
const testPubkeyInput = document.getElementById('test-pubkey-input');
|
|
let testPubkey = testPubkeyInput ? testPubkeyInput.value.trim() : '';
|
|
|
|
// Use a default test pubkey if none provided
|
|
if (!testPubkey) {
|
|
testPubkey = 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890';
|
|
logTestEvent('INFO', `Using default test pubkey: ${testPubkey}`, 'INFO');
|
|
}
|
|
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', `Testing Add Whitelist for pubkey: ${testPubkey.substring(0, 16)}...`, 'TEST');
|
|
|
|
// Create command array for adding whitelist rule
|
|
const command_array = `["whitelist", "pubkey", "${testPubkey}"]`;
|
|
|
|
// Encrypt the command content using NIP-44
|
|
const encrypted_content = await encryptForRelay(command_array);
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt whitelist command');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const authEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["p", getRelayPubkey()]
|
|
],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(authEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Add Whitelist event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Post Event relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Post Event relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test post event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Add Whitelist command sent successfully', 'SUCCESS');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Add Whitelist test failed: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Test: Config Query
|
|
async function testConfigQuery() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test admin API', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', 'Testing Config Query command...', 'TEST');
|
|
|
|
// Create command array for getting configuration
|
|
const command_array = '["config_query", "all"]';
|
|
|
|
// Encrypt the command content using NIP-44
|
|
const encrypted_content = await encryptForRelay(command_array);
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt config query command');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const configEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["p", getRelayPubkey()]
|
|
],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(configEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Config Query event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Config Query relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Config Query relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test config query event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Config Query command sent successfully', 'SUCCESS');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Config Query test failed: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Test: Post Basic Event
|
|
async function testPostEvent() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
logTestEvent('ERROR', 'Must be logged in to test event posting', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
logTestEvent('ERROR', 'SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
logTestEvent('INFO', 'Testing basic event posting...', 'TEST');
|
|
|
|
// Create a simple kind 1 text note event
|
|
const testEvent = {
|
|
kind: 1,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [
|
|
["t", "test"],
|
|
["client", "c-relay-admin-api"]
|
|
],
|
|
content: `Test event from C-Relay Admin API at ${new Date().toISOString()}`
|
|
};
|
|
|
|
logTestEvent('SENT', `Test event (before signing): ${JSON.stringify(testEvent)}`, 'EVENT');
|
|
|
|
// Sign the event using NIP-07
|
|
const signedEvent = await window.nostr.signEvent(testEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
logTestEvent('SENT', `Signed test event: ${JSON.stringify(signedEvent)}`, 'EVENT');
|
|
|
|
// Publish via SimplePool to the same relay with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
logTestEvent('INFO', `Publishing to relay: ${url}`, 'INFO');
|
|
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes instead of Promise.any
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
logTestEvent('INFO', `Test Post Event relay ${index} publish success`, 'PUBLISH');
|
|
} else {
|
|
logTestEvent('ERROR', `Test Post Event relay ${index} publish failed: ${result.reason?.message || result.reason}`, 'PUBLISH');
|
|
}
|
|
});
|
|
|
|
// Throw error if all relays failed
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected test post event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
logTestEvent('INFO', 'Test event published successfully!', 'SUCCESS');
|
|
logTestEvent('INFO', 'Check if the event appears in the subscription above...', 'INFO');
|
|
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `Post Event test failed: ${error.message}`, 'ERROR');
|
|
console.error('Post Event test error:', error);
|
|
}
|
|
}
|
|
|
|
// Helper function to encrypt content for relay using NIP-44
|
|
async function encryptForRelay(content) {
|
|
try {
|
|
logTestEvent('INFO', `Encrypting content: ${content}`, 'DEBUG');
|
|
|
|
// Get the relay public key for encryption
|
|
const relayPubkey = getRelayPubkey();
|
|
|
|
// Check if we have access to NIP-44 encryption via nostr-tools
|
|
if (!window.NostrTools || !window.NostrTools.nip44) {
|
|
throw new Error('NIP-44 encryption not available - nostr-tools library missing');
|
|
}
|
|
|
|
// Get user's private key for encryption
|
|
// We need to use the NIP-07 extension to get the private key
|
|
if (!window.nostr || !window.nostr.nip44) {
|
|
throw new Error('NIP-44 encryption not available via NIP-07 extension');
|
|
}
|
|
|
|
// Use NIP-07 extension's NIP-44 encrypt method
|
|
const encrypted_content = await window.nostr.nip44.encrypt(relayPubkey, content);
|
|
|
|
if (!encrypted_content) {
|
|
throw new Error('NIP-44 encryption returned empty result');
|
|
}
|
|
|
|
logTestEvent('INFO', `Successfully encrypted content using NIP-44`, 'DEBUG');
|
|
logTestEvent('INFO', `Encrypted content: ${encrypted_content.substring(0, 50)}...`, 'DEBUG');
|
|
|
|
return encrypted_content;
|
|
} catch (error) {
|
|
logTestEvent('ERROR', `NIP-44 encryption failed: ${error.message}`, 'ERROR');
|
|
|
|
// Fallback: Try using nostr-tools directly if NIP-07 fails
|
|
try {
|
|
logTestEvent('INFO', 'Attempting fallback encryption with nostr-tools...', 'DEBUG');
|
|
|
|
if (!window.NostrTools || !window.NostrTools.nip44) {
|
|
throw new Error('nostr-tools NIP-44 not available');
|
|
}
|
|
|
|
// We need the user's private key, but we can't get it directly
|
|
// This is a security limitation - we should use NIP-07
|
|
throw new Error('Cannot access private key for direct encryption - use NIP-07 extension');
|
|
|
|
} catch (fallbackError) {
|
|
logTestEvent('ERROR', `Fallback encryption failed: ${fallbackError.message}`, 'ERROR');
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send NIP-17 Direct Message to relay using NIP-59 layering
|
|
async function sendNIP17DM() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
log('Must be logged in to send DM', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!isRelayConnected || !relayPubkey) {
|
|
log('Must be connected to relay to send DM', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
const message = dmOutbox.value.trim();
|
|
if (!message) {
|
|
log('Please enter a message to send', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
// Capability checks
|
|
if (!window.nostr || !window.nostr.nip44 || !window.nostr.signEvent) {
|
|
log('NIP-17 DMs require a NIP-07 extension with NIP-44 support', 'ERROR');
|
|
alert('NIP-17 DMs require a NIP-07 extension with NIP-44 support. Please install and configure a compatible extension.');
|
|
return;
|
|
}
|
|
|
|
if (!window.NostrTools || !window.NostrTools.generateSecretKey || !window.NostrTools.getPublicKey || !window.NostrTools.finalizeEvent) {
|
|
log('NostrTools library not available for ephemeral key operations', 'ERROR');
|
|
alert('NostrTools library not available. Please ensure nostr.bundle.js is loaded.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
log(`Sending NIP-17 DM to relay: ${message.substring(0, 50)}...`, 'INFO');
|
|
|
|
// Step 1: Build unsigned rumor (kind 14)
|
|
const rumor = {
|
|
kind: 14,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000), // Canonical time for rumor
|
|
tags: [["p", relayPubkey]],
|
|
content: message
|
|
};
|
|
// NOTE: Rumor remains unsigned per NIP-59
|
|
|
|
log('Rumor built (unsigned), creating seal...', 'INFO');
|
|
|
|
// Step 2: Create seal (kind 13)
|
|
const seal = {
|
|
kind: 13,
|
|
pubkey: userPubkey,
|
|
created_at: randomNow(), // Randomized to past for metadata protection
|
|
tags: [], // Empty tags per NIP-59
|
|
content: await window.nostr.nip44.encrypt(relayPubkey, JSON.stringify(rumor))
|
|
};
|
|
|
|
// Sign seal with long-term key
|
|
const signedSeal = await window.nostr.signEvent(seal);
|
|
if (!signedSeal || !signedSeal.sig) {
|
|
throw new Error('Failed to sign seal event');
|
|
}
|
|
|
|
log('Seal created and signed, creating gift wrap...', 'INFO');
|
|
|
|
// Step 3: Create gift wrap (kind 1059) with ephemeral key
|
|
const ephemeralPriv = window.NostrTools.generateSecretKey();
|
|
const ephemeralPub = window.NostrTools.getPublicKey(ephemeralPriv);
|
|
|
|
const giftWrap = {
|
|
kind: 1059,
|
|
pubkey: ephemeralPub,
|
|
created_at: randomNow(), // Randomized to past for metadata protection
|
|
tags: [["p", relayPubkey]],
|
|
content: await window.NostrTools.nip44.encrypt(
|
|
JSON.stringify(signedSeal),
|
|
window.NostrTools.nip44.getConversationKey(ephemeralPriv, relayPubkey)
|
|
)
|
|
};
|
|
|
|
// Sign gift wrap with ephemeral key using finalizeEvent
|
|
const signedGiftWrap = window.NostrTools.finalizeEvent(giftWrap, ephemeralPriv);
|
|
if (!signedGiftWrap || !signedGiftWrap.sig) {
|
|
throw new Error('Failed to sign gift wrap event');
|
|
}
|
|
|
|
// DEBUG: Log NIP-17 event details when created
|
|
console.log('=== NIP-17 EVENT CREATED ===');
|
|
console.log('Full event:', JSON.stringify(signedGiftWrap, null, 2));
|
|
console.log('Timestamp:', signedGiftWrap.created_at);
|
|
console.log('Local date time:', new Date(signedGiftWrap.created_at * 1000).toLocaleString());
|
|
console.log('=== END NIP-17 EVENT DEBUG ===');
|
|
|
|
log('NIP-17 DM event created and signed with ephemeral key, publishing...', 'INFO');
|
|
|
|
// Publish via SimplePool
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedGiftWrap);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
log(`✅ NIP-17 DM published successfully to relay ${index}`, 'INFO');
|
|
} else {
|
|
log(`❌ NIP-17 DM failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected NIP-17 DM event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
// Clear the outbox and show success
|
|
dmOutbox.value = '';
|
|
log('NIP-17 DM sent successfully', 'INFO');
|
|
|
|
// Add to inbox for display
|
|
addMessageToInbox('sent', message, new Date().toLocaleString());
|
|
|
|
} catch (error) {
|
|
log(`Failed to send NIP-17 DM: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Add message to inbox display
|
|
function addMessageToInbox(direction, message, timestamp, pubkey = null) {
|
|
if (!dmInbox) return;
|
|
|
|
const messageDiv = document.createElement('div');
|
|
messageDiv.className = 'log-entry';
|
|
|
|
const directionColor = direction === 'sent' ? '#007bff' : '#28a745';
|
|
|
|
// Convert newlines to <br> tags for proper HTML display
|
|
const formattedMessage = message.replace(/\n/g, '<br>');
|
|
|
|
// Add pubkey display for received messages
|
|
let pubkeyDisplay = '';
|
|
if (pubkey && direction === 'received') {
|
|
try {
|
|
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
|
pubkeyDisplay = ` <span style="color: #666; font-size: 11px;">(${npub})</span>`;
|
|
} catch (error) {
|
|
console.error('Failed to encode pubkey to npub:', error);
|
|
}
|
|
}
|
|
|
|
messageDiv.innerHTML = `
|
|
<span class="log-timestamp">${timestamp}</span>
|
|
<span style="color: ${directionColor}; font-weight: bold;">[${direction.toUpperCase()}]</span>
|
|
<span style="white-space: pre-wrap;">${formattedMessage}${pubkeyDisplay}</span>
|
|
`;
|
|
|
|
// Remove the "No messages received yet" placeholder if it exists
|
|
const placeholder = dmInbox.querySelector('.log-entry');
|
|
if (placeholder && placeholder.textContent === 'No messages received yet.') {
|
|
dmInbox.innerHTML = '';
|
|
}
|
|
|
|
// Add new message at the top
|
|
dmInbox.insertBefore(messageDiv, dmInbox.firstChild);
|
|
|
|
// Limit to last 50 messages
|
|
while (dmInbox.children.length > 50) {
|
|
dmInbox.removeChild(dmInbox.lastChild);
|
|
}
|
|
}
|
|
|
|
// Update relay info in header
|
|
function updateRelayInfoInHeader() {
|
|
const relayNameElement = document.getElementById('relay-name');
|
|
const relayPubkeyElement = document.getElementById('relay-pubkey');
|
|
const relayDescriptionElement = document.getElementById('relay-description');
|
|
|
|
if (!relayNameElement || !relayPubkeyElement || !relayDescriptionElement) {
|
|
return;
|
|
}
|
|
|
|
// Get relay info from NIP-11 data or use defaults
|
|
const relayInfo = getRelayInfo();
|
|
const relayName = relayInfo.name || 'C-Relay';
|
|
const relayDescription = relayInfo.description || 'Nostr Relay';
|
|
|
|
// Convert relay pubkey to npub
|
|
let relayNpub = 'Loading...';
|
|
if (relayPubkey) {
|
|
try {
|
|
relayNpub = window.NostrTools.nip19.npubEncode(relayPubkey);
|
|
} catch (error) {
|
|
console.log('Failed to encode relay pubkey to npub:', error.message);
|
|
relayNpub = relayPubkey.substring(0, 16) + '...';
|
|
}
|
|
}
|
|
|
|
// Format npub into 3 lines of 21 characters each, with spaces dividing each line into 3 groups of 7 characters
|
|
let formattedNpub = relayNpub;
|
|
if (relayNpub.length === 63) {
|
|
const line1 = relayNpub.substring(0, 7) + ' ' + relayNpub.substring(7, 14) + ' ' + relayNpub.substring(14, 21);
|
|
const line2 = relayNpub.substring(21, 28) + ' ' + relayNpub.substring(28, 35) + ' ' + relayNpub.substring(35, 42);
|
|
const line3 = relayNpub.substring(42, 49) + ' ' + relayNpub.substring(49, 56) + ' ' + relayNpub.substring(56, 63);
|
|
formattedNpub = line1 + '\n' + line2 + '\n' + line3;
|
|
}
|
|
|
|
relayNameElement.textContent = relayName;
|
|
relayPubkeyElement.textContent = formattedNpub;
|
|
relayDescriptionElement.textContent = relayDescription;
|
|
}
|
|
|
|
// Global variable to store relay info from NIP-11 or config
|
|
let relayInfoData = null;
|
|
|
|
// Helper function to get relay info from stored data
|
|
function getRelayInfo() {
|
|
// Return stored relay info if available, otherwise defaults
|
|
if (relayInfoData) {
|
|
return relayInfoData;
|
|
}
|
|
|
|
// Default values
|
|
return {
|
|
name: 'C-Relay',
|
|
description: 'Nostr Relay',
|
|
pubkey: relayPubkey
|
|
};
|
|
}
|
|
|
|
// Update stored relay info when config is loaded
|
|
function updateStoredRelayInfo(configData) {
|
|
if (configData && configData.data) {
|
|
// Extract relay info from config data
|
|
const relayName = configData.data.find(item => item.key === 'relay_name')?.value || 'C-Relay';
|
|
const relayDescription = configData.data.find(item => item.key === 'relay_description')?.value || 'Nostr Relay';
|
|
|
|
relayInfoData = {
|
|
name: relayName,
|
|
description: relayDescription,
|
|
pubkey: relayPubkey
|
|
};
|
|
|
|
// Update header immediately
|
|
updateRelayInfoInHeader();
|
|
}
|
|
}
|
|
|
|
// Helper function to get relay pubkey
|
|
function getRelayPubkey() {
|
|
// Use the dynamically fetched relay pubkey if available
|
|
if (relayPubkey) {
|
|
return relayPubkey;
|
|
}
|
|
|
|
// No fallback - throw error if relay pubkey not available
|
|
throw new Error('Relay pubkey not available. Please connect to relay first.');
|
|
}
|
|
|
|
// Enhanced SimplePool message handler to capture test responses
|
|
function enhancePoolForTesting() {
|
|
// SimplePool handles message parsing automatically, so we just need to
|
|
// ensure our event handlers log appropriately. This is already done
|
|
// in the subscription onevent callback.
|
|
console.log('SimplePool enhanced for testing - automatic message handling enabled');
|
|
}
|
|
|
|
// Generate random test pubkey function
|
|
function generateRandomTestKey() {
|
|
// Generate 32 random bytes (64 hex characters) for a valid pubkey
|
|
const randomBytes = new Uint8Array(32);
|
|
crypto.getRandomValues(randomBytes);
|
|
|
|
// Convert to hex string
|
|
const hexPubkey = Array.from(randomBytes)
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
|
|
// Set the generated key in the input field
|
|
const testPubkeyInput = document.getElementById('test-pubkey-input');
|
|
if (testPubkeyInput) {
|
|
testPubkeyInput.value = hexPubkey;
|
|
logTestEvent('INFO', `Generated random test pubkey: ${hexPubkey.substring(0, 16)}...`, 'KEYGEN');
|
|
}
|
|
|
|
return hexPubkey;
|
|
}
|
|
|
|
// ================================
|
|
// DATABASE STATISTICS FUNCTIONS
|
|
// ================================
|
|
|
|
// Send restart command to restart the relay using Administrator API
|
|
async function sendRestartCommand() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
log('Must be logged in to restart relay', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
log('SimplePool connection not available', 'ERROR');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
log('Sending restart command to relay...', 'INFO');
|
|
|
|
// Create command array for restart
|
|
const command_array = ["system_command", "restart"];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const restartEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(restartEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
// Publish via SimplePool
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Check if any relay accepted the event
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
log(`Restart command published successfully to relay ${index}`, 'INFO');
|
|
} else {
|
|
log(`Restart command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected restart command. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Restart command sent successfully - relay should restart shortly...', 'INFO');
|
|
|
|
// Update connection status to indicate restart is in progress
|
|
updateRelayConnectionStatus('connecting');
|
|
relayConnectionStatus.textContent = 'RESTARTING...';
|
|
|
|
// The relay will disconnect and need to be reconnected after restart
|
|
// This will be handled by the WebSocket disconnection event
|
|
|
|
} catch (error) {
|
|
log(`Failed to send restart command: ${error.message}`, 'ERROR');
|
|
updateRelayConnectionStatus('error');
|
|
}
|
|
}
|
|
|
|
// Send stats_query command to get database statistics using Administrator API (inner events)
|
|
async function sendStatsQuery() {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
log('Must be logged in to query database statistics', 'ERROR');
|
|
updateStatsStatus('error', 'Not logged in');
|
|
return;
|
|
}
|
|
|
|
if (!relayPool) {
|
|
log('SimplePool connection not available', 'ERROR');
|
|
updateStatsStatus('error', 'No relay connection');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
updateStatsStatus('loading', 'Querying database...');
|
|
|
|
// Create command array for stats query
|
|
const command_array = ["stats_query", "all"];
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const statsEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(statsEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
log('Sending stats query command...', 'INFO');
|
|
|
|
// Publish via SimplePool
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Check if any relay accepted the event
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
log(`Stats query published successfully to relay ${index}`, 'INFO');
|
|
} else {
|
|
log(`Stats query failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected stats query event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Stats query command sent successfully - waiting for response...', 'INFO');
|
|
updateStatsStatus('waiting', 'Waiting for response...');
|
|
|
|
} catch (error) {
|
|
log(`Failed to send stats query: ${error.message}`, 'ERROR');
|
|
updateStatsStatus('error', error.message);
|
|
}
|
|
}
|
|
|
|
// Handle stats_query response and populate tables
|
|
function handleStatsQueryResponse(responseData) {
|
|
try {
|
|
log('Processing stats query response...', 'INFO');
|
|
console.log('Stats response data:', responseData);
|
|
|
|
if (responseData.query_type !== 'stats_query') {
|
|
log('Ignoring non-stats response', 'WARNING');
|
|
return;
|
|
}
|
|
|
|
// Populate overview table
|
|
populateStatsOverview(responseData);
|
|
|
|
// Populate event kinds table
|
|
populateStatsKinds(responseData);
|
|
|
|
// Populate time-based statistics
|
|
populateStatsTime(responseData);
|
|
|
|
// Populate top pubkeys table
|
|
populateStatsPubkeys(responseData);
|
|
|
|
updateStatsStatus('loaded');
|
|
log('Database statistics updated successfully', 'INFO');
|
|
|
|
} catch (error) {
|
|
log(`Error processing stats response: ${error.message}`, 'ERROR');
|
|
updateStatsStatus('error', 'Failed to process response');
|
|
}
|
|
}
|
|
|
|
// Update statistics display from real-time monitoring event
|
|
function updateStatsFromMonitoringEvent(monitoringData) {
|
|
try {
|
|
|
|
if (monitoringData.data_type !== 'event_kinds') {
|
|
return;
|
|
}
|
|
|
|
// Update total events count and track rate for chart
|
|
if (monitoringData.total_events !== undefined) {
|
|
const currentTotal = monitoringData.total_events;
|
|
updateStatsCell('total-events', currentTotal.toString());
|
|
|
|
// Calculate new events since last update for chart
|
|
if (previousTotalEvents > 0) {
|
|
const newEvents = currentTotal - previousTotalEvents;
|
|
if (newEvents > 0 && eventRateChart) {
|
|
console.log(`Adding ${newEvents} new events to rate chart (${currentTotal} - ${previousTotalEvents})`);
|
|
eventRateChart.addValue(newEvents);
|
|
}
|
|
}
|
|
|
|
// Update previous total for next calculation
|
|
previousTotalEvents = currentTotal;
|
|
}
|
|
|
|
// Update event kinds table with real-time data
|
|
if (monitoringData.kinds && Array.isArray(monitoringData.kinds)) {
|
|
populateStatsKindsFromMonitoring(monitoringData.kinds, monitoringData.total_events);
|
|
}
|
|
|
|
} catch (error) {
|
|
log(`Error updating stats from monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Update statistics display from time_stats monitoring event
|
|
function updateStatsFromTimeMonitoringEvent(monitoringData) {
|
|
try {
|
|
if (monitoringData.data_type !== 'time_stats') {
|
|
return;
|
|
}
|
|
|
|
// Update time-based statistics table with real-time data
|
|
if (monitoringData.periods && Array.isArray(monitoringData.periods)) {
|
|
// Use the existing populateStatsTime function which expects the nested time_stats object
|
|
const timeStats = { last_24h: 0, last_7d: 0, last_30d: 0 };
|
|
|
|
// Extract values from periods array
|
|
monitoringData.periods.forEach(period => {
|
|
if (period.period === 'last_24h') timeStats.last_24h = period.count;
|
|
else if (period.period === 'last_7d') timeStats.last_7d = period.count;
|
|
else if (period.period === 'last_30d') timeStats.last_30d = period.count;
|
|
});
|
|
|
|
populateStatsTime({ time_stats: timeStats });
|
|
}
|
|
|
|
} catch (error) {
|
|
log(`Error updating time stats from monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Update statistics display from top_pubkeys monitoring event
|
|
function updateStatsFromTopPubkeysMonitoringEvent(monitoringData) {
|
|
try {
|
|
if (monitoringData.data_type !== 'top_pubkeys') {
|
|
return;
|
|
}
|
|
|
|
// Update top pubkeys table with real-time data
|
|
if (monitoringData.pubkeys && Array.isArray(monitoringData.pubkeys)) {
|
|
// Pass total_events from monitoring data to the function
|
|
populateStatsPubkeysFromMonitoring(monitoringData.pubkeys, monitoringData.total_events || 0);
|
|
}
|
|
|
|
} catch (error) {
|
|
log(`Error updating top pubkeys from monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
|
|
// Update statistics display from subscription_details monitoring event
|
|
function updateStatsFromSubscriptionDetailsMonitoringEvent(monitoringData) {
|
|
try {
|
|
// DEBUG: Log every subscription_details event that arrives at the webpage
|
|
// console.log('subscription_details', JSON.stringify(monitoringData, null, 2));
|
|
console.log('subscription_details decoded:', monitoringData);
|
|
|
|
if (monitoringData.data_type !== 'subscription_details') {
|
|
return;
|
|
}
|
|
|
|
// Update subscription details table with real-time data
|
|
if (monitoringData.data && Array.isArray(monitoringData.data.subscriptions)) {
|
|
populateSubscriptionDetailsTable(monitoringData.data.subscriptions);
|
|
}
|
|
|
|
} catch (error) {
|
|
log(`Error updating subscription details from monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Update statistics display from CPU metrics monitoring event
|
|
function updateStatsFromCpuMonitoringEvent(monitoringData) {
|
|
try {
|
|
if (monitoringData.data_type !== 'cpu_metrics') {
|
|
return;
|
|
}
|
|
|
|
// Update CPU metrics in the database statistics table
|
|
if (monitoringData.process_id !== undefined) {
|
|
updateStatsCell('process-id', monitoringData.process_id.toString());
|
|
}
|
|
|
|
if (monitoringData.memory_usage_mb !== undefined) {
|
|
updateStatsCell('memory-usage', monitoringData.memory_usage_mb.toFixed(1) + ' MB');
|
|
}
|
|
|
|
if (monitoringData.current_cpu_core !== undefined) {
|
|
updateStatsCell('cpu-core', 'Core ' + monitoringData.current_cpu_core);
|
|
}
|
|
|
|
// Calculate CPU usage percentage if we have the data
|
|
if (monitoringData.process_cpu_time !== undefined && monitoringData.system_cpu_time !== undefined) {
|
|
// For now, just show the raw process CPU time (simplified)
|
|
// In a real implementation, you'd calculate deltas over time
|
|
updateStatsCell('cpu-usage', monitoringData.process_cpu_time + ' ticks');
|
|
}
|
|
|
|
} catch (error) {
|
|
log(`Error updating CPU metrics from monitoring event: ${error.message}`, 'ERROR');
|
|
}
|
|
}
|
|
|
|
// Populate event kinds table from monitoring data
|
|
function populateStatsKindsFromMonitoring(kindsData, totalEvents) {
|
|
const tableBody = document.getElementById('stats-kinds-table-body');
|
|
if (!tableBody) return;
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (kindsData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No event data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
kindsData.forEach(kind => {
|
|
const row = document.createElement('tr');
|
|
const percentage = totalEvents > 0 ? ((kind.count / totalEvents) * 100).toFixed(1) : '0.0';
|
|
row.innerHTML = `
|
|
<td>${kind.kind}</td>
|
|
<td>${kind.count}</td>
|
|
<td>${percentage}%</td>
|
|
`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Populate database overview table
|
|
function populateStatsOverview(data) {
|
|
if (!data) return;
|
|
|
|
// Update individual cells with flash animation for changed values
|
|
updateStatsCell('db-size', data.database_size_bytes ? formatFileSize(data.database_size_bytes) : '-');
|
|
updateStatsCell('total-events', data.total_events || '-');
|
|
updateStatsCell('oldest-event', data.database_created_at ? formatTimestamp(data.database_created_at) : '-');
|
|
updateStatsCell('newest-event', data.latest_event_at ? formatTimestamp(data.latest_event_at) : '-');
|
|
}
|
|
|
|
// Populate event kinds distribution table
|
|
function populateStatsKinds(data) {
|
|
const tableBody = document.getElementById('stats-kinds-table-body');
|
|
if (!tableBody || !data.event_kinds) return;
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (data.event_kinds.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="3" style="text-align: center; font-style: italic;">No event data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
data.event_kinds.forEach(kind => {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = `
|
|
<td>${kind.kind}</td>
|
|
<td>${kind.count}</td>
|
|
<td>${kind.percentage}%</td>
|
|
`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Populate time-based statistics table
|
|
function populateStatsTime(data) {
|
|
if (!data) return;
|
|
|
|
// Access the nested time_stats object from backend response
|
|
const timeStats = data.time_stats || {};
|
|
|
|
// Update cells with flash animation for changed values
|
|
updateStatsCell('events-24h', timeStats.last_24h || '0');
|
|
updateStatsCell('events-7d', timeStats.last_7d || '0');
|
|
updateStatsCell('events-30d', timeStats.last_30d || '0');
|
|
}
|
|
|
|
// Populate top pubkeys table
|
|
function populateStatsPubkeys(data) {
|
|
const tableBody = document.getElementById('stats-pubkeys-table-body');
|
|
if (!tableBody || !data.top_pubkeys) return;
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (data.top_pubkeys.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
data.top_pubkeys.forEach((pubkey, index) => {
|
|
const row = document.createElement('tr');
|
|
// Convert hex pubkey to npub for display
|
|
let displayPubkey = pubkey.pubkey || '-';
|
|
let npubLink = displayPubkey;
|
|
try {
|
|
if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) {
|
|
const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey);
|
|
displayPubkey = npub;
|
|
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to encode pubkey to npub:', error.message);
|
|
}
|
|
row.innerHTML = `
|
|
<td>${index + 1}</td>
|
|
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
|
|
<td>${pubkey.event_count}</td>
|
|
<td>${pubkey.percentage}%</td>
|
|
`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Populate top pubkeys table from monitoring data
|
|
function populateStatsPubkeysFromMonitoring(pubkeysData, totalEvents) {
|
|
const tableBody = document.getElementById('stats-pubkeys-table-body');
|
|
if (!tableBody || !pubkeysData || !Array.isArray(pubkeysData)) return;
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (pubkeysData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No pubkey data</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
pubkeysData.forEach((pubkey, index) => {
|
|
const row = document.createElement('tr');
|
|
// Convert hex pubkey to npub for display
|
|
let displayPubkey = pubkey.pubkey || '-';
|
|
let npubLink = displayPubkey;
|
|
try {
|
|
if (pubkey.pubkey && pubkey.pubkey.length === 64 && /^[0-9a-fA-F]+$/.test(pubkey.pubkey)) {
|
|
const npub = window.NostrTools.nip19.npubEncode(pubkey.pubkey);
|
|
displayPubkey = npub;
|
|
npubLink = `<a href="https://njump.me/${npub}" target="_blank" class="npub-link">${npub}</a>`;
|
|
}
|
|
} catch (error) {
|
|
console.log('Failed to encode pubkey to npub:', error.message);
|
|
}
|
|
|
|
// Calculate percentage using totalEvents parameter
|
|
const percentage = totalEvents > 0 ? ((pubkey.event_count / totalEvents) * 100).toFixed(1) : '0.0';
|
|
|
|
row.innerHTML = `
|
|
<td>${index + 1}</td>
|
|
<td style="font-family: 'Courier New', monospace; font-size: 12px; word-break: break-all;">${npubLink}</td>
|
|
<td>${pubkey.event_count}</td>
|
|
<td>${percentage}%</td>
|
|
`;
|
|
tableBody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Populate subscription details table from monitoring data
|
|
function populateSubscriptionDetailsTable(subscriptionsData) {
|
|
const tableBody = document.getElementById('subscription-details-table-body');
|
|
if (!tableBody || !subscriptionsData || !Array.isArray(subscriptionsData)) return;
|
|
|
|
// Store current expand/collapse state before rebuilding
|
|
const expandedGroups = new Set();
|
|
const headerRows = tableBody.querySelectorAll('.subscription-group-header');
|
|
headerRows.forEach(header => {
|
|
const wsiPointer = header.getAttribute('data-wsi-pointer');
|
|
const isExpanded = header.getAttribute('data-expanded') === 'true';
|
|
if (isExpanded) {
|
|
expandedGroups.add(wsiPointer);
|
|
}
|
|
});
|
|
|
|
tableBody.innerHTML = '';
|
|
|
|
if (subscriptionsData.length === 0) {
|
|
const row = document.createElement('tr');
|
|
row.innerHTML = '<td colspan="4" style="text-align: center; font-style: italic;">No active subscriptions</td>';
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
// Sort subscriptions by wsi_pointer to group them together
|
|
subscriptionsData.sort((a, b) => {
|
|
const wsiA = a.wsi_pointer || '';
|
|
const wsiB = b.wsi_pointer || '';
|
|
return wsiA.localeCompare(wsiB);
|
|
});
|
|
|
|
// Group subscriptions by wsi_pointer
|
|
const groupedSubscriptions = {};
|
|
subscriptionsData.forEach(sub => {
|
|
const wsiKey = sub.wsi_pointer || 'N/A';
|
|
if (!groupedSubscriptions[wsiKey]) {
|
|
groupedSubscriptions[wsiKey] = [];
|
|
}
|
|
groupedSubscriptions[wsiKey].push(sub);
|
|
});
|
|
|
|
// Create rows for each group
|
|
Object.entries(groupedSubscriptions).forEach(([wsiPointer, subscriptions]) => {
|
|
// Calculate group summary
|
|
const subCount = subscriptions.length;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const oldestDuration = Math.max(...subscriptions.map(s => now - s.created_at));
|
|
const oldestDurationStr = formatDuration(oldestDuration);
|
|
|
|
// Calculate total query stats for this connection
|
|
const totalQueries = subscriptions.reduce((sum, s) => sum + (s.db_queries_executed || 0), 0);
|
|
const totalRows = subscriptions.reduce((sum, s) => sum + (s.db_rows_returned || 0), 0);
|
|
const avgQueryRate = subscriptions.length > 0 ? (subscriptions[0].query_rate_per_min || 0) : 0;
|
|
const clientIp = subscriptions.length > 0 ? (subscriptions[0].client_ip || 'unknown') : 'unknown';
|
|
|
|
// Create header row (summary)
|
|
const headerRow = document.createElement('tr');
|
|
headerRow.className = 'subscription-group-header';
|
|
headerRow.setAttribute('data-wsi-pointer', wsiPointer);
|
|
const wasExpanded = expandedGroups.has(wsiPointer);
|
|
headerRow.setAttribute('data-expanded', wasExpanded ? 'true' : 'false');
|
|
|
|
headerRow.innerHTML = `
|
|
<td colspan="4" style="padding: 8px;">
|
|
<span class="expand-icon" style="display: inline-block; width: 20px; transition: transform 0.2s;">▶</span>
|
|
<strong style="font-family: 'Courier New', monospace; font-size: 12px;">IP: ${clientIp}</strong>
|
|
<span style="color: #666; margin-left: 10px; font-size: 11px;">
|
|
WS: ${wsiPointer} |
|
|
Subs: ${subCount} |
|
|
Queries: ${totalQueries.toLocaleString()} |
|
|
Rows: ${totalRows.toLocaleString()} |
|
|
Rate: ${avgQueryRate.toFixed(1)} q/min |
|
|
Duration: ${oldestDurationStr}
|
|
</span>
|
|
</td>
|
|
`;
|
|
|
|
// Add click handler to toggle expansion
|
|
headerRow.addEventListener('click', () => toggleSubscriptionGroup(wsiPointer));
|
|
|
|
tableBody.appendChild(headerRow);
|
|
|
|
// Create detail rows (initially hidden)
|
|
subscriptions.forEach((subscription, index) => {
|
|
const detailRow = document.createElement('tr');
|
|
detailRow.className = 'subscription-detail-row';
|
|
detailRow.setAttribute('data-wsi-group', wsiPointer);
|
|
detailRow.style.display = 'none';
|
|
|
|
// Calculate duration
|
|
const duration = now - subscription.created_at;
|
|
const durationStr = formatDuration(duration);
|
|
|
|
// Format filters
|
|
let filtersDisplay = 'None';
|
|
if (subscription.filters && subscription.filters.length > 0) {
|
|
const filterDetails = [];
|
|
subscription.filters.forEach((filter) => {
|
|
const parts = [];
|
|
|
|
if (filter.kinds && Array.isArray(filter.kinds) && filter.kinds.length > 0) {
|
|
parts.push(`kinds:[${filter.kinds.join(',')}]`);
|
|
}
|
|
|
|
if (filter.authors && Array.isArray(filter.authors) && filter.authors.length > 0) {
|
|
const authorCount = filter.authors.length;
|
|
if (authorCount === 1) {
|
|
const shortPubkey = filter.authors[0].substring(0, 8) + '...';
|
|
parts.push(`authors:[${shortPubkey}]`);
|
|
} else {
|
|
parts.push(`authors:[${authorCount} pubkeys]`);
|
|
}
|
|
}
|
|
|
|
if (filter.ids && Array.isArray(filter.ids) && filter.ids.length > 0) {
|
|
const idCount = filter.ids.length;
|
|
parts.push(`ids:[${idCount} event${idCount > 1 ? 's' : ''}]`);
|
|
}
|
|
|
|
const timeParts = [];
|
|
if (filter.since && filter.since > 0) {
|
|
const sinceDate = new Date(filter.since * 1000).toLocaleString();
|
|
timeParts.push(`since:${sinceDate}`);
|
|
}
|
|
if (filter.until && filter.until > 0) {
|
|
const untilDate = new Date(filter.until * 1000).toLocaleString();
|
|
timeParts.push(`until:${untilDate}`);
|
|
}
|
|
if (timeParts.length > 0) {
|
|
parts.push(timeParts.join(', '));
|
|
}
|
|
|
|
if (filter.limit && filter.limit > 0) {
|
|
parts.push(`limit:${filter.limit}`);
|
|
}
|
|
|
|
if (filter.tag_filters && Array.isArray(filter.tag_filters) && filter.tag_filters.length > 0) {
|
|
parts.push(`tags:[${filter.tag_filters.length} filter${filter.tag_filters.length > 1 ? 's' : ''}]`);
|
|
}
|
|
|
|
if (parts.length > 0) {
|
|
filterDetails.push(parts.join(', '));
|
|
} else {
|
|
filterDetails.push('empty filter');
|
|
}
|
|
});
|
|
|
|
filtersDisplay = filterDetails.join(' | ');
|
|
}
|
|
|
|
detailRow.innerHTML = `
|
|
<td class="subscription-detail-prefix">└─</td>
|
|
<td class="subscription-detail-id">${subscription.id || 'N/A'}</td>
|
|
<td class="subscription-detail-duration">${durationStr}</td>
|
|
<td class="subscription-detail-filters">${filtersDisplay}</td>
|
|
`;
|
|
|
|
tableBody.appendChild(detailRow);
|
|
|
|
// Restore expand/collapse state after adding all rows
|
|
if (wasExpanded) {
|
|
const detailRows = tableBody.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
|
detailRows.forEach(row => row.style.display = 'table-row');
|
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
|
if (expandIcon) {
|
|
expandIcon.textContent = '▼';
|
|
expandIcon.style.transform = 'rotate(90deg)';
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// Toggle function for expanding/collapsing groups
|
|
function toggleSubscriptionGroup(wsiPointer) {
|
|
const headerRow = document.querySelector(`.subscription-group-header[data-wsi-pointer="${wsiPointer}"]`);
|
|
const detailRows = document.querySelectorAll(`.subscription-detail-row[data-wsi-group="${wsiPointer}"]`);
|
|
const expandIcon = headerRow.querySelector('.expand-icon');
|
|
|
|
const isExpanded = headerRow.getAttribute('data-expanded') === 'true';
|
|
|
|
if (isExpanded) {
|
|
// Collapse
|
|
detailRows.forEach(row => row.style.display = 'none');
|
|
expandIcon.textContent = '▶';
|
|
expandIcon.style.transform = 'rotate(0deg)';
|
|
headerRow.setAttribute('data-expanded', 'false');
|
|
} else {
|
|
// Expand
|
|
detailRows.forEach(row => row.style.display = 'table-row');
|
|
expandIcon.textContent = '▼';
|
|
expandIcon.style.transform = 'rotate(90deg)';
|
|
headerRow.setAttribute('data-expanded', 'true');
|
|
}
|
|
}
|
|
|
|
// Helper function to format duration in human-readable format
|
|
function formatDuration(seconds) {
|
|
if (seconds < 60) return `${seconds}s`;
|
|
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
|
|
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h`;
|
|
}
|
|
|
|
// Update statistics status indicator (disabled - status display removed)
|
|
function updateStatsStatus(status, message = '') {
|
|
// Status display has been removed from the UI
|
|
return;
|
|
}
|
|
|
|
// Utility function to format file size
|
|
function formatFileSize(bytes) {
|
|
if (!bytes || bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
// Utility function to format timestamp
|
|
function formatTimestamp(timestamp) {
|
|
if (!timestamp) return '-';
|
|
const date = new Date(timestamp * 1000);
|
|
return date.toLocaleString();
|
|
}
|
|
|
|
// Update statistics cell with flash animation if value changed
|
|
function updateStatsCell(cellId, newValue) {
|
|
const cell = document.getElementById(cellId);
|
|
if (!cell) return;
|
|
|
|
const currentValue = cell.textContent;
|
|
cell.textContent = newValue;
|
|
|
|
// Flash if value changed
|
|
if (currentValue !== newValue && currentValue !== '-') {
|
|
cell.classList.add('flash-value');
|
|
setTimeout(() => {
|
|
cell.classList.remove('flash-value');
|
|
}, 500);
|
|
}
|
|
}
|
|
|
|
// Start auto-refreshing database statistics every 10 seconds
|
|
function startStatsAutoRefresh() {
|
|
// DISABLED - Using real-time monitoring events instead of polling
|
|
// This function is kept for backward compatibility but no longer starts auto-refresh
|
|
log('Database statistics auto-refresh DISABLED - using real-time monitoring events', 'INFO');
|
|
}
|
|
|
|
// Stop auto-refreshing database statistics
|
|
function stopStatsAutoRefresh() {
|
|
if (statsAutoRefreshInterval) {
|
|
clearInterval(statsAutoRefreshInterval);
|
|
statsAutoRefreshInterval = null;
|
|
}
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval);
|
|
countdownInterval = null;
|
|
}
|
|
// Reset countdown display
|
|
updateCountdownDisplay();
|
|
log('Database statistics auto-refresh stopped', 'INFO');
|
|
}
|
|
|
|
// Update countdown display in refresh button
|
|
function updateCountdownDisplay() {
|
|
const refreshBtn = document.getElementById('refresh-stats-btn');
|
|
if (!refreshBtn) return;
|
|
|
|
// DISABLED - No countdown display when using real-time monitoring
|
|
// Show empty button text
|
|
refreshBtn.textContent = '';
|
|
}
|
|
|
|
// Flash refresh button red on successful refresh
|
|
function flashRefreshButton() {
|
|
const refreshBtn = document.getElementById('refresh-stats-btn');
|
|
if (!refreshBtn) return;
|
|
|
|
// DISABLED - No flashing when using real-time monitoring
|
|
// This function is kept for backward compatibility
|
|
}
|
|
|
|
// Event handlers for test buttons
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// Test button event handlers
|
|
const testGetAuthRulesBtn = document.getElementById('test-get-auth-rules-btn');
|
|
const testClearAuthRulesBtn = document.getElementById('test-clear-auth-rules-btn');
|
|
const testAddBlacklistBtn = document.getElementById('test-add-blacklist-btn');
|
|
const testAddWhitelistBtn = document.getElementById('test-add-whitelist-btn');
|
|
const testConfigQueryBtn = document.getElementById('test-config-query-btn');
|
|
const testPostEventBtn = document.getElementById('test-post-event-btn');
|
|
const clearTestLogBtn = document.getElementById('clear-test-log-btn');
|
|
const generateTestKeyBtn = document.getElementById('generate-test-key-btn');
|
|
|
|
if (testGetAuthRulesBtn) {
|
|
testGetAuthRulesBtn.addEventListener('click', testGetAuthRules);
|
|
}
|
|
|
|
if (testClearAuthRulesBtn) {
|
|
testClearAuthRulesBtn.addEventListener('click', testClearAuthRules);
|
|
}
|
|
|
|
if (testAddBlacklistBtn) {
|
|
testAddBlacklistBtn.addEventListener('click', testAddBlacklist);
|
|
}
|
|
|
|
if (testAddWhitelistBtn) {
|
|
testAddWhitelistBtn.addEventListener('click', testAddWhitelist);
|
|
}
|
|
|
|
if (testConfigQueryBtn) {
|
|
testConfigQueryBtn.addEventListener('click', testConfigQuery);
|
|
}
|
|
|
|
if (testPostEventBtn) {
|
|
testPostEventBtn.addEventListener('click', testPostEvent);
|
|
}
|
|
|
|
if (clearTestLogBtn) {
|
|
clearTestLogBtn.addEventListener('click', () => {
|
|
const testLog = document.getElementById('test-event-log');
|
|
if (testLog) {
|
|
testLog.innerHTML = '<div class="log-entry"><span class="log-timestamp">SYSTEM:</span> Test log cleared.</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (generateTestKeyBtn) {
|
|
generateTestKeyBtn.addEventListener('click', generateRandomTestKey);
|
|
}
|
|
|
|
// Show test input section when needed
|
|
const testInputSection = document.getElementById('test-input-section');
|
|
if (testInputSection) {
|
|
testInputSection.style.display = 'block';
|
|
}
|
|
|
|
// Database statistics event handlers
|
|
const refreshStatsBtn = document.getElementById('refresh-stats-btn');
|
|
if (refreshStatsBtn) {
|
|
refreshStatsBtn.addEventListener('click', sendStatsQuery);
|
|
}
|
|
|
|
// Subscription details section is always visible when authenticated
|
|
|
|
// NIP-17 DM event handlers
|
|
if (sendDmBtn) {
|
|
sendDmBtn.addEventListener('click', sendNIP17DM);
|
|
}
|
|
|
|
// SQL Query event handlers
|
|
const executeSqlBtn = document.getElementById('execute-sql-btn');
|
|
const clearSqlBtn = document.getElementById('clear-sql-btn');
|
|
const clearHistoryBtn = document.getElementById('clear-history-btn');
|
|
|
|
if (executeSqlBtn) {
|
|
executeSqlBtn.addEventListener('click', executeSqlQuery);
|
|
}
|
|
|
|
if (clearSqlBtn) {
|
|
clearSqlBtn.addEventListener('click', clearSqlQuery);
|
|
}
|
|
|
|
if (clearHistoryBtn) {
|
|
clearHistoryBtn.addEventListener('click', clearQueryHistory);
|
|
}
|
|
});
|
|
|
|
|
|
// Dark mode functionality
|
|
function toggleDarkMode() {
|
|
const body = document.body;
|
|
const isDarkMode = body.classList.contains('dark-mode');
|
|
|
|
if (isDarkMode) {
|
|
body.classList.remove('dark-mode');
|
|
localStorage.setItem('darkMode', 'false');
|
|
updateDarkModeButton(false);
|
|
log('Switched to light mode', 'INFO');
|
|
} else {
|
|
body.classList.add('dark-mode');
|
|
localStorage.setItem('darkMode', 'true');
|
|
updateDarkModeButton(true);
|
|
log('Switched to dark mode', 'INFO');
|
|
}
|
|
}
|
|
|
|
function updateDarkModeButton(isDarkMode) {
|
|
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
|
|
if (navDarkModeBtn) {
|
|
navDarkModeBtn.textContent = isDarkMode ? 'LIGHT MODE' : 'DARK MODE';
|
|
}
|
|
}
|
|
|
|
function initializeDarkMode() {
|
|
const savedDarkMode = localStorage.getItem('darkMode');
|
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
const shouldBeDark = savedDarkMode === 'true' || (savedDarkMode === null && prefersDark);
|
|
|
|
if (shouldBeDark) {
|
|
document.body.classList.add('dark-mode');
|
|
updateDarkModeButton(true);
|
|
} else {
|
|
updateDarkModeButton(false);
|
|
}
|
|
}
|
|
|
|
// Side navigation functions
|
|
function toggleSideNav() {
|
|
const sideNav = document.getElementById('side-nav');
|
|
const overlay = document.getElementById('side-nav-overlay');
|
|
|
|
if (sideNavOpen) {
|
|
sideNav.classList.remove('open');
|
|
overlay.classList.remove('show');
|
|
sideNavOpen = false;
|
|
} else {
|
|
sideNav.classList.add('open');
|
|
overlay.classList.add('show');
|
|
sideNavOpen = true;
|
|
}
|
|
}
|
|
|
|
function closeSideNav() {
|
|
const sideNav = document.getElementById('side-nav');
|
|
const overlay = document.getElementById('side-nav-overlay');
|
|
|
|
sideNav.classList.remove('open');
|
|
overlay.classList.remove('show');
|
|
sideNavOpen = false;
|
|
}
|
|
|
|
function switchPage(pageName) {
|
|
// Update current page
|
|
currentPage = pageName;
|
|
|
|
// Update navigation active state
|
|
const navItems = document.querySelectorAll('.nav-item');
|
|
navItems.forEach(item => {
|
|
item.classList.remove('active');
|
|
if (item.getAttribute('data-page') === pageName) {
|
|
item.classList.add('active');
|
|
}
|
|
});
|
|
|
|
// Hide all sections
|
|
const sections = [
|
|
'databaseStatisticsSection',
|
|
'subscriptionDetailsSection',
|
|
'div_config',
|
|
'authRulesSection',
|
|
'relayEventsSection',
|
|
'nip17DMSection',
|
|
'sqlQuerySection'
|
|
];
|
|
|
|
sections.forEach(sectionId => {
|
|
const section = document.getElementById(sectionId);
|
|
if (section) {
|
|
section.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Show selected section
|
|
const pageMap = {
|
|
'statistics': 'databaseStatisticsSection',
|
|
'subscriptions': 'subscriptionDetailsSection',
|
|
'configuration': 'div_config',
|
|
'authorization': 'authRulesSection',
|
|
'relay-events': 'relayEventsSection',
|
|
'dm': 'nip17DMSection',
|
|
'database': 'sqlQuerySection'
|
|
};
|
|
|
|
const targetSectionId = pageMap[pageName];
|
|
if (targetSectionId) {
|
|
const targetSection = document.getElementById(targetSectionId);
|
|
if (targetSection) {
|
|
targetSection.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
// Special handling for configuration page - ensure config-display is visible and refresh data
|
|
if (pageName === 'configuration') {
|
|
const configDisplay = document.getElementById('config-display');
|
|
if (configDisplay) {
|
|
configDisplay.classList.remove('hidden');
|
|
}
|
|
// Always refresh configuration data when navigating to config page
|
|
fetchConfiguration().catch(error => {
|
|
console.log('Failed to refresh configuration on page switch: ' + error.message);
|
|
});
|
|
}
|
|
|
|
// Close side navigation
|
|
closeSideNav();
|
|
|
|
log(`Switched to page: ${pageName}`, 'INFO');
|
|
}
|
|
|
|
// Initialize the app
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
console.log('C-Relay Admin API interface loaded');
|
|
|
|
// Initialize dark mode
|
|
initializeDarkMode();
|
|
|
|
// Initialize sidebar button text
|
|
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
|
|
if (navDarkModeBtn) {
|
|
navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
|
|
}
|
|
|
|
// Start RELAY letter animation
|
|
startRelayAnimation();
|
|
|
|
// Initialize real-time event rate chart
|
|
setTimeout(() => {
|
|
initializeEventRateChart();
|
|
}, 1000); // Delay to ensure text_graph.js is loaded
|
|
|
|
// Initialize side navigation
|
|
initializeSideNavigation();
|
|
|
|
// Ensure admin sections are hidden by default on page load
|
|
updateAdminSectionsVisibility();
|
|
|
|
setTimeout(() => {
|
|
initializeApp();
|
|
// Enhance SimplePool for testing after initialization
|
|
setTimeout(enhancePoolForTesting, 2000);
|
|
}, 100);
|
|
});
|
|
|
|
// Initialize side navigation event handlers
|
|
function initializeSideNavigation() {
|
|
// Header title click handler
|
|
const headerTitle = document.getElementById('header-title');
|
|
if (headerTitle) {
|
|
headerTitle.addEventListener('click', toggleSideNav);
|
|
}
|
|
|
|
// Overlay click handler
|
|
const overlay = document.getElementById('side-nav-overlay');
|
|
if (overlay) {
|
|
overlay.addEventListener('click', closeSideNav);
|
|
}
|
|
|
|
// Navigation item click handlers
|
|
const navItems = document.querySelectorAll('.nav-item');
|
|
navItems.forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
const pageName = e.target.getAttribute('data-page');
|
|
if (pageName) {
|
|
switchPage(pageName);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Footer button handlers
|
|
const navDarkModeBtn = document.getElementById('nav-dark-mode-btn');
|
|
const navLogoutBtn = document.getElementById('nav-logout-btn');
|
|
|
|
if (navDarkModeBtn) {
|
|
navDarkModeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleDarkMode();
|
|
// Update button text after toggle
|
|
setTimeout(() => {
|
|
navDarkModeBtn.textContent = document.body.classList.contains('dark-mode') ? 'LIGHT MODE' : 'DARK MODE';
|
|
}, 10);
|
|
closeSideNav();
|
|
});
|
|
}
|
|
|
|
if (navLogoutBtn) {
|
|
navLogoutBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
logout();
|
|
closeSideNav();
|
|
});
|
|
}
|
|
|
|
// Set initial page
|
|
switchPage(currentPage);
|
|
}
|
|
|
|
// ================================
|
|
// SQL QUERY FUNCTIONS
|
|
// ================================
|
|
|
|
// Predefined query templates
|
|
const SQL_QUERY_TEMPLATES = {
|
|
recent_events: "SELECT id, pubkey, created_at, kind, substr(content, 1, 50) as content FROM events ORDER BY created_at DESC LIMIT 20",
|
|
event_stats: "SELECT * FROM event_stats",
|
|
subscriptions: "SELECT * FROM active_subscriptions_log ORDER BY created_at DESC",
|
|
top_pubkeys: "SELECT * FROM top_pubkeys_view",
|
|
event_kinds: "SELECT * FROM event_kinds_view ORDER BY count DESC",
|
|
time_stats: "SELECT 'total' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events UNION ALL SELECT '24h' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 86400) UNION ALL SELECT '7d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 604800) UNION ALL SELECT '30d' as period, COUNT(*) as total_events, COUNT(DISTINCT pubkey) as unique_pubkeys, MIN(created_at) as oldest_event, MAX(created_at) as newest_event FROM events WHERE created_at >= (strftime('%s', 'now') - 2592000)"
|
|
};
|
|
|
|
// Query history management (localStorage)
|
|
const QUERY_HISTORY_KEY = 'c_relay_sql_history';
|
|
const MAX_HISTORY_ITEMS = 20;
|
|
|
|
// Load query history from localStorage
|
|
function loadQueryHistory() {
|
|
try {
|
|
const history = localStorage.getItem(QUERY_HISTORY_KEY);
|
|
return history ? JSON.parse(history) : [];
|
|
} catch (e) {
|
|
console.error('Failed to load query history:', e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Save query to history
|
|
function saveQueryToHistory(query) {
|
|
if (!query || query.trim().length === 0) return;
|
|
|
|
try {
|
|
let history = loadQueryHistory();
|
|
|
|
// Remove duplicate if exists
|
|
history = history.filter(q => q !== query);
|
|
|
|
// Add to beginning
|
|
history.unshift(query);
|
|
|
|
// Limit size
|
|
if (history.length > MAX_HISTORY_ITEMS) {
|
|
history = history.slice(0, MAX_HISTORY_ITEMS);
|
|
}
|
|
|
|
localStorage.setItem(QUERY_HISTORY_KEY, JSON.stringify(history));
|
|
updateQueryDropdown();
|
|
} catch (e) {
|
|
console.error('Failed to save query history:', e);
|
|
}
|
|
}
|
|
|
|
// Clear query history
|
|
function clearQueryHistory() {
|
|
if (confirm('Clear all query history?')) {
|
|
localStorage.removeItem(QUERY_HISTORY_KEY);
|
|
updateQueryDropdown();
|
|
}
|
|
}
|
|
|
|
// Update dropdown with history
|
|
function updateQueryDropdown() {
|
|
const historyGroup = document.getElementById('history-group');
|
|
if (!historyGroup) return;
|
|
|
|
// Clear existing history options
|
|
historyGroup.innerHTML = '';
|
|
|
|
const history = loadQueryHistory();
|
|
if (history.length === 0) {
|
|
const option = document.createElement('option');
|
|
option.value = '';
|
|
option.textContent = '(no history)';
|
|
option.disabled = true;
|
|
historyGroup.appendChild(option);
|
|
return;
|
|
}
|
|
|
|
history.forEach((query, index) => {
|
|
const option = document.createElement('option');
|
|
option.value = `history_${index}`;
|
|
// Truncate long queries for display
|
|
const displayQuery = query.length > 60 ? query.substring(0, 60) + '...' : query;
|
|
option.textContent = displayQuery;
|
|
option.dataset.query = query;
|
|
historyGroup.appendChild(option);
|
|
});
|
|
}
|
|
|
|
// Load selected query from dropdown
|
|
function loadSelectedQuery() {
|
|
const dropdown = document.getElementById('query-dropdown');
|
|
const selectedValue = dropdown.value;
|
|
|
|
if (!selectedValue) return;
|
|
|
|
let query = '';
|
|
|
|
// Check if it's a template
|
|
if (SQL_QUERY_TEMPLATES[selectedValue]) {
|
|
query = SQL_QUERY_TEMPLATES[selectedValue];
|
|
}
|
|
// Check if it's from history
|
|
else if (selectedValue.startsWith('history_')) {
|
|
const selectedOption = dropdown.options[dropdown.selectedIndex];
|
|
query = selectedOption.dataset.query;
|
|
}
|
|
|
|
if (query) {
|
|
document.getElementById('sql-input').value = query;
|
|
}
|
|
|
|
// Reset dropdown to placeholder
|
|
dropdown.value = '';
|
|
}
|
|
|
|
// Clear the SQL query input
|
|
function clearSqlQuery() {
|
|
document.getElementById('sql-input').value = '';
|
|
document.getElementById('query-info').innerHTML = '';
|
|
document.getElementById('query-table').innerHTML = '';
|
|
}
|
|
|
|
// Execute SQL query via admin API
|
|
async function executeSqlQuery() {
|
|
const query = document.getElementById('sql-input').value;
|
|
if (!query.trim()) {
|
|
log('Please enter a SQL query', 'ERROR');
|
|
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Please enter a SQL query</div>';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Show loading state
|
|
document.getElementById('query-info').innerHTML = '<div class="loading">Executing query...</div>';
|
|
document.getElementById('query-table').innerHTML = '';
|
|
|
|
// Save to history (before execution, so it's saved even if query fails)
|
|
saveQueryToHistory(query.trim());
|
|
|
|
// Send query as kind 23456 admin command
|
|
const command = ["sql_query", query];
|
|
const requestEvent = await sendAdminCommand(command);
|
|
|
|
// Store query info for when response arrives
|
|
if (requestEvent && requestEvent.id) {
|
|
pendingSqlQueries.set(requestEvent.id, {
|
|
query: query,
|
|
timestamp: Date.now()
|
|
});
|
|
}
|
|
|
|
// Note: Response will be handled by the event listener
|
|
// which will call displaySqlQueryResults() when response arrives
|
|
} catch (error) {
|
|
log('Failed to execute query: ' + error.message, 'ERROR');
|
|
document.getElementById('query-info').innerHTML = '<div class="error-message">❌ Failed to execute query: ' + error.message + '</div>';
|
|
}
|
|
}
|
|
|
|
// Helper function to send admin commands (kind 23456 events)
|
|
async function sendAdminCommand(commandArray) {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to send admin commands');
|
|
}
|
|
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
try {
|
|
log(`Sending admin command: ${JSON.stringify(commandArray)}`, 'INFO');
|
|
|
|
// Encrypt the command array directly using NIP-44
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(commandArray));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create single kind 23456 admin event
|
|
const adminEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(adminEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
// Publish via SimplePool with detailed error diagnostics
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Use Promise.allSettled to capture per-relay outcomes
|
|
const results = await Promise.allSettled(publishPromises);
|
|
|
|
// Log detailed publish results for diagnostics
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
log(`✅ Admin command published successfully to relay ${index}`, 'INFO');
|
|
} else {
|
|
log(`❌ Admin command failed on relay ${index}: ${result.reason?.message || result.reason}`, 'ERROR');
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected admin command event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
log('Admin command sent successfully', 'INFO');
|
|
return signedEvent; // Return the signed event for request ID tracking
|
|
|
|
} catch (error) {
|
|
log(`Failed to send admin command: ${error.message}`, 'ERROR');
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Display SQL query results
|
|
function displaySqlQueryResults(response) {
|
|
const infoDiv = document.getElementById('query-info');
|
|
const tableDiv = document.getElementById('query-table');
|
|
|
|
if (response.status === 'error' || response.error) {
|
|
infoDiv.innerHTML = `<div class="error-message">❌ ${response.error || 'Query failed'}</div>`;
|
|
tableDiv.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
// Show query info with request ID for debugging
|
|
const rowCount = response.row_count || 0;
|
|
const execTime = response.execution_time_ms || 0;
|
|
const requestId = response.request_id ? response.request_id.substring(0, 8) + '...' : 'unknown';
|
|
infoDiv.innerHTML = `
|
|
<div class="query-info-success">
|
|
<span>✅ Query executed successfully</span>
|
|
<span>Rows: ${rowCount}</span>
|
|
<span>Execution Time: ${execTime}ms</span>
|
|
<span class="request-id" title="${response.request_id || ''}">Request: ${requestId}</span>
|
|
</div>
|
|
`;
|
|
|
|
// Build results table
|
|
if (response.rows && response.rows.length > 0) {
|
|
let html = '<table class="sql-results-table"><thead><tr>';
|
|
response.columns.forEach(col => {
|
|
html += `<th>${escapeHtml(col)}</th>`;
|
|
});
|
|
html += '</tr></thead><tbody>';
|
|
|
|
response.rows.forEach(row => {
|
|
html += '<tr>';
|
|
row.forEach(cell => {
|
|
const cellValue = cell === null ? '<em>NULL</em>' : escapeHtml(String(cell));
|
|
html += `<td>${cellValue}</td>`;
|
|
});
|
|
html += '</tr>';
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
tableDiv.innerHTML = html;
|
|
} else {
|
|
tableDiv.innerHTML = '<p class="no-results">No results returned</p>';
|
|
}
|
|
}
|
|
|
|
// Handle SQL query response (called by event listener)
|
|
function handleSqlQueryResponse(response) {
|
|
console.log('=== HANDLING SQL QUERY RESPONSE ===');
|
|
console.log('Response:', response);
|
|
|
|
// Always display SQL query results when received
|
|
displaySqlQueryResults(response);
|
|
|
|
// Clean up any pending queries
|
|
if (response.request_id && pendingSqlQueries.has(response.request_id)) {
|
|
pendingSqlQueries.delete(response.request_id);
|
|
}
|
|
}
|
|
|
|
// Helper function to escape HTML
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Initialize query history on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
updateQueryDropdown();
|
|
});
|
|
|
|
// RELAY letter animation function
|
|
function startRelayAnimation() {
|
|
const letters = document.querySelectorAll('.relay-letter');
|
|
let currentIndex = 0;
|
|
|
|
function animateLetter() {
|
|
// Remove underline from all letters first
|
|
letters.forEach(letter => letter.classList.remove('underlined'));
|
|
|
|
// Add underline to current letter
|
|
if (letters[currentIndex]) {
|
|
letters[currentIndex].classList.add('underlined');
|
|
}
|
|
|
|
// Move to next letter
|
|
currentIndex++;
|
|
|
|
// If we've gone through all letters, remove all underlines and wait 4000ms then restart
|
|
if (currentIndex > letters.length) {
|
|
// Remove all underlines before the pause
|
|
letters.forEach(letter => letter.classList.remove('underlined'));
|
|
setTimeout(() => {
|
|
currentIndex = 0;
|
|
animateLetter();
|
|
}, 4000);
|
|
} else {
|
|
// Otherwise, continue to next letter after 200ms
|
|
setTimeout(animateLetter, 100);
|
|
}
|
|
}
|
|
|
|
// Start the animation
|
|
animateLetter();
|
|
}
|
|
|
|
// ================================
|
|
// CONFIG TOGGLE BUTTON COMPONENT
|
|
// ================================
|
|
|
|
// Global registry for config toggle buttons
|
|
const configToggleButtons = new Map();
|
|
|
|
// ConfigToggleButton class for tri-state boolean config toggles
|
|
class ConfigToggleButton {
|
|
constructor(configKey, container, options = {}) {
|
|
this.configKey = configKey;
|
|
this.container = container;
|
|
this.state = 'false'; // Start in false state by default
|
|
this.pendingValue = null;
|
|
this.options = {
|
|
dataType: 'boolean',
|
|
category: 'monitoring',
|
|
...options
|
|
};
|
|
|
|
this.render();
|
|
this.attachEventListeners();
|
|
|
|
// Register this button instance
|
|
configToggleButtons.set(configKey, this);
|
|
}
|
|
|
|
render() {
|
|
console.log('=== RENDERING CONFIG TOGGLE BUTTON ===');
|
|
console.log('Config key:', this.configKey);
|
|
console.log('Container:', this.container);
|
|
|
|
// Create button element
|
|
this.button = document.createElement('button');
|
|
this.button.className = 'config-toggle-btn';
|
|
this.button.setAttribute('data-config-key', this.configKey);
|
|
this.button.setAttribute('data-state', this.state);
|
|
this.button.setAttribute('title', `Toggle ${this.configKey}`);
|
|
this.updateIcon();
|
|
|
|
console.log('Button element created:', this.button);
|
|
console.log('Container before append:', this.container);
|
|
console.log('Container children before:', this.container.children.length);
|
|
|
|
this.container.appendChild(this.button);
|
|
|
|
console.log('Container children after:', this.container.children.length);
|
|
console.log('Button in DOM:', document.contains(this.button));
|
|
}
|
|
|
|
updateIcon() {
|
|
const icons = {
|
|
'true': 'I',
|
|
'false': '0',
|
|
'indeterminate': '⟳'
|
|
};
|
|
this.button.textContent = icons[this.state] || '?';
|
|
}
|
|
|
|
setState(newState) {
|
|
if (['true', 'false', 'indeterminate'].includes(newState)) {
|
|
this.state = newState;
|
|
this.button.setAttribute('data-state', newState);
|
|
this.updateIcon();
|
|
}
|
|
}
|
|
|
|
async toggle() {
|
|
console.log('=== TOGGLE BUTTON CLICKED ===');
|
|
console.log('Current state:', this.state);
|
|
console.log('Button element:', this.button);
|
|
|
|
if (this.state === 'indeterminate') {
|
|
console.log('Ignoring toggle - currently indeterminate');
|
|
return; // Don't toggle while pending
|
|
}
|
|
|
|
// Toggle between true and false
|
|
const newValue = this.state === 'true' ? 'false' : 'true';
|
|
this.pendingValue = newValue;
|
|
|
|
console.log('Sending toggle command:', newValue);
|
|
|
|
// Set to indeterminate while waiting
|
|
this.setState('indeterminate');
|
|
|
|
// Create config object
|
|
const configObj = {
|
|
key: this.configKey,
|
|
value: newValue,
|
|
data_type: this.options.dataType,
|
|
category: this.options.category
|
|
};
|
|
|
|
console.log('Config object:', configObj);
|
|
|
|
try {
|
|
// Send config update command
|
|
console.log('Sending config update command...');
|
|
await sendConfigUpdateCommand([configObj]);
|
|
console.log('Config update command sent successfully');
|
|
log(`Config toggle sent: ${this.configKey} = ${newValue}`, 'INFO');
|
|
} catch (error) {
|
|
console.log('Config update command failed:', error);
|
|
log(`Failed to send config toggle: ${error.message}`, 'ERROR');
|
|
// Revert to previous state on error
|
|
this.setState('false');
|
|
this.pendingValue = null;
|
|
}
|
|
}
|
|
|
|
handleResponse(success, actualValue) {
|
|
console.log('=== HANDLE RESPONSE ===');
|
|
console.log('Success:', success);
|
|
console.log('Actual value:', actualValue);
|
|
console.log('Pending value:', this.pendingValue);
|
|
|
|
if (success) {
|
|
console.log('Success - setting to actual server value:', actualValue);
|
|
this.setState(actualValue);
|
|
} else {
|
|
console.log('Failed - reverting to false state');
|
|
// Failed - revert to false state
|
|
this.setState('false');
|
|
}
|
|
this.pendingValue = null;
|
|
console.log('Pending value cleared');
|
|
}
|
|
|
|
attachEventListeners() {
|
|
this.button.addEventListener('click', () => this.toggle());
|
|
}
|
|
}
|
|
|
|
// Helper function to get a registered toggle button
|
|
function getConfigToggleButton(configKey) {
|
|
return configToggleButtons.get(configKey);
|
|
}
|
|
|
|
// Monitoring is now subscription-based - no toggle button needed
|
|
// Monitoring automatically activates when someone subscribes to kind 24567 events
|
|
function initializeMonitoringToggleButton() {
|
|
console.log('=== MONITORING IS NOW SUBSCRIPTION-BASED ===');
|
|
console.log('No toggle button needed - monitoring activates automatically when subscribing to kind 24567');
|
|
log('Monitoring system is subscription-based - no manual toggle required', 'INFO');
|
|
return null;
|
|
}
|
|
|
|
// Monitoring is subscription-based - no toggle button response handling needed
|
|
const originalHandleConfigUpdateResponse = handleConfigUpdateResponse;
|
|
handleConfigUpdateResponse = function(responseData) {
|
|
console.log('=== CONFIG UPDATE RESPONSE HANDLER ===');
|
|
console.log('Response data:', responseData);
|
|
|
|
// Call original handler
|
|
originalHandleConfigUpdateResponse(responseData);
|
|
|
|
// Monitoring is now subscription-based - no toggle buttons to update
|
|
console.log('Monitoring system is subscription-based - no toggle buttons to handle');
|
|
};
|
|
|
|
// Monitoring is now subscription-based - no toggle buttons needed
|
|
function initializeToggleButtonsFromConfig(configData) {
|
|
console.log('=== MONITORING IS SUBSCRIPTION-BASED ===');
|
|
console.log('No toggle buttons needed - monitoring activates automatically when subscribing to kind 24567');
|
|
log('Monitoring system initialized - subscription-based activation ready', 'INFO');
|
|
}
|
|
|
|
// ================================
|
|
// RELAY EVENTS FUNCTIONS
|
|
// ================================
|
|
|
|
|
|
// Handle received relay events
|
|
function handleRelayEventReceived(event) {
|
|
console.log('Handling relay event:', event.kind, event);
|
|
|
|
switch (event.kind) {
|
|
case 0:
|
|
populateKind0Form(event);
|
|
break;
|
|
case 10050:
|
|
populateKind10050Form(event);
|
|
break;
|
|
case 10002:
|
|
populateKind10002Form(event);
|
|
break;
|
|
default:
|
|
console.log('Unknown relay event kind:', event.kind);
|
|
}
|
|
}
|
|
|
|
// Populate Kind 0 form (User Metadata)
|
|
function populateKind0Form(event) {
|
|
try {
|
|
const metadata = JSON.parse(event.content);
|
|
console.log('Populating Kind 0 form with:', metadata);
|
|
|
|
// Update form fields
|
|
const nameField = document.getElementById('kind0-name');
|
|
const aboutField = document.getElementById('kind0-about');
|
|
const pictureField = document.getElementById('kind0-picture');
|
|
const bannerField = document.getElementById('kind0-banner');
|
|
const nip05Field = document.getElementById('kind0-nip05');
|
|
const websiteField = document.getElementById('kind0-website');
|
|
|
|
if (nameField) nameField.value = metadata.name || '';
|
|
if (aboutField) aboutField.value = metadata.about || '';
|
|
if (pictureField) pictureField.value = metadata.picture || '';
|
|
if (bannerField) bannerField.value = metadata.banner || '';
|
|
if (nip05Field) nip05Field.value = metadata.nip05 || '';
|
|
if (websiteField) websiteField.value = metadata.website || '';
|
|
|
|
showStatus('kind0-status', 'Metadata loaded from relay', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error populating Kind 0 form:', error);
|
|
showStatus('kind0-status', 'Error loading metadata', 'error');
|
|
}
|
|
}
|
|
|
|
// Populate Kind 10050 form (DM Relay List)
|
|
function populateKind10050Form(event) {
|
|
try {
|
|
console.log('Populating Kind 10050 form with tags:', event.tags);
|
|
|
|
// Extract relay URLs from "relay" tags
|
|
const relayUrls = event.tags
|
|
.filter(tag => tag[0] === 'relay' && tag[1])
|
|
.map(tag => tag[1]);
|
|
|
|
const relaysField = document.getElementById('kind10050-relays');
|
|
if (relaysField) {
|
|
relaysField.value = relayUrls.join('\n');
|
|
}
|
|
|
|
showStatus('kind10050-status', 'DM relay list loaded from relay', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error populating Kind 10050 form:', error);
|
|
showStatus('kind10050-status', 'Error loading DM relay list', 'error');
|
|
}
|
|
}
|
|
|
|
// Populate Kind 10002 form (Relay List)
|
|
function populateKind10002Form(event) {
|
|
try {
|
|
console.log('Populating Kind 10002 form with tags:', event.tags);
|
|
|
|
// Clear existing entries
|
|
const container = document.getElementById('kind10002-relay-entries');
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
}
|
|
|
|
// Extract relay entries from "r" tags
|
|
event.tags.forEach(tag => {
|
|
if (tag[0] === 'r' && tag[1]) {
|
|
const url = tag[1];
|
|
const marker = tag[2] || 'read'; // Default to read if no marker
|
|
const read = marker.includes('read');
|
|
const write = marker.includes('write');
|
|
|
|
addRelayEntry(url, read, write);
|
|
}
|
|
});
|
|
|
|
showStatus('kind10002-status', 'Relay list loaded from relay', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error populating Kind 10002 form:', error);
|
|
showStatus('kind10002-status', 'Error loading relay list', 'error');
|
|
}
|
|
}
|
|
|
|
// Submit Kind 0 event
|
|
async function submitKind0Event() {
|
|
try {
|
|
showStatus('kind0-status', 'Submitting metadata...', 'info');
|
|
|
|
// Collect form data
|
|
const metadata = {
|
|
name: document.getElementById('kind0-name').value.trim(),
|
|
about: document.getElementById('kind0-about').value.trim(),
|
|
picture: document.getElementById('kind0-picture').value.trim(),
|
|
banner: document.getElementById('kind0-banner').value.trim(),
|
|
nip05: document.getElementById('kind0-nip05').value.trim(),
|
|
website: document.getElementById('kind0-website').value.trim()
|
|
};
|
|
|
|
// Remove empty fields
|
|
Object.keys(metadata).forEach(key => {
|
|
if (!metadata[key]) delete metadata[key];
|
|
});
|
|
|
|
// Validate required fields
|
|
if (!metadata.name) {
|
|
showStatus('kind0-status', 'Name is required', 'error');
|
|
return;
|
|
}
|
|
|
|
await sendCreateRelayEventCommand(0, metadata);
|
|
showStatus('kind0-status', 'Metadata updated successfully', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error submitting Kind 0 event:', error);
|
|
showStatus('kind0-status', 'Error updating metadata: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Submit Kind 10050 event
|
|
async function submitKind10050Event() {
|
|
try {
|
|
showStatus('kind10050-status', 'Submitting DM relay list...', 'info');
|
|
|
|
// Parse textarea content
|
|
const relaysText = document.getElementById('kind10050-relays').value.trim();
|
|
const relays = relaysText.split('\n')
|
|
.map(url => url.trim())
|
|
.filter(url => url.length > 0)
|
|
.filter(url => isValidRelayUrl(url));
|
|
|
|
if (relays.length === 0) {
|
|
showStatus('kind10050-status', 'At least one valid relay URL is required', 'error');
|
|
return;
|
|
}
|
|
|
|
await sendCreateRelayEventCommand(10050, { relays });
|
|
showStatus('kind10050-status', 'DM relay list updated successfully', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error submitting Kind 10050 event:', error);
|
|
showStatus('kind10050-status', 'Error updating DM relay list: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Submit Kind 10002 event
|
|
async function submitKind10002Event() {
|
|
try {
|
|
showStatus('kind10002-status', 'Submitting relay list...', 'info');
|
|
|
|
// Collect relay entries
|
|
const relays = [];
|
|
const entries = document.querySelectorAll('.relay-entry');
|
|
|
|
entries.forEach(entry => {
|
|
const url = entry.querySelector('.relay-url').value.trim();
|
|
const read = entry.querySelector('.relay-read').checked;
|
|
const write = entry.querySelector('.relay-write').checked;
|
|
|
|
if (url && isValidRelayUrl(url)) {
|
|
relays.push({
|
|
url: url,
|
|
read: read,
|
|
write: write
|
|
});
|
|
}
|
|
});
|
|
|
|
if (relays.length === 0) {
|
|
showStatus('kind10002-status', 'At least one valid relay entry is required', 'error');
|
|
return;
|
|
}
|
|
|
|
await sendCreateRelayEventCommand(10002, { relays });
|
|
showStatus('kind10002-status', 'Relay list updated successfully', 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Error submitting Kind 10002 event:', error);
|
|
showStatus('kind10002-status', 'Error updating relay list: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Send create_relay_event command
|
|
async function sendCreateRelayEventCommand(kind, eventData) {
|
|
if (!isLoggedIn || !userPubkey) {
|
|
throw new Error('Must be logged in to create relay events');
|
|
}
|
|
|
|
if (!relayPool) {
|
|
throw new Error('SimplePool connection not available');
|
|
}
|
|
|
|
try {
|
|
console.log(`Sending create_relay_event command for kind ${kind}...`);
|
|
|
|
// Create command array
|
|
const command_array = ["create_relay_event", kind, eventData];
|
|
|
|
// Encrypt the command array
|
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
|
if (!encrypted_content) {
|
|
throw new Error('Failed to encrypt command array');
|
|
}
|
|
|
|
// Create kind 23456 admin event
|
|
const adminEvent = {
|
|
kind: 23456,
|
|
pubkey: userPubkey,
|
|
created_at: Math.floor(Date.now() / 1000),
|
|
tags: [["p", getRelayPubkey()]],
|
|
content: encrypted_content
|
|
};
|
|
|
|
// Sign the event
|
|
const signedEvent = await window.nostr.signEvent(adminEvent);
|
|
if (!signedEvent || !signedEvent.sig) {
|
|
throw new Error('Event signing failed');
|
|
}
|
|
|
|
// Publish via SimplePool
|
|
const url = relayConnectionUrl.value.trim();
|
|
const publishPromises = relayPool.publish([url], signedEvent);
|
|
|
|
// Wait for publish results
|
|
const results = await Promise.allSettled(publishPromises);
|
|
let successCount = 0;
|
|
results.forEach((result, index) => {
|
|
if (result.status === 'fulfilled') {
|
|
successCount++;
|
|
console.log(`✅ Relay event published successfully to relay ${index}`);
|
|
} else {
|
|
console.error(`❌ Relay event failed on relay ${index}:`, result.reason);
|
|
}
|
|
});
|
|
|
|
if (successCount === 0) {
|
|
const errorDetails = results.map((r, i) => `Relay ${i}: ${r.reason?.message || r.reason}`).join('; ');
|
|
throw new Error(`All relays rejected relay event. Details: ${errorDetails}`);
|
|
}
|
|
|
|
console.log(`Relay event command sent successfully for kind ${kind}`);
|
|
|
|
} catch (error) {
|
|
console.error(`Failed to send create_relay_event command for kind ${kind}:`, error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Validation helpers
|
|
function isValidUrl(url) {
|
|
try {
|
|
new URL(url);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isValidRelayUrl(url) {
|
|
if (!isValidUrl(url)) return false;
|
|
return url.startsWith('ws://') || url.startsWith('wss://');
|
|
}
|
|
|
|
// UI helpers
|
|
function showStatus(elementId, message, type = 'info') {
|
|
const element = document.getElementById(elementId);
|
|
if (!element) return;
|
|
|
|
// Remove emojis from message
|
|
const cleanMessage = message.replace(/[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '');
|
|
|
|
element.textContent = cleanMessage;
|
|
element.className = 'status-message';
|
|
element.style.display = 'block'; // Ensure it's visible
|
|
|
|
// Add type-specific styling
|
|
switch (type) {
|
|
case 'success':
|
|
element.style.color = 'var(--accent-color)';
|
|
break;
|
|
case 'error':
|
|
element.style.color = '#ff0000';
|
|
break;
|
|
case 'info':
|
|
default:
|
|
element.style.color = 'var(--primary-color)';
|
|
break;
|
|
}
|
|
|
|
// Auto-hide after 5 seconds
|
|
setTimeout(() => {
|
|
element.style.display = 'none';
|
|
}, 5000);
|
|
}
|
|
|
|
function addRelayEntry(url = '', read = true, write = true) {
|
|
const container = document.getElementById('kind10002-relay-entries');
|
|
if (!container) return;
|
|
|
|
const entryDiv = document.createElement('div');
|
|
entryDiv.className = 'relay-entry';
|
|
entryDiv.innerHTML = `
|
|
<div class="form-group" style="display: flex; align-items: center; gap: 10px; margin-bottom: 10px;">
|
|
<input type="url" class="relay-url" placeholder="wss://relay.example.com" value="${url}" style="flex: 1; min-width: 300px; pointer-events: auto; cursor: text;">
|
|
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
|
|
<input type="checkbox" class="relay-read" ${read ? 'checked' : ''}>
|
|
Read
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 5px; white-space: nowrap;">
|
|
<input type="checkbox" class="relay-write" ${write ? 'checked' : ''}>
|
|
Write
|
|
</label>
|
|
<button type="button" onclick="removeRelayEntry(this)" style="padding: 4px 8px; font-size: 12px; white-space: nowrap;">Remove</button>
|
|
</div>
|
|
`;
|
|
|
|
container.appendChild(entryDiv);
|
|
}
|
|
|
|
function removeRelayEntry(button) {
|
|
const entry = button.closest('.relay-entry');
|
|
if (entry) {
|
|
entry.remove();
|
|
}
|
|
}
|
|
|
|
// Initialize toggle button after DOM is ready
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
console.log('=== DOM CONTENT LOADED - INITIALIZING TOGGLE BUTTON ===');
|
|
|
|
// Initialize the monitoring toggle button
|
|
setTimeout(() => {
|
|
console.log('=== SETTIMEOUT CALLBACK - CALLING initializeMonitoringToggleButton ===');
|
|
initializeMonitoringToggleButton();
|
|
}, 500); // Small delay to ensure DOM is fully ready
|
|
|
|
// Initialize relay events functionality
|
|
initializeRelayEvents();
|
|
});
|
|
|
|
// Initialize relay events functionality
|
|
function initializeRelayEvents() {
|
|
console.log('Initializing relay events functionality...');
|
|
|
|
// Set up event handlers for relay events page
|
|
const submitKind0Btn = document.getElementById('submit-kind0-btn');
|
|
const submitKind10050Btn = document.getElementById('submit-kind10050-btn');
|
|
const submitKind10002Btn = document.getElementById('submit-kind10002-btn');
|
|
const addRelayEntryBtn = document.getElementById('add-relay-entry-btn');
|
|
|
|
if (submitKind0Btn) {
|
|
submitKind0Btn.addEventListener('click', submitKind0Event);
|
|
}
|
|
|
|
if (submitKind10050Btn) {
|
|
submitKind10050Btn.addEventListener('click', submitKind10050Event);
|
|
}
|
|
|
|
if (submitKind10002Btn) {
|
|
submitKind10002Btn.addEventListener('click', submitKind10002Event);
|
|
}
|
|
|
|
if (addRelayEntryBtn) {
|
|
addRelayEntryBtn.addEventListener('click', () => addRelayEntry());
|
|
}
|
|
|
|
// Add one empty relay entry by default for Kind 10002
|
|
const kind10002Container = document.getElementById('kind10002-relay-entries');
|
|
if (kind10002Container && kind10002Container.children.length === 0) {
|
|
addRelayEntry(); // Add one empty entry to start
|
|
console.log('Added initial empty relay entry for Kind 10002');
|
|
}
|
|
|
|
console.log('Relay events functionality initialized');
|
|
} |