3921 lines
144 KiB
JavaScript
3921 lines
144 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
|
||
// 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;
|
||
|
||
// 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');
|
||
const logoutDropdown = document.getElementById('logout-dropdown');
|
||
const logoutBtn = document.getElementById('logout-btn');
|
||
|
||
// 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)
|
||
function randomNow() {
|
||
const TWO_DAYS = 2 * 24 * 60 * 60; // 172800 seconds
|
||
const now = Math.round(Date.now() / 1000);
|
||
return 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) {
|
||
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
|
||
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';
|
||
}
|
||
|
||
// Initialize relay pool for admin API communication
|
||
if (!relayPool) {
|
||
relayPool = new window.NostrTools.SimplePool();
|
||
console.log('🔌 Initialized SimplePool for admin API communication');
|
||
}
|
||
|
||
// Set up subscription to receive admin API responses
|
||
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);
|
||
|
||
} 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 divConfig = document.getElementById('div_config');
|
||
const authRulesSection = document.getElementById('authRulesSection');
|
||
const databaseStatisticsSection = document.getElementById('databaseStatisticsSection');
|
||
const nip17DMSection = document.getElementById('nip17DMSection');
|
||
const shouldShow = isLoggedIn && isRelayConnected;
|
||
|
||
if (divConfig) divConfig.style.display = shouldShow ? 'block' : 'none';
|
||
if (authRulesSection) authRulesSection.style.display = shouldShow ? 'block' : 'none';
|
||
if (databaseStatisticsSection) databaseStatisticsSection.style.display = shouldShow ? 'block' : 'none';
|
||
if (nip17DMSection) nip17DMSection.style.display = shouldShow ? 'block' : 'none';
|
||
|
||
// Start/stop auto-refresh based on visibility
|
||
if (shouldShow && databaseStatisticsSection && databaseStatisticsSection.style.display === 'block') {
|
||
// Load statistics immediately, then start auto-refresh
|
||
sendStatsQuery().catch(error => {
|
||
console.log('Auto-fetch statistics failed: ' + error.message);
|
||
});
|
||
startStatsAutoRefresh();
|
||
// Also load configuration and auth rules automatically when sections become visible
|
||
fetchConfiguration().catch(error => {
|
||
console.log('Auto-fetch configuration failed: ' + error.message);
|
||
});
|
||
loadAuthRules().catch(error => {
|
||
console.log('Auto-load auth rules failed: ' + error.message);
|
||
});
|
||
} else {
|
||
stopStatsAutoRefresh();
|
||
}
|
||
|
||
// Update countdown display when visibility changes
|
||
updateCountdownDisplay();
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
// Also hide logout dropdown if visible
|
||
if (logoutDropdown) {
|
||
logoutDropdown.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',
|
||
'wss://relay.laantungir.net'];
|
||
|
||
// Get profile event (kind 0) for the user
|
||
const events = await profilePool.querySync(relays, {
|
||
kinds: [0],
|
||
authors: [userPubkey],
|
||
limit: 1
|
||
});
|
||
|
||
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
|
||
}
|
||
|
||
// Close the profile pool
|
||
profilePool.close(relays);
|
||
|
||
} 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 configuration pool
|
||
if (relayPool) {
|
||
log('Closing configuration pool...', 'INFO');
|
||
const url = relayConnectionUrl.value.trim();
|
||
if (url) {
|
||
relayPool.close([url]);
|
||
}
|
||
relayPool = null;
|
||
subscriptionId = null;
|
||
// Reset subscription flag
|
||
isSubscribed = false;
|
||
}
|
||
|
||
await nlLite.logout();
|
||
|
||
userPubkey = null;
|
||
isLoggedIn = false;
|
||
currentConfig = null;
|
||
|
||
// Reset relay connection state
|
||
isRelayConnected = false;
|
||
relayPubkey = null;
|
||
// Reset subscription flag
|
||
isSubscribed = false;
|
||
|
||
// Reset UI - hide profile and show login modal
|
||
hideProfileFromHeader();
|
||
// showLoginModal() removed - handled by handleLogoutEvent()
|
||
|
||
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, and hyphens
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-';
|
||
let result = '';
|
||
for (let i = 0; i < 12; i++) {
|
||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||
}
|
||
return result;
|
||
}
|
||
|
||
// Configuration subscription using nostr-tools SimplePool
|
||
async function subscribeToConfiguration() {
|
||
try {
|
||
console.log('=== STARTING SIMPLEPOOL CONFIGURATION SUBSCRIPTION ===');
|
||
|
||
// Prevent multiple simultaneous subscription attempts
|
||
if (isSubscribed) {
|
||
console.log('Subscription already established, skipping duplicate subscription attempt');
|
||
return true;
|
||
}
|
||
|
||
if (!isLoggedIn) {
|
||
console.log('WARNING: Not logged in, but proceeding with subscription test');
|
||
}
|
||
|
||
const url = relayConnectionUrl.value.trim();
|
||
if (!url) {
|
||
console.error('Please enter a relay URL');
|
||
return false;
|
||
}
|
||
|
||
console.log(`Connecting to relay via SimplePool: ${url}`);
|
||
|
||
// Reuse existing pool if available, otherwise create new one
|
||
if (!relayPool) {
|
||
console.log('Creating new SimplePool instance');
|
||
relayPool = new window.NostrTools.SimplePool();
|
||
} else {
|
||
console.log('Reusing existing SimplePool instance');
|
||
}
|
||
|
||
subscriptionId = generateSubId();
|
||
|
||
console.log(`Generated subscription ID: ${subscriptionId}`);
|
||
console.log(`User pubkey ${userPubkey}`)
|
||
// Subscribe to kind 23457 events (admin response events), kind 4 (NIP-04 DMs), and kind 1059 (NIP-17 GiftWrap)
|
||
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), // Start from current time
|
||
kinds: [1059], // NIP-17 GiftWrap events
|
||
"#p": [userPubkey], // Only GiftWrap events addressed to this user
|
||
limit: 50
|
||
}], {
|
||
async onevent(event) {
|
||
console.log('=== EVENT RECEIVED VIA SIMPLEPOOL ===');
|
||
console.log('Event data:', event);
|
||
console.log('Event kind:', event.kind);
|
||
console.log('Event tags:', event.tags);
|
||
console.log('Event pubkey:', event.pubkey);
|
||
console.log('=== END EVENT ===');
|
||
|
||
// Handle NIP-04 DMs
|
||
if (event.kind === 4) {
|
||
console.log('=== NIP-04 DM RECEIVED ===');
|
||
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('=== NIP-17 GIFTWRAP RECEIVED ===');
|
||
try {
|
||
// Step 1: Unwrap gift wrap to get seal
|
||
const sealJson = await window.nostr.nip44.decrypt(event.pubkey, event.content);
|
||
const seal = safeJsonParse(sealJson);
|
||
if (!seal || seal.kind !== 13) {
|
||
throw new Error('Unwrapped content is not a valid seal (kind 13)');
|
||
}
|
||
|
||
// Step 2: Unseal to get rumor
|
||
const rumorJson = await window.nostr.nip44.decrypt(seal.pubkey, seal.content);
|
||
const rumor = safeJsonParse(rumorJson);
|
||
if (!rumor || rumor.kind !== 14) {
|
||
throw new Error('Unsealed content is not a valid rumor (kind 14)');
|
||
}
|
||
|
||
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) {
|
||
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);
|
||
}
|
||
},
|
||
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);
|
||
updateConfigStatus(false);
|
||
}
|
||
});
|
||
|
||
// Store subscription for cleanup
|
||
relayPool.currentSubscription = subscription;
|
||
|
||
// Mark as subscribed to prevent duplicate attempts
|
||
isSubscribed = true;
|
||
|
||
console.log('SimplePool subscription established');
|
||
return true;
|
||
|
||
} catch (error) {
|
||
console.error('Configuration subscription failed:', error.message);
|
||
console.error('Configuration subscription failed:', error);
|
||
console.error('Error stack:', error.stack);
|
||
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);
|
||
|
||
// Parse the decrypted JSON response
|
||
const responseData = JSON.parse(decryptedContent);
|
||
console.log('Parsed response data:', responseData);
|
||
|
||
// 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');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 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;
|
||
}
|
||
|
||
// 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);
|
||
|
||
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 for logout dropdown
|
||
function toggleLogoutDropdown(event) {
|
||
if (!logoutDropdown) return;
|
||
|
||
// Only toggle if clicking on the image, not the text or container
|
||
if (event.target === headerUserImage) {
|
||
const isVisible = logoutDropdown.style.display === 'block';
|
||
logoutDropdown.style.display = isVisible ? 'none' : 'block';
|
||
}
|
||
}
|
||
|
||
// Close logout dropdown when clicking outside
|
||
document.addEventListener('click', function(event) {
|
||
if (profileArea && logoutDropdown && !profileArea.contains(event.target)) {
|
||
logoutDropdown.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Initialize profile area click handler
|
||
if (profileArea) {
|
||
profileArea.addEventListener('click', toggleLogoutDropdown);
|
||
}
|
||
|
||
// Initialize logout button handler
|
||
if (logoutBtn) {
|
||
logoutBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation(); // Prevent profile area click
|
||
logout();
|
||
});
|
||
}
|
||
|
||
// Initialize dark mode button handler
|
||
const darkModeBtn = document.getElementById('dark-mode-btn');
|
||
if (darkModeBtn) {
|
||
darkModeBtn.addEventListener('click', function(e) {
|
||
e.stopPropagation(); // Prevent profile area click
|
||
toggleDarkMode();
|
||
});
|
||
}
|
||
|
||
// 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 line breaks for clipboard)
|
||
const fullNpub = relayPubkeyElement.textContent.replace(/\n/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');
|
||
}
|
||
}
|
||
|
||
// 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');
|
||
}
|
||
|
||
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
|
||
let formattedNpub = relayNpub;
|
||
if (relayNpub.length === 63) {
|
||
formattedNpub = relayNpub.substring(0, 21) + '\n' +
|
||
relayNpub.substring(21, 42) + '\n' +
|
||
relayNpub.substring(42, 63);
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
|
||
// 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() {
|
||
// Clear any existing interval
|
||
stopStatsAutoRefresh();
|
||
|
||
// Reset countdown
|
||
countdownSeconds = 10;
|
||
updateCountdownDisplay();
|
||
|
||
// Start countdown interval - update every second
|
||
countdownInterval = setInterval(() => {
|
||
countdownSeconds--;
|
||
updateCountdownDisplay();
|
||
|
||
if (countdownSeconds <= 0) {
|
||
// Time to refresh
|
||
if (isLoggedIn && isRelayConnected) {
|
||
log('Auto-refreshing database statistics...', 'INFO');
|
||
sendStatsQuery().then(() => {
|
||
// Flash button red on successful refresh
|
||
flashRefreshButton();
|
||
// Reset countdown
|
||
countdownSeconds = 10;
|
||
updateCountdownDisplay();
|
||
}).catch(error => {
|
||
log(`Auto-refresh failed: ${error.message}`, 'ERROR');
|
||
// Reset countdown even on failure
|
||
countdownSeconds = 10;
|
||
updateCountdownDisplay();
|
||
});
|
||
} else {
|
||
// Reset countdown if not logged in/connected
|
||
countdownSeconds = 10;
|
||
updateCountdownDisplay();
|
||
}
|
||
}
|
||
}, 1000); // Update every 1 second
|
||
|
||
log('Database statistics auto-refresh started (10 second intervals)', '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;
|
||
|
||
if (countdownInterval && isLoggedIn && isRelayConnected) {
|
||
// Japanese Kanji numbers: 〇 一 二 三 四 五 六 七 八 九 十
|
||
const kanjiNumbers = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
|
||
|
||
// Show single character counting down from 十 (10) to 〇 (0)
|
||
if (countdownSeconds >= 0 && countdownSeconds <= 10) {
|
||
refreshBtn.textContent = kanjiNumbers[countdownSeconds];
|
||
} else {
|
||
refreshBtn.textContent = '';
|
||
}
|
||
} else {
|
||
// Show empty when not active
|
||
refreshBtn.textContent = '';
|
||
}
|
||
}
|
||
|
||
// Flash refresh button red on successful refresh
|
||
function flashRefreshButton() {
|
||
const refreshBtn = document.getElementById('refresh-stats-btn');
|
||
if (!refreshBtn) return;
|
||
|
||
// Add red flash class
|
||
refreshBtn.classList.add('flash-red');
|
||
|
||
// Remove flash class after animation
|
||
setTimeout(() => {
|
||
refreshBtn.classList.remove('flash-red');
|
||
}, 500); // Match CSS animation duration
|
||
}
|
||
|
||
// 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);
|
||
}
|
||
|
||
// NIP-17 DM event handlers
|
||
if (sendDmBtn) {
|
||
sendDmBtn.addEventListener('click', sendNIP17DM);
|
||
}
|
||
});
|
||
|
||
|
||
// 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 darkModeBtn = document.getElementById('dark-mode-btn');
|
||
if (darkModeBtn) {
|
||
darkModeBtn.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);
|
||
}
|
||
}
|
||
|
||
// Initialize the app
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
console.log('C-Relay Admin API interface loaded');
|
||
|
||
// Initialize dark mode
|
||
initializeDarkMode();
|
||
|
||
// Start RELAY letter animation
|
||
startRelayAnimation();
|
||
|
||
// Ensure admin sections are hidden by default on page load
|
||
updateAdminSectionsVisibility();
|
||
|
||
setTimeout(() => {
|
||
initializeApp();
|
||
// Enhance SimplePool for testing after initialization
|
||
setTimeout(enhancePoolForTesting, 2000);
|
||
}, 100);
|
||
});
|
||
|
||
// 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();
|
||
} |