v1.2.5 - Add nip17_admin_enabled config toggle (default off) to prevent OOM from NIP-17 gift wrap decryption flood
This commit is contained in:
@@ -11,3 +11,4 @@ copy_executable_local.sh
|
|||||||
nostr_login_lite/
|
nostr_login_lite/
|
||||||
style_guide/
|
style_guide/
|
||||||
nostr-tools
|
nostr-tools
|
||||||
|
.test_keys
|
||||||
+178
@@ -1291,6 +1291,184 @@ body.dark-mode .sql-results-table tbody tr:nth-child(even) {
|
|||||||
/* background-color: var(--secondary-color); */
|
/* background-color: var(--secondary-color); */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
WEB OF TRUST (WoT) STYLES
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
.wot-status-row, .wot-stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-indicator {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-indicator.wot-found { background: #28a745; color: white; }
|
||||||
|
.wot-indicator.wot-missing { background: #dc3545; color: white; }
|
||||||
|
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
|
||||||
|
|
||||||
|
.wot-level-selector { padding: 10px 0; }
|
||||||
|
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||||
|
|
||||||
|
.wot-level-btn { min-width: 100px; }
|
||||||
|
.wot-level-btn.active {
|
||||||
|
background: var(--accent-color, #ff0000);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-color, #ff0000);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-level-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments for WoT */
|
||||||
|
body.dark-mode .wot-indicator.wot-found { background: #28a745; }
|
||||||
|
body.dark-mode .wot-indicator.wot-missing { background: #dc3545; }
|
||||||
|
body.dark-mode .wot-indicator.wot-unknown { background: #6c757d; }
|
||||||
|
|
||||||
|
/* ================================
|
||||||
|
ADMIN ACCESS GATE STYLES
|
||||||
|
================================ */
|
||||||
|
|
||||||
|
/* Access Denied Overlay */
|
||||||
|
.access-denied-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
z-index: 9999;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 2px solid #dc3545;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 60px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
box-shadow: 0 10px 40px rgba(220, 53, 69, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-content h2 {
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 32px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-message {
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-submessage {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted-color);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-logout-btn {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.access-denied-logout-btn:hover {
|
||||||
|
background: #c82333;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(220, 53, 69, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Admin Verification Loading Overlay */
|
||||||
|
.admin-verification-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 9998;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-verification-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px 60px;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-verification-content h3 {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-size: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-verification-content p {
|
||||||
|
color: var(--muted-color);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner Animation */
|
||||||
|
.spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid var(--border-color);
|
||||||
|
border-top: 4px solid var(--accent-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments */
|
||||||
|
body.dark-mode .access-denied-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.dark-mode .admin-verification-content {
|
||||||
|
background: var(--card-bg);
|
||||||
|
}
|
||||||
|
|
||||||
.subscription-detail-row:hover {
|
.subscription-detail-row:hover {
|
||||||
background-color: var(--muted-color);
|
background-color: var(--muted-color);
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-7
@@ -67,6 +67,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Access Denied Overlay (shown when non-admin user logs in) -->
|
||||||
|
<div id="access-denied-overlay" class="access-denied-overlay" style="display: none;">
|
||||||
|
<div class="access-denied-content">
|
||||||
|
<div class="access-denied-icon">⛔</div>
|
||||||
|
<h2>ACCESS DENIED</h2>
|
||||||
|
<p class="access-denied-message">This interface is restricted to the relay administrator.</p>
|
||||||
|
<p class="access-denied-submessage">The logged-in account does not have admin privileges.</p>
|
||||||
|
<button type="button" class="access-denied-logout-btn" onclick="logout()">LOGOUT</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- DATABASE STATISTICS Section -->
|
<!-- DATABASE STATISTICS Section -->
|
||||||
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
|
<!-- Subscribe to kind 24567 events to receive real-time monitoring data -->
|
||||||
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
|
<div class="section flex-section" id="databaseStatisticsSection" style="display: none;">
|
||||||
@@ -251,6 +262,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auth Rules Management - Moved after configuration -->
|
<!-- Auth Rules Management - Moved after configuration -->
|
||||||
|
<!-- AUTH RULES MANAGEMENT SECTION -->
|
||||||
<div class="section flex-section" id="authRulesSection" style="display: none;">
|
<div class="section flex-section" id="authRulesSection" style="display: none;">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
AUTH RULES MANAGEMENT
|
AUTH RULES MANAGEMENT
|
||||||
@@ -273,16 +285,11 @@ AUTH RULES MANAGEMENT
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Simplified Auth Rule Input Section -->
|
<!-- Auth Rule Input Section -->
|
||||||
<div id="authRuleInputSections" style="display: block;">
|
<div id="authRuleInputSections" style="display: block;">
|
||||||
|
|
||||||
<!-- Combined Pubkey Auth Rule Section -->
|
|
||||||
|
|
||||||
|
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
|
<label for="authRulePubkey">Pubkey (nsec or hex):</label>
|
||||||
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
|
<input type="text" id="authRulePubkey" placeholder="nsec1... or 64-character hex pubkey">
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div id="whitelistWarning" class="warning-box" style="display: none;">
|
<div id="whitelistWarning" class="warning-box" style="display: none;">
|
||||||
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
|
<strong>⚠️ WARNING:</strong> Adding whitelist rules changes relay behavior to whitelist-only
|
||||||
@@ -296,10 +303,51 @@ AUTH RULES MANAGEMENT
|
|||||||
BLACKLIST</button>
|
BLACKLIST</button>
|
||||||
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
|
<button type="button" id="refreshAuthRulesBtn">REFRESH</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WEB OF TRUST SECTION -->
|
||||||
|
<div class="section flex-section" id="wotSection" style="display: none;">
|
||||||
|
<div class="section-header">
|
||||||
|
WEB OF TRUST
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Kind 3 Status Indicator -->
|
||||||
|
<div id="wotKind3Status" class="wot-status-row">
|
||||||
|
<span>Admin Contact List (kind 3):</span>
|
||||||
|
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WoT Level Selector -->
|
||||||
|
<div class="wot-level-selector">
|
||||||
|
<label>WoT Level:</label>
|
||||||
|
<div class="inline-buttons">
|
||||||
|
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
|
||||||
|
OFF
|
||||||
|
</button>
|
||||||
|
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
|
||||||
|
WRITE ONLY
|
||||||
|
</button>
|
||||||
|
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
|
||||||
|
FULL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="wot-level-description" id="wotLevelDescription">
|
||||||
|
Level 0: Open relay — anyone can read and write
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WoT Stats -->
|
||||||
|
<div class="wot-stats-row">
|
||||||
|
<span>Whitelisted Pubkeys:</span>
|
||||||
|
<span id="wotWhitelistCount">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Button -->
|
||||||
|
<div class="inline-buttons">
|
||||||
|
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||||
|
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+411
-18
@@ -39,6 +39,11 @@ let countdownSeconds = 10;
|
|||||||
let currentPage = 'statistics'; // Default page
|
let currentPage = 'statistics'; // Default page
|
||||||
let sideNavOpen = false;
|
let sideNavOpen = false;
|
||||||
|
|
||||||
|
// Admin verification state
|
||||||
|
let isAdminVerified = false;
|
||||||
|
let adminVerificationTimeout = null;
|
||||||
|
let pendingAdminVerification = false;
|
||||||
|
|
||||||
// SQL Query state
|
// SQL Query state
|
||||||
let pendingSqlQueries = new Map();
|
let pendingSqlQueries = new Map();
|
||||||
|
|
||||||
@@ -306,7 +311,10 @@ async function restoreAuthenticationState(pubkey) {
|
|||||||
loadUserProfile();
|
loadUserProfile();
|
||||||
|
|
||||||
// Automatically set up relay connection (but don't show admin sections yet)
|
// Automatically set up relay connection (but don't show admin sections yet)
|
||||||
await setupAutomaticRelayConnection();
|
await setupAutomaticRelayConnection(false);
|
||||||
|
|
||||||
|
// Verify admin access before showing admin sections
|
||||||
|
verifyAdminAccess();
|
||||||
|
|
||||||
console.log('✅ Authentication state restored successfully');
|
console.log('✅ Authentication state restored successfully');
|
||||||
}
|
}
|
||||||
@@ -422,8 +430,8 @@ async function initializeApp() {
|
|||||||
if (wasAlreadyLoggedIn) {
|
if (wasAlreadyLoggedIn) {
|
||||||
console.log('User was already logged in, showing profile in header');
|
console.log('User was already logged in, showing profile in header');
|
||||||
showProfileInHeader();
|
showProfileInHeader();
|
||||||
// Show admin sections since user is already authenticated and relay is connected
|
// Admin verification will be triggered by restoreAuthenticationState
|
||||||
updateAdminSectionsVisibility();
|
// which calls verifyAdminAccess() after setting up relay connection
|
||||||
} else {
|
} else {
|
||||||
console.log('No existing authentication found, showing login modal');
|
console.log('No existing authentication found, showing login modal');
|
||||||
showLoginModal();
|
showLoginModal();
|
||||||
@@ -453,11 +461,11 @@ function handleAuthEvent(event) {
|
|||||||
showProfileInHeader();
|
showProfileInHeader();
|
||||||
loadUserProfile();
|
loadUserProfile();
|
||||||
|
|
||||||
// Automatically set up relay connection and show admin sections
|
// Automatically set up relay connection
|
||||||
setupAutomaticRelayConnection(true);
|
setupAutomaticRelayConnection(false); // Don't show sections yet - wait for admin verification
|
||||||
|
|
||||||
// Auto-enable monitoring when admin logs in
|
// Verify admin access before showing admin sections
|
||||||
autoEnableMonitoring();
|
verifyAdminAccess();
|
||||||
|
|
||||||
} else if (error) {
|
} else if (error) {
|
||||||
console.log(`Authentication error: ${error}`);
|
console.log(`Authentication error: ${error}`);
|
||||||
@@ -471,11 +479,25 @@ function handleLogoutEvent() {
|
|||||||
userPubkey = null;
|
userPubkey = null;
|
||||||
isLoggedIn = false;
|
isLoggedIn = false;
|
||||||
currentConfig = null;
|
currentConfig = null;
|
||||||
|
isAdminVerified = false;
|
||||||
|
|
||||||
// Reset relay connection state
|
// Reset relay connection state
|
||||||
isRelayConnected = false;
|
isRelayConnected = false;
|
||||||
relayPubkey = null;
|
relayPubkey = null;
|
||||||
|
|
||||||
|
// Clear any pending admin verification
|
||||||
|
if (adminVerificationTimeout) {
|
||||||
|
clearTimeout(adminVerificationTimeout);
|
||||||
|
adminVerificationTimeout = null;
|
||||||
|
}
|
||||||
|
pendingAdminVerification = false;
|
||||||
|
|
||||||
|
// Hide access denied overlay if showing
|
||||||
|
hideAccessDeniedOverlay();
|
||||||
|
|
||||||
|
// Hide admin verification loading if showing
|
||||||
|
hideAdminVerificationLoading();
|
||||||
|
|
||||||
// Reset UI - hide profile and show login modal
|
// Reset UI - hide profile and show login modal
|
||||||
hideProfileFromHeader();
|
hideProfileFromHeader();
|
||||||
showLoginModal();
|
showLoginModal();
|
||||||
@@ -486,12 +508,115 @@ function handleLogoutEvent() {
|
|||||||
console.log('Logout event handled successfully');
|
console.log('Logout event handled successfully');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify admin access by sending a system_status command and waiting for response
|
||||||
|
async function verifyAdminAccess() {
|
||||||
|
console.log('=== VERIFYING ADMIN ACCESS ===');
|
||||||
|
|
||||||
|
if (!isLoggedIn || !isRelayConnected) {
|
||||||
|
console.log('Cannot verify admin access - not logged in or not connected to relay');
|
||||||
|
showAccessDeniedOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update visibility of admin sections based on login and relay connection status
|
pendingAdminVerification = true;
|
||||||
|
|
||||||
|
// Show a loading indicator while verifying
|
||||||
|
showAdminVerificationLoading();
|
||||||
|
|
||||||
|
// Send system_status command to verify admin access
|
||||||
|
try {
|
||||||
|
await sendAdminCommand(['system_command', 'system_status']);
|
||||||
|
console.log('Admin verification command sent');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send admin verification command:', error);
|
||||||
|
// Continue with timeout - the command might have been queued
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set timeout for admin verification (5 seconds)
|
||||||
|
adminVerificationTimeout = setTimeout(() => {
|
||||||
|
if (pendingAdminVerification) {
|
||||||
|
console.log('⛔ Admin verification timeout - user is not admin');
|
||||||
|
pendingAdminVerification = false;
|
||||||
|
isAdminVerified = false;
|
||||||
|
hideAdminVerificationLoading();
|
||||||
|
showAccessDeniedOverlay();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading indicator while verifying admin access
|
||||||
|
function showAdminVerificationLoading() {
|
||||||
|
// Create loading overlay if it doesn't exist
|
||||||
|
let loadingOverlay = document.getElementById('admin-verification-loading');
|
||||||
|
if (!loadingOverlay) {
|
||||||
|
loadingOverlay = document.createElement('div');
|
||||||
|
loadingOverlay.id = 'admin-verification-loading';
|
||||||
|
loadingOverlay.className = 'admin-verification-overlay';
|
||||||
|
loadingOverlay.innerHTML = `
|
||||||
|
<div class="admin-verification-content">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<h3>Verifying Administrator Access...</h3>
|
||||||
|
<p>Please wait while we verify your admin privileges.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(loadingOverlay);
|
||||||
|
}
|
||||||
|
loadingOverlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide admin verification loading overlay
|
||||||
|
function hideAdminVerificationLoading() {
|
||||||
|
const loadingOverlay = document.getElementById('admin-verification-loading');
|
||||||
|
if (loadingOverlay) {
|
||||||
|
loadingOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show access denied overlay for non-admin users
|
||||||
|
function showAccessDeniedOverlay() {
|
||||||
|
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
|
||||||
|
if (accessDeniedOverlay) {
|
||||||
|
accessDeniedOverlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also hide any admin sections that might be showing
|
||||||
|
hideAllAdminSections();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide access denied overlay
|
||||||
|
function hideAccessDeniedOverlay() {
|
||||||
|
const accessDeniedOverlay = document.getElementById('access-denied-overlay');
|
||||||
|
if (accessDeniedOverlay) {
|
||||||
|
accessDeniedOverlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all admin sections
|
||||||
|
function hideAllAdminSections() {
|
||||||
|
const sections = [
|
||||||
|
'databaseStatisticsSection',
|
||||||
|
'subscriptionDetailsSection',
|
||||||
|
'div_config',
|
||||||
|
'authRulesSection',
|
||||||
|
'wotSection',
|
||||||
|
'nip17DMSection',
|
||||||
|
'sqlQuerySection'
|
||||||
|
];
|
||||||
|
|
||||||
|
sections.forEach(sectionId => {
|
||||||
|
const section = document.getElementById(sectionId);
|
||||||
|
if (section) {
|
||||||
|
section.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Update visibility of admin sections based on login, relay connection, and admin verification status
|
||||||
function updateAdminSectionsVisibility() {
|
function updateAdminSectionsVisibility() {
|
||||||
const shouldShow = isLoggedIn && isRelayConnected;
|
const shouldShow = isLoggedIn && isRelayConnected && isAdminVerified;
|
||||||
|
|
||||||
// If logged in and connected, show the current page, otherwise hide all sections
|
// If logged in, connected, and admin verified, show the current page
|
||||||
if (shouldShow) {
|
if (shouldShow) {
|
||||||
// Show the current page
|
// Show the current page
|
||||||
switchPage(currentPage);
|
switchPage(currentPage);
|
||||||
@@ -499,12 +624,13 @@ function updateAdminSectionsVisibility() {
|
|||||||
// Load data for the current page
|
// Load data for the current page
|
||||||
loadCurrentPageData();
|
loadCurrentPageData();
|
||||||
} else {
|
} else {
|
||||||
// Hide all sections when not logged in or not connected
|
// Hide all sections when not logged in, not connected, or not admin
|
||||||
const sections = [
|
const sections = [
|
||||||
'databaseStatisticsSection',
|
'databaseStatisticsSection',
|
||||||
'subscriptionDetailsSection',
|
'subscriptionDetailsSection',
|
||||||
'div_config',
|
'div_config',
|
||||||
'authRulesSection',
|
'authRulesSection',
|
||||||
|
'wotSection',
|
||||||
'nip17DMSection',
|
'nip17DMSection',
|
||||||
'sqlQuerySection'
|
'sqlQuerySection'
|
||||||
];
|
];
|
||||||
@@ -543,6 +669,10 @@ function loadCurrentPageData() {
|
|||||||
loadAuthRules().catch(error => {
|
loadAuthRules().catch(error => {
|
||||||
console.log('Auto-load auth rules failed: ' + error.message);
|
console.log('Auto-load auth rules failed: ' + error.message);
|
||||||
});
|
});
|
||||||
|
// Load WoT status
|
||||||
|
loadWotStatus().catch(error => {
|
||||||
|
console.log('Auto-load WoT status failed: ' + error.message);
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
// Other pages don't need initial data loading
|
// Other pages don't need initial data loading
|
||||||
}
|
}
|
||||||
@@ -1717,6 +1847,31 @@ function handleSystemCommandResponse(responseData) {
|
|||||||
console.log('Command:', responseData.command);
|
console.log('Command:', responseData.command);
|
||||||
console.log('Status:', responseData.status);
|
console.log('Status:', responseData.status);
|
||||||
|
|
||||||
|
// Handle admin verification via system_status response
|
||||||
|
if (responseData.command === 'system_status' && pendingAdminVerification) {
|
||||||
|
console.log('✅ Admin verification successful - received system_status response');
|
||||||
|
|
||||||
|
// Clear the timeout
|
||||||
|
if (adminVerificationTimeout) {
|
||||||
|
clearTimeout(adminVerificationTimeout);
|
||||||
|
adminVerificationTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingAdminVerification = false;
|
||||||
|
isAdminVerified = true;
|
||||||
|
|
||||||
|
// Hide loading overlay
|
||||||
|
hideAdminVerificationLoading();
|
||||||
|
|
||||||
|
// Show admin sections now that we're verified
|
||||||
|
updateAdminSectionsVisibility();
|
||||||
|
|
||||||
|
// Auto-enable monitoring when admin logs in
|
||||||
|
autoEnableMonitoring();
|
||||||
|
|
||||||
|
log('Administrator access verified', 'INFO');
|
||||||
|
}
|
||||||
|
|
||||||
// Handle delete auth rule responses
|
// Handle delete auth rule responses
|
||||||
if (responseData.command === 'delete_auth_rule') {
|
if (responseData.command === 'delete_auth_rule') {
|
||||||
if (responseData.status === 'success') {
|
if (responseData.status === 'success') {
|
||||||
@@ -1741,11 +1896,224 @@ function handleSystemCommandResponse(responseData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle WoT status and sync responses
|
||||||
|
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
|
||||||
|
handleWotStatusResponse(responseData);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof logTestEvent === 'function') {
|
if (typeof logTestEvent === 'function') {
|
||||||
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
|
logTestEvent('RECV', `System command response: ${responseData.command} - ${responseData.status}`, 'SYSTEM_CMD');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// WEB OF TRUST (WoT) FUNCTIONS
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
// Load WoT status from relay using admin API
|
||||||
|
async function loadWotStatus() {
|
||||||
|
try {
|
||||||
|
log('Loading WoT status via admin API...', 'INFO');
|
||||||
|
|
||||||
|
if (!isLoggedIn || !userPubkey) {
|
||||||
|
throw new Error('Must be logged in to load WoT status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayPool) {
|
||||||
|
throw new Error('SimplePool connection not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command array for WoT status
|
||||||
|
const command_array = ["system_command", "wot_status"];
|
||||||
|
|
||||||
|
// 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 WoT status command');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create single kind 23456 admin event
|
||||||
|
const event = {
|
||||||
|
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(event);
|
||||||
|
if (!signedEvent || !signedEvent.sig) {
|
||||||
|
throw new Error('Event signing failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Sending WoT status query to relay...', 'INFO');
|
||||||
|
|
||||||
|
// Publish via SimplePool
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log('WoT status query sent successfully', 'INFO');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to load WoT status: ${error.message}`, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set WoT level via config_set
|
||||||
|
async function setWotLevel(level) {
|
||||||
|
try {
|
||||||
|
log(`Setting WoT level to ${level}...`, 'INFO');
|
||||||
|
|
||||||
|
if (!isLoggedIn || !userPubkey) {
|
||||||
|
throw new Error('Must be logged in to set WoT level');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayPool) {
|
||||||
|
throw new Error('SimplePool connection not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send config_set for wot_enabled
|
||||||
|
const command_array = ["config_set", "wot_enabled", String(level)];
|
||||||
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||||
|
if (!encrypted_content) {
|
||||||
|
throw new Error('Failed to encrypt config_set command');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create single kind 23456 admin event
|
||||||
|
const event = {
|
||||||
|
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(event);
|
||||||
|
if (!signedEvent || !signedEvent.sig) {
|
||||||
|
throw new Error('Event signing failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish via SimplePool
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log(`WoT level set to ${level} - sync will trigger automatically`, 'INFO');
|
||||||
|
|
||||||
|
// Refresh status after a delay to allow sync to complete
|
||||||
|
setTimeout(() => loadWotStatus(), 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to set WoT level: ${error.message}`, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force WoT sync from admin's kind 3 event
|
||||||
|
async function syncWot() {
|
||||||
|
try {
|
||||||
|
log('Triggering WoT sync from kind 3...', 'INFO');
|
||||||
|
|
||||||
|
if (!isLoggedIn || !userPubkey) {
|
||||||
|
throw new Error('Must be logged in to sync WoT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!relayPool) {
|
||||||
|
throw new Error('SimplePool connection not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create command array for WoT sync
|
||||||
|
const command_array = ["system_command", "wot_sync"];
|
||||||
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||||
|
if (!encrypted_content) {
|
||||||
|
throw new Error('Failed to encrypt WoT sync command');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create single kind 23456 admin event
|
||||||
|
const event = {
|
||||||
|
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(event);
|
||||||
|
if (!signedEvent || !signedEvent.sig) {
|
||||||
|
throw new Error('Event signing failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish via SimplePool
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log('WoT sync command sent', 'INFO');
|
||||||
|
|
||||||
|
// Refresh status after a delay
|
||||||
|
setTimeout(() => loadWotStatus(), 2000);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`Failed to sync WoT: ${error.message}`, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle WoT status response and update UI
|
||||||
|
function handleWotStatusResponse(responseData) {
|
||||||
|
console.log('=== WoT STATUS RESPONSE ===', responseData);
|
||||||
|
|
||||||
|
const kind3Indicator = document.getElementById('wotKind3Indicator');
|
||||||
|
const whitelistCount = document.getElementById('wotWhitelistCount');
|
||||||
|
const levelDesc = document.getElementById('wotLevelDescription');
|
||||||
|
|
||||||
|
// Update kind 3 indicator
|
||||||
|
if (kind3Indicator) {
|
||||||
|
if (responseData.admin_kind3_exists) {
|
||||||
|
kind3Indicator.textContent = 'Found ✓';
|
||||||
|
kind3Indicator.className = 'wot-indicator wot-found';
|
||||||
|
} else {
|
||||||
|
kind3Indicator.textContent = 'Not Found ✗';
|
||||||
|
kind3Indicator.className = 'wot-indicator wot-missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update whitelist count
|
||||||
|
if (whitelistCount) {
|
||||||
|
whitelistCount.textContent = responseData.wot_whitelist_count !== undefined ? responseData.wot_whitelist_count : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update level buttons
|
||||||
|
const level = responseData.wot_enabled !== undefined ? responseData.wot_enabled : 0;
|
||||||
|
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (btn) {
|
||||||
|
if (i === level) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update description
|
||||||
|
const descriptions = [
|
||||||
|
'Level 0: Open relay — anyone can read and write',
|
||||||
|
'Level 1: Write-only — only followed pubkeys can publish events',
|
||||||
|
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
|
||||||
|
];
|
||||||
|
if (levelDesc) {
|
||||||
|
levelDesc.textContent = descriptions[level] || descriptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable sync button based on kind 3 existence
|
||||||
|
const syncBtn = document.getElementById('wotSyncBtn');
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.disabled = !responseData.admin_kind3_exists;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`WoT status: level=${level}, kind3_exists=${responseData.admin_kind3_exists}, whitelist_count=${responseData.wot_whitelist_count}`, 'INFO');
|
||||||
|
}
|
||||||
|
|
||||||
// Handle auth rule modification responses
|
// Handle auth rule modification responses
|
||||||
function handleAuthRuleResponse(responseData) {
|
function handleAuthRuleResponse(responseData) {
|
||||||
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
|
console.log('=== AUTH RULE MODIFICATION RESPONSE ===');
|
||||||
@@ -2206,6 +2574,7 @@ let editingAuthRule = null;
|
|||||||
|
|
||||||
// DOM elements for auth rules
|
// DOM elements for auth rules
|
||||||
const authRulesSection = document.getElementById('authRulesSection');
|
const authRulesSection = document.getElementById('authRulesSection');
|
||||||
|
const wotSection = document.getElementById('wotSection');
|
||||||
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
|
const refreshAuthRulesBtn = document.getElementById('refreshAuthRulesBtn');
|
||||||
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
|
const authRulesTableContainer = document.getElementById('authRulesTableContainer');
|
||||||
const authRulesTableBody = document.getElementById('authRulesTableBody');
|
const authRulesTableBody = document.getElementById('authRulesTableBody');
|
||||||
@@ -2222,6 +2591,12 @@ function showAuthRulesSection() {
|
|||||||
updateAuthRulesStatus('ready');
|
updateAuthRulesStatus('ready');
|
||||||
log('Auth rules section is now available', 'INFO');
|
log('Auth rules section is now available', 'INFO');
|
||||||
}
|
}
|
||||||
|
// Also show WoT section
|
||||||
|
if (wotSection) {
|
||||||
|
wotSection.style.display = 'block';
|
||||||
|
loadWotStatus();
|
||||||
|
log('WoT section is now available', 'INFO');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide auth rules section on logout
|
// Hide auth rules section on logout
|
||||||
@@ -2241,6 +2616,11 @@ function hideAuthRulesSection() {
|
|||||||
editingAuthRule = null;
|
editingAuthRule = null;
|
||||||
log('Auth rules section hidden', 'INFO');
|
log('Auth rules section hidden', 'INFO');
|
||||||
}
|
}
|
||||||
|
// Also hide WoT section
|
||||||
|
if (wotSection) {
|
||||||
|
wotSection.style.display = 'none';
|
||||||
|
log('WoT section hidden', 'INFO');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update auth rules status indicator (removed - no status element)
|
// Update auth rules status indicator (removed - no status element)
|
||||||
@@ -4728,6 +5108,7 @@ function switchPage(pageName) {
|
|||||||
'subscriptionDetailsSection',
|
'subscriptionDetailsSection',
|
||||||
'div_config',
|
'div_config',
|
||||||
'authRulesSection',
|
'authRulesSection',
|
||||||
|
'wotSection',
|
||||||
'relayEventsSection',
|
'relayEventsSection',
|
||||||
'nip17DMSection',
|
'nip17DMSection',
|
||||||
'sqlQuerySection'
|
'sqlQuerySection'
|
||||||
@@ -4740,22 +5121,34 @@ function switchPage(pageName) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show selected section
|
// Show selected section(s)
|
||||||
const pageMap = {
|
const pageMap = {
|
||||||
'statistics': 'databaseStatisticsSection',
|
'statistics': 'databaseStatisticsSection',
|
||||||
'subscriptions': 'subscriptionDetailsSection',
|
'subscriptions': 'subscriptionDetailsSection',
|
||||||
'configuration': 'div_config',
|
'configuration': 'div_config',
|
||||||
'authorization': 'authRulesSection',
|
|
||||||
'relay-events': 'relayEventsSection',
|
'relay-events': 'relayEventsSection',
|
||||||
'dm': 'nip17DMSection',
|
'dm': 'nip17DMSection',
|
||||||
'database': 'sqlQuerySection'
|
'database': 'sqlQuerySection'
|
||||||
};
|
};
|
||||||
|
|
||||||
const targetSectionId = pageMap[pageName];
|
// Authorization page shows both authRulesSection and wotSection
|
||||||
if (targetSectionId) {
|
if (pageName === 'authorization') {
|
||||||
const targetSection = document.getElementById(targetSectionId);
|
const authSection = document.getElementById('authRulesSection');
|
||||||
if (targetSection) {
|
const wotSectionEl = document.getElementById('wotSection');
|
||||||
targetSection.style.display = 'block';
|
if (authSection) {
|
||||||
|
authSection.style.display = 'block';
|
||||||
|
}
|
||||||
|
if (wotSectionEl) {
|
||||||
|
wotSectionEl.style.display = 'block';
|
||||||
|
loadWotStatus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const targetSectionId = pageMap[pageName];
|
||||||
|
if (targetSectionId) {
|
||||||
|
const targetSection = document.getElementById(targetSectionId);
|
||||||
|
if (targetSection) {
|
||||||
|
targetSection.style.display = 'block';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,24 @@ while [[ $# -gt 0 ]]; do
|
|||||||
;;
|
;;
|
||||||
--test-keys|-t)
|
--test-keys|-t)
|
||||||
USE_TEST_KEYS=true
|
USE_TEST_KEYS=true
|
||||||
|
# Read keys from .test_keys file
|
||||||
|
if [ -f ".test_keys" ]; then
|
||||||
|
echo "Reading test keys from .test_keys file..."
|
||||||
|
# Source the file to get the variables
|
||||||
|
source .test_keys
|
||||||
|
# Remove any single quotes from the values
|
||||||
|
# Note: -a flag expects ADMIN_PUBKEY (public key), not ADMIN_PRIVKEY
|
||||||
|
ADMIN_KEY=$(echo "$ADMIN_PUBKEY" | tr -d "'")
|
||||||
|
RELAY_KEY=$(echo "$SERVER_PRIVKEY" | tr -d "'")
|
||||||
|
echo "Using admin pubkey from .test_keys: ${ADMIN_KEY:0:16}..."
|
||||||
|
echo "Using relay privkey from .test_keys: ${RELAY_KEY:0:16}..."
|
||||||
|
else
|
||||||
|
echo "ERROR: .test_keys file not found"
|
||||||
|
echo "Please create a .test_keys file with the following format:"
|
||||||
|
echo " ADMIN_PUBKEY='your_admin_public_key_hex'"
|
||||||
|
echo " SERVER_PRIVKEY='your_relay_private_key_hex'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
--debug-level=*)
|
--debug-level=*)
|
||||||
@@ -336,8 +354,8 @@ fi
|
|||||||
cd build
|
cd build
|
||||||
# Start relay in background and capture its PID
|
# Start relay in background and capture its PID
|
||||||
if [ "$USE_TEST_KEYS" = true ]; then
|
if [ "$USE_TEST_KEYS" = true ]; then
|
||||||
echo "Using deterministic test keys for development..."
|
echo "Using test keys from .test_keys file..."
|
||||||
./$(basename $BINARY_PATH) -a 6a04ab98d9e4774ad806e302dddeb63bea16b5cb5f223ee77478e861bb583eb3 -r 1111111111111111111111111111111111111111111111111111111111111111 --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
./$(basename $BINARY_PATH) -a "$ADMIN_KEY" -r "$RELAY_KEY" --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
||||||
elif [ -n "$RELAY_ARGS" ]; then
|
elif [ -n "$RELAY_ARGS" ]; then
|
||||||
echo "Starting relay with custom configuration..."
|
echo "Starting relay with custom configuration..."
|
||||||
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
./$(basename $BINARY_PATH) $RELAY_ARGS --debug-level=$DEBUG_LEVEL --strict-port > ../relay.log 2>&1 &
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Event Tags Denormalization Plan
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
The relay is at 99% CPU with 1,178 WebSocket connections and ~120 new REQ subscriptions per minute. The root cause is that every tag-filtered REQ query (e.g., `#g`, `#e`, `#p`) uses `json_each(json(tags))` with `json_extract()` — a correlated subquery that parses the JSON tags column for every candidate row. With geohash-based subscriptions from a location app generating constant connection churn, this creates unsustainable CPU load.
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
- `EXPLAIN QUERY PLAN` shows: `CORRELATED SCALAR SUBQUERY → SCAN json_each VIRTUAL TABLE`
|
||||||
|
- 8,077 subscription creates/hour but only 442 active at any time (connection churn)
|
||||||
|
- Only 56 events stored/hour — the load is entirely from **read queries**, not writes
|
||||||
|
- 14,819 events match the kind filter; each query parses JSON tags on candidate rows
|
||||||
|
|
||||||
|
## Solution: Denormalized `event_tags` Table
|
||||||
|
|
||||||
|
Replace `json_each()` queries with indexed lookups on a separate `event_tags` table. This is the standard approach used by strfry, nostream, and other production Nostr relays.
|
||||||
|
|
||||||
|
### Performance Impact
|
||||||
|
|
||||||
|
| Metric | Before | After |
|
||||||
|
|--------|--------|-------|
|
||||||
|
| Tag query type | `json_each()` correlated subquery | Indexed JOIN/subquery |
|
||||||
|
| Complexity per query | O(rows × tags_per_event) JSON parsing | O(log n) B-tree lookup |
|
||||||
|
| Expected CPU reduction | 99% | <10% for same traffic |
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/sql_schema.h` | Add `event_tags` table, indexes, cascade triggers; bump to schema v12 |
|
||||||
|
| `src/main.c` | Add `store_event_tags()`, update `handle_req_message()` tag filter SQL, move config reads out of row loop, add startup tag population |
|
||||||
|
| `src/nip009.c` | Add `DELETE FROM event_tags` alongside event deletions |
|
||||||
|
| `src/dm_admin.c` | Downgrade NIP-17 decryption failure log level |
|
||||||
|
| `src/websockets.c` | Downgrade NIP-17 error log to DEBUG at lines 780 and 1488 |
|
||||||
|
|
||||||
|
## Detailed Changes
|
||||||
|
|
||||||
|
### 1. Schema: `src/sql_schema.h`
|
||||||
|
|
||||||
|
Add the `event_tags` table after the events table definition. Bump schema version to 12.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Denormalized event tags for fast indexed lookups
|
||||||
|
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row
|
||||||
|
CREATE TABLE event_tags (
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
tag_name TEXT NOT NULL,
|
||||||
|
tag_value TEXT NOT NULL,
|
||||||
|
tag_index INTEGER NOT NULL DEFAULT 0,
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Primary lookup index: find events by tag name + value
|
||||||
|
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);
|
||||||
|
|
||||||
|
-- Reverse lookup: find all tags for an event (for cleanup)
|
||||||
|
CREATE INDEX idx_event_tags_event ON event_tags(event_id);
|
||||||
|
|
||||||
|
-- Composite index for common query pattern: tag + kind (via join)
|
||||||
|
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key design decisions:**
|
||||||
|
- `ON DELETE CASCADE` handles cleanup when events are deleted (NIP-09, replaceable events)
|
||||||
|
- `tag_index` stores the position within the tags array (useful for ordered tag access)
|
||||||
|
- Only the first two elements of each tag are indexed (`tag_name` = `$[0]`, `tag_value` = `$[1]`) — this covers all standard Nostr tag filters (`#e`, `#p`, `#t`, `#g`, `#d`, etc.)
|
||||||
|
- `PRAGMA foreign_keys = ON` is already in the schema, so CASCADE will work
|
||||||
|
|
||||||
|
### 2. Store Tags: `src/main.c` — New `store_event_tags()` Function
|
||||||
|
|
||||||
|
Add a new function called after successful event INSERT in `store_event()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Insert denormalized tags into event_tags table for fast indexed lookups
|
||||||
|
int store_event_tags(const char* event_id, cJSON* tags) {
|
||||||
|
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
|
||||||
|
return 0; // Not an error if no tags
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
|
||||||
|
sqlite3_stmt* stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tag_index = 0;
|
||||||
|
cJSON* tag = NULL;
|
||||||
|
cJSON_ArrayForEach(tag, tags) {
|
||||||
|
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
|
||||||
|
cJSON* name = cJSON_GetArrayItem(tag, 0);
|
||||||
|
cJSON* value = cJSON_GetArrayItem(tag, 1);
|
||||||
|
|
||||||
|
if (cJSON_IsString(name) && cJSON_IsString(value)) {
|
||||||
|
sqlite3_reset(stmt);
|
||||||
|
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_int(stmt, 4, tag_index);
|
||||||
|
|
||||||
|
rc = sqlite3_step(stmt);
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Call site** — in `store_event()` at line ~784, after `monitoring_on_event_stored()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// After successful event storage, insert denormalized tags
|
||||||
|
store_event_tags(cJSON_GetStringValue(id), tags);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Update REQ Query Builder: `src/main.c` — `handle_req_message()`
|
||||||
|
|
||||||
|
Replace the `json_each()` tag filter at lines 1243-1270 with an `event_tags` subquery:
|
||||||
|
|
||||||
|
**Before** (current code at line 1244):
|
||||||
|
```c
|
||||||
|
snprintf(sql_ptr, remaining,
|
||||||
|
" AND EXISTS (SELECT 1 FROM json_each(json(tags)) "
|
||||||
|
"WHERE json_extract(value, '$[0]') = ? "
|
||||||
|
"AND json_extract(value, '$[1]') IN (");
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (new code):
|
||||||
|
```c
|
||||||
|
snprintf(sql_ptr, remaining,
|
||||||
|
" AND id IN (SELECT event_id FROM event_tags "
|
||||||
|
"WHERE tag_name = ? AND tag_value IN (");
|
||||||
|
```
|
||||||
|
|
||||||
|
The rest of the parameter binding code (lines 1248-1270) stays the same — the bind params are identical (`tag_name` then `tag_value(s)`). Only the SQL template changes.
|
||||||
|
|
||||||
|
The closing parenthesis changes from `"))` to `"))` — same syntax, just different semantics.
|
||||||
|
|
||||||
|
### 4. NIP-09 Delete Cascade: `src/nip009.c`
|
||||||
|
|
||||||
|
The `ON DELETE CASCADE` foreign key handles this automatically. When `DELETE FROM events WHERE id = ?` executes, SQLite will automatically delete matching rows from `event_tags`. **No code changes needed in nip009.c** as long as `PRAGMA foreign_keys = ON` is set (it already is in the schema).
|
||||||
|
|
||||||
|
However, verify the replaceable/addressable event triggers in the schema also cascade properly. The existing triggers at schema lines 97-119 use `DELETE FROM events WHERE ...` which will trigger the CASCADE.
|
||||||
|
|
||||||
|
### 5. Performance Fix: Move Config Reads Out of Row Loop
|
||||||
|
|
||||||
|
In `handle_req_message()` at lines 1400-1401, `get_config_bool()` is called **inside the `while (sqlite3_step())` loop** — meaning it executes a SQLite query for every row returned. Move these before the loop:
|
||||||
|
|
||||||
|
**Before** (inside loop):
|
||||||
|
```c
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
// ...
|
||||||
|
int expiration_enabled = get_config_bool("expiration_enabled", 1); // SQLite query per row!
|
||||||
|
int filter_responses = get_config_bool("expiration_filter", 1); // SQLite query per row!
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (before loop):
|
||||||
|
```c
|
||||||
|
int expiration_enabled = get_config_bool("expiration_enabled", 1);
|
||||||
|
int filter_responses = get_config_bool("expiration_filter", 1);
|
||||||
|
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
// ... use cached values
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Log Level Fixes
|
||||||
|
|
||||||
|
#### NIP-17 Decryption Failure — `src/websockets.c` lines 780 and 1488
|
||||||
|
|
||||||
|
Change from `DEBUG_ERROR` to `DEBUG_INFO`:
|
||||||
|
```c
|
||||||
|
// Before:
|
||||||
|
DEBUG_ERROR("NIP-17 admin message processing failed");
|
||||||
|
// After:
|
||||||
|
DEBUG_INFO("NIP-17 admin message processing failed");
|
||||||
|
```
|
||||||
|
|
||||||
|
This is expected behavior when non-admin gift wraps arrive — not an error condition.
|
||||||
|
|
||||||
|
#### Duplicate Event INSERT — `src/main.c` line 741
|
||||||
|
|
||||||
|
The `DEBUG_ERROR` at line 741 fires before the CONSTRAINT check at line 746. Suppress it for constraint violations:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Before (line 738-741):
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
const char* err_msg = sqlite3_errmsg(g_db);
|
||||||
|
int extended_errcode = sqlite3_extended_errcode(g_db);
|
||||||
|
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After:
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
const char* err_msg = sqlite3_errmsg(g_db);
|
||||||
|
int extended_errcode = sqlite3_extended_errcode(g_db);
|
||||||
|
if (rc != SQLITE_CONSTRAINT) {
|
||||||
|
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Startup Tag Population
|
||||||
|
|
||||||
|
Since you said no migration code is needed (fresh deploy), the `event_tags` table will start empty and populate as new events arrive. Existing events won't have tags in the lookup table.
|
||||||
|
|
||||||
|
However, to avoid a period where old events don't appear in tag-filtered queries, add a one-time population function that runs at startup:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Populate event_tags from existing events (run once at startup)
|
||||||
|
int populate_event_tags_from_existing(void) {
|
||||||
|
if (!g_db) return -1;
|
||||||
|
|
||||||
|
// Check if event_tags is already populated
|
||||||
|
sqlite3_stmt* check_stmt;
|
||||||
|
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
|
||||||
|
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
|
||||||
|
sqlite3_finalize(check_stmt);
|
||||||
|
DEBUG_INFO("event_tags already populated, skipping");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(check_stmt);
|
||||||
|
|
||||||
|
DEBUG_INFO("Populating event_tags from existing events...");
|
||||||
|
|
||||||
|
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
|
||||||
|
sqlite3_stmt* stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) return -1;
|
||||||
|
|
||||||
|
// Use a transaction for bulk insert performance
|
||||||
|
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
|
||||||
|
|
||||||
|
int event_count = 0;
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
|
||||||
|
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
|
||||||
|
|
||||||
|
if (event_id && tags_json) {
|
||||||
|
cJSON* tags = cJSON_Parse(tags_json);
|
||||||
|
if (tags) {
|
||||||
|
store_event_tags(event_id, tags);
|
||||||
|
cJSON_Delete(tags);
|
||||||
|
event_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
|
||||||
|
|
||||||
|
DEBUG_INFO("Populated event_tags for %d events", event_count);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Call this during startup, after database initialization but before accepting connections.
|
||||||
|
|
||||||
|
## Architecture Diagram
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph "Current: O(n) per query"
|
||||||
|
A1["REQ with #g filter"] --> B1["SQL: SELECT ... WHERE EXISTS"]
|
||||||
|
B1 --> C1["json_each(json(tags))"]
|
||||||
|
C1 --> D1["json_extract per row"]
|
||||||
|
D1 --> E1["🔴 Full JSON parse per candidate row"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "New: O(log n) per query"
|
||||||
|
A2["REQ with #g filter"] --> B2["SQL: SELECT ... WHERE id IN"]
|
||||||
|
B2 --> C2["event_tags table"]
|
||||||
|
C2 --> D2["idx_event_tags_lookup"]
|
||||||
|
D2 --> E2["🟢 B-tree index lookup"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Write Path (unchanged speed)"
|
||||||
|
F["EVENT arrives"] --> G["store_event()"]
|
||||||
|
G --> H["INSERT INTO events"]
|
||||||
|
H --> I["store_event_tags()"]
|
||||||
|
I --> J["INSERT INTO event_tags\n(one row per tag)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Delete Path (automatic)"
|
||||||
|
K["NIP-09 DELETE"] --> L["DELETE FROM events"]
|
||||||
|
L --> M["ON DELETE CASCADE"]
|
||||||
|
M --> N["event_tags rows auto-deleted"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. Run existing test suite: `tests/run_all_tests.sh`
|
||||||
|
2. Specifically run NIP tests that use tag filters: `tests/run_nip_tests.sh`
|
||||||
|
3. Verify tag-filtered REQ queries return correct results
|
||||||
|
4. Verify NIP-09 deletion cascades to event_tags
|
||||||
|
5. Verify replaceable event triggers cascade to event_tags
|
||||||
|
6. Monitor CPU usage after deployment with same traffic pattern
|
||||||
|
|
||||||
|
## Risk Assessment
|
||||||
|
|
||||||
|
- **Low risk**: The `event_tags` table is additive — it doesn't change the events table structure
|
||||||
|
- **Low risk**: `ON DELETE CASCADE` is a well-tested SQLite feature
|
||||||
|
- **Low risk**: The SQL change in `handle_req_message()` is a drop-in replacement (same bind params)
|
||||||
|
- **Medium risk**: Startup population on a large database could take a few seconds — but with only 55K events, this should complete in under 1 second
|
||||||
|
- **Write overhead**: Each event INSERT now also inserts ~5 rows into event_tags — negligible at 56 events/hour
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
# Web of Trust (WoT) Implementation Plan
|
||||||
|
|
||||||
|
## Feature Description
|
||||||
|
|
||||||
|
When enabled, the relay restricts access to pubkeys that the admin follows. The admin's kind 3 (contact list) event is used as the source of truth — all `p` tags in that event become whitelisted pubkeys. The admin themselves is always whitelisted.
|
||||||
|
|
||||||
|
Single config variable `wot_enabled` with three levels:
|
||||||
|
|
||||||
|
| Value | Mode | Effect |
|
||||||
|
|-------|------|--------|
|
||||||
|
| `0` | Off | Open relay — anyone can read and write |
|
||||||
|
| `1` | Write-only | Only followed pubkeys can **publish** events. Anyone can read/subscribe. |
|
||||||
|
| `2` | Full | Only followed pubkeys can **publish AND subscribe**. Requires NIP-42 auth for subscriptions. Eliminates subscription churn from anonymous connections. |
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
### WoT Sync Flow
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["Admin publishes kind 3 event\n- contact list -"] --> B{wot_enabled > 0?}
|
||||||
|
B -->|No| C["Store event normally"]
|
||||||
|
B -->|Yes| D["Extract p tags from kind 3"]
|
||||||
|
D --> E["Clear existing WoT whitelist rules"]
|
||||||
|
E --> F["Insert new whitelist rules\nfor each followed pubkey"]
|
||||||
|
F --> G["Enable auth_enabled"]
|
||||||
|
G --> H{wot_enabled == 2?}
|
||||||
|
H -->|Yes| I["Enable nip42_auth_required_subscriptions"]
|
||||||
|
H -->|No| J["Done - write-only restriction"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Publishing Flow — Write Restriction (wot_enabled >= 1)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["EVENT message arrives"] --> B{auth_enabled?}
|
||||||
|
B -->|No| C["Accept event"]
|
||||||
|
B -->|Yes| D["check_database_auth_rules - pubkey -"]
|
||||||
|
D --> E{Pubkey in whitelist\nor wot_whitelist?}
|
||||||
|
E -->|Yes| C
|
||||||
|
E -->|No| F{Any whitelist rules exist?}
|
||||||
|
F -->|Yes| G["REJECT: not whitelisted"]
|
||||||
|
F -->|No| C
|
||||||
|
```
|
||||||
|
|
||||||
|
### Subscription Flow — Read Restriction (wot_enabled == 2)
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["REQ message arrives"] --> B{wot_enabled == 2?}
|
||||||
|
B -->|No| C["Allow subscription"]
|
||||||
|
B -->|Yes| D{NIP-42 authenticated?}
|
||||||
|
D -->|No| E["Send AUTH challenge"]
|
||||||
|
D -->|Yes| F["check_database_auth_rules\nusing authenticated_pubkey"]
|
||||||
|
F --> G{Pubkey in whitelist\nor wot_whitelist?}
|
||||||
|
G -->|Yes| C
|
||||||
|
G -->|No| H["REJECT: not authorized\nfor subscriptions"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Design Decisions
|
||||||
|
|
||||||
|
### Leveraging Existing Infrastructure
|
||||||
|
|
||||||
|
The relay already has everything needed:
|
||||||
|
|
||||||
|
1. **`auth_rules` table** — stores whitelist/blacklist rules with `rule_type`, `pattern_type`, `pattern_value`
|
||||||
|
2. **`check_database_auth_rules()`** in [`request_validator.c:529`](src/request_validator.c:529) — already implements the logic: "if whitelist rules exist and pubkey is not whitelisted, deny"
|
||||||
|
3. **`add_auth_rule_from_config()`** / **`remove_auth_rule_from_config()`** in [`config.c`](src/config.c:2082) — already manage auth rules
|
||||||
|
4. **`event_tags` table** — can efficiently query `p` tags from kind 3 events
|
||||||
|
5. **Config system** — `auth_enabled` flag already controls whether auth rules are checked
|
||||||
|
6. **NIP-42 auth** — `nip42_auth_required_subscriptions` already gates REQ access, `pss->authenticated_pubkey` stores the authenticated pubkey
|
||||||
|
|
||||||
|
### WoT-Specific Whitelist Rules
|
||||||
|
|
||||||
|
To distinguish WoT-generated whitelist rules from manually-added ones, we use a new `rule_type` value: `wot_whitelist`. This allows:
|
||||||
|
- Clearing all WoT rules without affecting manual whitelist/blacklist rules
|
||||||
|
- Querying WoT status separately
|
||||||
|
- The existing `check_database_auth_rules()` function already checks for `rule_type = 'whitelist'` — we need to also match `wot_whitelist` in the whitelist check
|
||||||
|
|
||||||
|
### Trigger Mechanism
|
||||||
|
|
||||||
|
The WoT sync happens when:
|
||||||
|
1. **A kind 3 event from the admin is stored** — detected in `store_event()` after successful INSERT
|
||||||
|
2. **Startup** — if `wot_enabled > 0`, sync from the most recent admin kind 3 event in the database
|
||||||
|
3. **Admin DM command** — `wot sync` to force a manual resync
|
||||||
|
|
||||||
|
### What Gets Whitelisted
|
||||||
|
|
||||||
|
- All pubkeys in `p` tags of the admin's kind 3 event
|
||||||
|
- The admin pubkey itself (always whitelisted)
|
||||||
|
- The relay pubkey (always whitelisted — for admin DM responses)
|
||||||
|
|
||||||
|
### What Is NOT Blocked
|
||||||
|
|
||||||
|
Even with WoT enabled, these are always allowed:
|
||||||
|
- Admin events (kind 23456) — already bypassed in [`request_validator.c:303`](src/request_validator.c:303)
|
||||||
|
- NIP-42 auth events (kind 22242) — already bypassed in [`request_validator.c:318`](src/request_validator.c:318)
|
||||||
|
- Kind 3 events from the admin — needed to update the follow list itself
|
||||||
|
- Kind 1059 gift wraps addressed to the relay — needed for admin DMs
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/default_config_event.h` | Add `wot_enabled` config key (default: `0`) |
|
||||||
|
| `src/config.c` | Add `wot_sync_from_admin_kind3()` function |
|
||||||
|
| `src/main.c` | Add WoT trigger in `store_event()` when admin kind 3 is stored, add startup WoT sync |
|
||||||
|
| `src/request_validator.c` | Update whitelist SQL to also match `wot_whitelist` rule type |
|
||||||
|
| `src/sql_schema.h` | Update `auth_rules` CHECK constraint to include `wot_whitelist` |
|
||||||
|
| `src/websockets.c` | Add WoT pubkey check after NIP-42 auth check in REQ handler |
|
||||||
|
| `src/dm_admin.c` | Add `wot 0`, `wot 1`, `wot 2`, `wot sync`, `wot status` DM commands |
|
||||||
|
|
||||||
|
## Detailed Changes
|
||||||
|
|
||||||
|
### 1. Schema: `src/sql_schema.h`
|
||||||
|
|
||||||
|
Update the `auth_rules` table CHECK constraint to allow `wot_whitelist`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Before:
|
||||||
|
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required'))
|
||||||
|
|
||||||
|
-- After:
|
||||||
|
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist'))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Config: `src/default_config_event.h`
|
||||||
|
|
||||||
|
Add WoT configuration:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Web of Trust Settings
|
||||||
|
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
|
||||||
|
{"wot_enabled", "0"},
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Core WoT Sync Function: `src/config.c`
|
||||||
|
|
||||||
|
New function `wot_sync_from_admin_kind3()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
int wot_sync_from_admin_kind3(void) {
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level <= 0) return 0; // WoT disabled
|
||||||
|
|
||||||
|
// 1. Get admin pubkey from config
|
||||||
|
// 2. Query event_tags for p tags from admin's latest kind 3 event:
|
||||||
|
// SELECT DISTINCT et.tag_value FROM event_tags et
|
||||||
|
// JOIN events e ON et.event_id = e.id
|
||||||
|
// WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p'
|
||||||
|
// ORDER BY e.created_at DESC
|
||||||
|
// 3. BEGIN TRANSACTION
|
||||||
|
// 4. DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'
|
||||||
|
// 5. INSERT wot_whitelist for admin pubkey
|
||||||
|
// 6. INSERT wot_whitelist for relay pubkey
|
||||||
|
// 7. For each p tag: INSERT INTO auth_rules (rule_type, pattern_type, pattern_value)
|
||||||
|
// VALUES ('wot_whitelist', 'pubkey', ?)
|
||||||
|
// 8. COMMIT
|
||||||
|
// 9. Set auth_enabled = true
|
||||||
|
// 10. If wot_level == 2: set nip42_auth_required_subscriptions = true
|
||||||
|
// 11. Log count of whitelisted pubkeys
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Trigger on Kind 3 Store: `src/main.c`
|
||||||
|
|
||||||
|
In `store_event()`, after successful INSERT and after `store_event_tags()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Check if this is a kind 3 event from the admin — trigger WoT sync
|
||||||
|
if ((int)cJSON_GetNumberValue(kind) == 3) {
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level > 0) {
|
||||||
|
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||||
|
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey), admin_pubkey) == 0) {
|
||||||
|
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
}
|
||||||
|
if (admin_pubkey) free((char*)admin_pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Startup WoT Sync: `src/main.c`
|
||||||
|
|
||||||
|
In `main()`, after `populate_event_tags_from_existing()`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Sync Web of Trust whitelist if enabled
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level > 0) {
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Update Whitelist Check: `src/request_validator.c`
|
||||||
|
|
||||||
|
Update the whitelist SQL queries to also match `wot_whitelist`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Before:
|
||||||
|
"SELECT rule_type FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
|
||||||
|
|
||||||
|
// After:
|
||||||
|
"SELECT rule_type FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
And the whitelist-exists check:
|
||||||
|
```c
|
||||||
|
// Before:
|
||||||
|
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
|
||||||
|
|
||||||
|
// After:
|
||||||
|
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND active = 1 LIMIT 1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. REQ Handler WoT Check: `src/websockets.c`
|
||||||
|
|
||||||
|
When `wot_enabled == 2`, add a pubkey whitelist check **after** the existing NIP-42 auth check in the REQ handler. The existing code at line 903 already requires NIP-42 auth when `nip42_auth_required_subscriptions` is set. We add a WoT check right after:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// Existing NIP-42 auth check (line 903):
|
||||||
|
if (pss && pss->nip42_auth_required_subscriptions && !pss->authenticated) {
|
||||||
|
// ... send AUTH challenge or NOTICE ...
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEW: WoT read restriction check (wot_enabled == 2)
|
||||||
|
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
|
||||||
|
// Client is authenticated — check if their pubkey is in the WoT whitelist
|
||||||
|
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
|
||||||
|
if (wot_result != NOSTR_SUCCESS) {
|
||||||
|
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
|
||||||
|
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
|
||||||
|
cJSON_Delete(json);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Admin DM Commands: `src/dm_admin.c`
|
||||||
|
|
||||||
|
Add plain text DM commands:
|
||||||
|
|
||||||
|
| Command | Action |
|
||||||
|
|---------|--------|
|
||||||
|
| `wot 0` or `wot off` | Disable WoT, delete all `wot_whitelist` rules, reset `nip42_auth_required_subscriptions` |
|
||||||
|
| `wot 1` or `wot write` | Write-only WoT — sync follow list, set `auth_enabled=true` |
|
||||||
|
| `wot 2` or `wot full` | Full WoT — sync follow list, set `auth_enabled=true`, set `nip42_auth_required_subscriptions=true` |
|
||||||
|
| `wot sync` | Force resync from admin's kind 3 event |
|
||||||
|
| `wot status` | Show WoT level (0/1/2), count of whitelisted pubkeys |
|
||||||
|
|
||||||
|
### 9. NIP-11 Update
|
||||||
|
|
||||||
|
When WoT is enabled (level 1 or 2), the relay should indicate this in NIP-11 relay info. Add to the `limitation` object:
|
||||||
|
```json
|
||||||
|
"auth_required": true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Admin has no kind 3 event in database**: WoT sync does nothing, logs a warning. No whitelist rules created = open relay.
|
||||||
|
|
||||||
|
2. **Admin updates follow list**: New kind 3 event triggers full resync — old WoT rules are cleared, new ones inserted. This is atomic (transaction).
|
||||||
|
|
||||||
|
3. **WoT disabled (set to 0)**: Clears all `wot_whitelist` rules. Manual `whitelist` rules are preserved. `nip42_auth_required_subscriptions` is reset to false.
|
||||||
|
|
||||||
|
4. **Admin unfollows someone**: Next kind 3 event triggers resync, the unfollowed pubkey's `wot_whitelist` rule is removed (full clear + reinsert).
|
||||||
|
|
||||||
|
5. **Kind 1059 gift wraps**: These need special handling — the relay needs to accept gift wraps addressed to it even from non-whitelisted pubkeys (for admin DMs). The `is_nip17_gift_wrap_for_relay()` check should bypass WoT.
|
||||||
|
|
||||||
|
6. **Read restriction without NIP-42 support (level 2)**: If a client doesn't support NIP-42, they cannot authenticate and therefore cannot subscribe. This is by design — it's the most effective way to reduce subscription churn from anonymous connections.
|
||||||
|
|
||||||
|
7. **NIP-11 discovery**: Clients can check NIP-11 to see if `auth_required` is true before connecting, avoiding wasted connections.
|
||||||
|
|
||||||
|
8. **Changing level from 2 to 1**: `nip42_auth_required_subscriptions` is reset to false, allowing anonymous subscriptions again. WoT whitelist rules remain for write restriction.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
1. Enable write-only WoT via DM: `wot 1`
|
||||||
|
2. Verify admin can still publish events
|
||||||
|
3. Verify followed pubkeys can publish events
|
||||||
|
4. Verify non-followed pubkeys are rejected for EVENT
|
||||||
|
5. Verify non-followed pubkeys can still subscribe (REQ)
|
||||||
|
6. Enable full WoT via DM: `wot 2`
|
||||||
|
7. Verify unauthenticated clients get AUTH challenge on REQ
|
||||||
|
8. Verify authenticated non-followed pubkeys are rejected for REQ
|
||||||
|
9. Verify authenticated followed pubkeys can subscribe
|
||||||
|
10. Verify `wot status` shows correct level and count
|
||||||
|
11. Publish new kind 3 event, verify auto-resync
|
||||||
|
12. `wot 0` — verify all pubkeys can publish and subscribe again
|
||||||
|
13. Verify admin DMs still work when WoT is enabled
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- WoT sync is O(n) where n = number of follows (typically 100-2000)
|
||||||
|
- Uses a transaction for bulk insert — fast even for large follow lists
|
||||||
|
- Auth rule check is already indexed: `idx_auth_rules_pattern ON auth_rules(pattern_type, pattern_value)`
|
||||||
|
- No additional per-event overhead — the existing `check_database_auth_rules()` already runs on every event when auth is enabled
|
||||||
|
- **Level 2 directly addresses CPU load**: With `wot_enabled=2`, unauthenticated connections cannot create subscriptions, eliminating the ~120 REQ/minute churn from anonymous connections that was causing 99% CPU
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# Web of Trust — Admin Web UI Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Add a **Web of Trust** section to the existing Authorization page in the admin web interface. The section lets the admin see whether their kind 3 contact list is on the relay, select a WoT level, trigger a sync, and view the current whitelist count.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[Web UI] -->|config_set wot_enabled N| B[Kind 23456 Event]
|
||||||
|
A -->|system_command wot_sync| B
|
||||||
|
A -->|system_command wot_status| B
|
||||||
|
B --> C[Relay Backend]
|
||||||
|
C -->|Kind 23457 Response| D[Web UI updates display]
|
||||||
|
```
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **On page load** — the Authorization page already calls `loadAuthRules`. We add a parallel call to `loadWotStatus` which sends a `system_command` / `wot_status` event.
|
||||||
|
2. **Backend responds** with a kind 23457 JSON containing: `wot_enabled` level, `wot_whitelist_count`, and `admin_kind3_exists` boolean.
|
||||||
|
3. **UI renders** a card showing:
|
||||||
|
- Whether the admin's kind 3 event exists on the relay
|
||||||
|
- Current WoT level as a 3-option radio/button group
|
||||||
|
- Count of whitelisted pubkeys
|
||||||
|
- A SYNC button to force resync
|
||||||
|
4. **Changing level** sends `config_set` / `wot_enabled` / `0|1|2` via the existing admin API, then triggers `wot_sync` if level > 0.
|
||||||
|
5. **SYNC button** sends `system_command` / `wot_sync`.
|
||||||
|
|
||||||
|
## Detailed Changes
|
||||||
|
|
||||||
|
### 1. Backend: New system commands in `src/config.c`
|
||||||
|
|
||||||
|
Add two new branches to `handle_system_command_unified`:
|
||||||
|
|
||||||
|
#### `wot_status` command
|
||||||
|
|
||||||
|
Returns JSON response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "wot_status",
|
||||||
|
"status": "success",
|
||||||
|
"wot_enabled": 2,
|
||||||
|
"admin_kind3_exists": true,
|
||||||
|
"wot_whitelist_count": 347,
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Read `wot_enabled` from config table
|
||||||
|
- Query `SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?` with admin_pubkey to check kind 3 existence
|
||||||
|
- Query `SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1` for whitelist count
|
||||||
|
- Return as kind 23457 signed response
|
||||||
|
|
||||||
|
#### `wot_sync` command
|
||||||
|
|
||||||
|
Calls `wot_sync_from_admin_kind3` and returns result:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"command": "wot_sync",
|
||||||
|
"status": "success",
|
||||||
|
"wot_whitelist_count": 347,
|
||||||
|
"timestamp": 1234567890
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend: Hook `config_set` for `wot_enabled`
|
||||||
|
|
||||||
|
In `handle_config_set_unified`, after the config value is updated, add a check:
|
||||||
|
```c
|
||||||
|
// After successful update, trigger WoT sync if wot_enabled changed
|
||||||
|
if (strcmp(config_key, "wot_enabled") == 0) {
|
||||||
|
int new_level = atoi(config_value);
|
||||||
|
if (new_level > 0) {
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
} else {
|
||||||
|
// Level 0: clear wot_whitelist rules and reset auth flags
|
||||||
|
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
|
||||||
|
update_config_in_table("nip42_auth_required_subscriptions", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend: HTML — WoT section in `api/index.html`
|
||||||
|
|
||||||
|
Add a new div **inside** the `authRulesSection`, before the auth rules table. This keeps WoT visually grouped with authorization:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Web of Trust Section -->
|
||||||
|
<div id="wotSection" class="input-group">
|
||||||
|
<div class="section-header" style="font-size: 14px; margin-bottom: 10px;">
|
||||||
|
WEB OF TRUST
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Kind 3 Status Indicator -->
|
||||||
|
<div id="wotKind3Status" class="wot-status-row">
|
||||||
|
<span>Admin Contact List (kind 3):</span>
|
||||||
|
<span id="wotKind3Indicator" class="wot-indicator wot-unknown">Checking...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WoT Level Selector -->
|
||||||
|
<div class="wot-level-selector">
|
||||||
|
<label>WoT Level:</label>
|
||||||
|
<div class="inline-buttons">
|
||||||
|
<button type="button" id="wotLevel0Btn" class="wot-level-btn" onclick="setWotLevel(0)">
|
||||||
|
OFF
|
||||||
|
</button>
|
||||||
|
<button type="button" id="wotLevel1Btn" class="wot-level-btn" onclick="setWotLevel(1)">
|
||||||
|
WRITE ONLY
|
||||||
|
</button>
|
||||||
|
<button type="button" id="wotLevel2Btn" class="wot-level-btn" onclick="setWotLevel(2)">
|
||||||
|
FULL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="wot-level-description" id="wotLevelDescription">
|
||||||
|
Level 0: Open relay — anyone can read and write
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WoT Stats -->
|
||||||
|
<div class="wot-stats-row">
|
||||||
|
<span>Whitelisted Pubkeys:</span>
|
||||||
|
<span id="wotWhitelistCount">—</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Button -->
|
||||||
|
<div class="inline-buttons">
|
||||||
|
<button type="button" id="wotSyncBtn" onclick="syncWot()">SYNC FROM KIND 3</button>
|
||||||
|
<button type="button" id="wotRefreshBtn" onclick="loadWotStatus()">REFRESH STATUS</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 15px 0; border-color: var(--border-color);">
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Frontend: CSS additions in `api/index.css`
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Web of Trust styles */
|
||||||
|
.wot-status-row, .wot-stats-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-indicator {
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-indicator.wot-found { background: #28a745; color: white; }
|
||||||
|
.wot-indicator.wot-missing { background: #dc3545; color: white; }
|
||||||
|
.wot-indicator.wot-unknown { background: #6c757d; color: white; }
|
||||||
|
|
||||||
|
.wot-level-selector { padding: 10px 0; }
|
||||||
|
.wot-level-selector label { display: block; margin-bottom: 5px; font-weight: bold; }
|
||||||
|
|
||||||
|
.wot-level-btn { min-width: 100px; }
|
||||||
|
.wot-level-btn.active {
|
||||||
|
background: var(--accent-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wot-level-description {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Frontend: JavaScript functions in `api/index.js`
|
||||||
|
|
||||||
|
#### `loadWotStatus` — query current WoT state
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function loadWotStatus() {
|
||||||
|
try {
|
||||||
|
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||||
|
|
||||||
|
const command_array = ["system_command", "wot_status"];
|
||||||
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||||
|
if (!encrypted_content) return;
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: 23456,
|
||||||
|
pubkey: userPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", getRelayPubkey()]],
|
||||||
|
content: encrypted_content
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log('WoT status query sent', 'INFO');
|
||||||
|
} catch (error) {
|
||||||
|
log('Failed to load WoT status: ' + error.message, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `setWotLevel` — change WoT level via config_set
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function setWotLevel(level) {
|
||||||
|
try {
|
||||||
|
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||||
|
|
||||||
|
// Send config_set for wot_enabled
|
||||||
|
const command_array = ["config_set", "wot_enabled", String(level)];
|
||||||
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||||
|
if (!encrypted_content) return;
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: 23456,
|
||||||
|
pubkey: userPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", getRelayPubkey()]],
|
||||||
|
content: encrypted_content
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log('WoT level set to ' + level, 'INFO');
|
||||||
|
|
||||||
|
// Refresh status after a short delay
|
||||||
|
setTimeout(() => loadWotStatus(), 1500);
|
||||||
|
} catch (error) {
|
||||||
|
log('Failed to set WoT level: ' + error.message, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `syncWot` — force WoT sync
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async function syncWot() {
|
||||||
|
try {
|
||||||
|
if (!isLoggedIn || !userPubkey || !relayPool) return;
|
||||||
|
|
||||||
|
const command_array = ["system_command", "wot_sync"];
|
||||||
|
const encrypted_content = await encryptForRelay(JSON.stringify(command_array));
|
||||||
|
if (!encrypted_content) return;
|
||||||
|
|
||||||
|
const event = {
|
||||||
|
kind: 23456,
|
||||||
|
pubkey: userPubkey,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [["p", getRelayPubkey()]],
|
||||||
|
content: encrypted_content
|
||||||
|
};
|
||||||
|
|
||||||
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
|
const url = relayConnectionUrl.value.trim();
|
||||||
|
await relayPool.publish([url], signedEvent);
|
||||||
|
|
||||||
|
log('WoT sync command sent', 'INFO');
|
||||||
|
|
||||||
|
// Refresh status after sync completes
|
||||||
|
setTimeout(() => loadWotStatus(), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
log('Failed to sync WoT: ' + error.message, 'ERROR');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `handleWotStatusResponse` — process backend response
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function handleWotStatusResponse(responseData) {
|
||||||
|
const kind3Indicator = document.getElementById('wotKind3Indicator');
|
||||||
|
const whitelistCount = document.getElementById('wotWhitelistCount');
|
||||||
|
const levelDesc = document.getElementById('wotLevelDescription');
|
||||||
|
|
||||||
|
// Update kind 3 indicator
|
||||||
|
if (kind3Indicator) {
|
||||||
|
if (responseData.admin_kind3_exists) {
|
||||||
|
kind3Indicator.textContent = 'Found ✓';
|
||||||
|
kind3Indicator.className = 'wot-indicator wot-found';
|
||||||
|
} else {
|
||||||
|
kind3Indicator.textContent = 'Not Found ✗';
|
||||||
|
kind3Indicator.className = 'wot-indicator wot-missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update whitelist count
|
||||||
|
if (whitelistCount) {
|
||||||
|
whitelistCount.textContent = responseData.wot_whitelist_count || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update level buttons
|
||||||
|
const level = responseData.wot_enabled || 0;
|
||||||
|
['wotLevel0Btn', 'wotLevel1Btn', 'wotLevel2Btn'].forEach((id, i) => {
|
||||||
|
const btn = document.getElementById(id);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.toggle('active', i === level);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update description
|
||||||
|
const descriptions = [
|
||||||
|
'Level 0: Open relay — anyone can read and write',
|
||||||
|
'Level 1: Write-only — only followed pubkeys can publish events',
|
||||||
|
'Level 2: Full — only followed pubkeys can publish AND subscribe (NIP-42 required)'
|
||||||
|
];
|
||||||
|
if (levelDesc) {
|
||||||
|
levelDesc.textContent = descriptions[level] || descriptions[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable sync button based on kind 3 existence
|
||||||
|
const syncBtn = document.getElementById('wotSyncBtn');
|
||||||
|
if (syncBtn) {
|
||||||
|
syncBtn.disabled = !responseData.admin_kind3_exists;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wire into existing response handler
|
||||||
|
|
||||||
|
In the existing `handleSystemCommandResponse` function, add:
|
||||||
|
```javascript
|
||||||
|
if (responseData.command === 'wot_status' || responseData.command === 'wot_sync') {
|
||||||
|
handleWotStatusResponse(responseData);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Wire into page navigation
|
||||||
|
|
||||||
|
In the `authorization` case of the page switch handler, add:
|
||||||
|
```javascript
|
||||||
|
case 'authorization':
|
||||||
|
loadAuthRules().catch(...);
|
||||||
|
loadWotStatus().catch(error => {
|
||||||
|
console.log('Auto-load WoT status failed: ' + error.message);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files to Modify
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `src/config.c` | Add `wot_status` and `wot_sync` branches to `handle_system_command_unified`; add WoT sync hook to `handle_config_set_unified` |
|
||||||
|
| `api/index.html` | Add WoT section HTML inside `authRulesSection` |
|
||||||
|
| `api/index.css` | Add WoT-specific CSS styles |
|
||||||
|
| `api/index.js` | Add `loadWotStatus`, `setWotLevel`, `syncWot`, `handleWotStatusResponse` functions; wire into page nav and response handler |
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
1. **Admin not logged in** — WoT section shows but buttons are disabled; status shows "Checking..."
|
||||||
|
2. **No kind 3 event** — Indicator shows red "Not Found ✗"; SYNC button is disabled; level selector still works but sync will whitelist only admin + relay
|
||||||
|
3. **Level change from 2 to 0** — Backend clears all wot_whitelist rules and resets nip42_auth_required_subscriptions
|
||||||
|
4. **Concurrent config_set and wot_sync** — The backend handles these sequentially via SQLite transactions; no race condition
|
||||||
|
|
||||||
|
## UI Mockup
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ WEB OF TRUST │
|
||||||
|
│ │
|
||||||
|
│ Admin Contact List (kind 3): [Found ✓] │
|
||||||
|
│ │
|
||||||
|
│ WoT Level: │
|
||||||
|
│ [ OFF ] [ WRITE ONLY ] [ FULL ] │
|
||||||
|
│ Level 2: Full — only followed pubkeys can │
|
||||||
|
│ publish AND subscribe (NIP-42 required) │
|
||||||
|
│ │
|
||||||
|
│ Whitelisted Pubkeys: 347 │
|
||||||
|
│ │
|
||||||
|
│ [ SYNC FROM KIND 3 ] [ REFRESH STATUS ] │
|
||||||
|
├─────────────────────────────────────────────┤
|
||||||
|
│ ─────────────────────────────────────────── │
|
||||||
|
│ AUTH RULES MANAGEMENT │
|
||||||
|
│ ... existing auth rules table ... │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
+424
-4
@@ -956,7 +956,8 @@ static int validate_config_field(const char* key, const char* value, char* error
|
|||||||
if (strcmp(key, "auth_enabled") == 0 ||
|
if (strcmp(key, "auth_enabled") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_strict") == 0 ||
|
strcmp(key, "nip40_expiration_strict") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_filter") == 0) {
|
strcmp(key, "nip40_expiration_filter") == 0 ||
|
||||||
|
strcmp(key, "nip17_admin_enabled") == 0) {
|
||||||
if (!is_valid_boolean(value)) {
|
if (!is_valid_boolean(value)) {
|
||||||
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
|
snprintf(error_msg, error_size, "invalid boolean value '%s' for %s", value, key);
|
||||||
return -1;
|
return -1;
|
||||||
@@ -1724,7 +1725,8 @@ int populate_default_config_values(void) {
|
|||||||
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_strict") == 0 ||
|
strcmp(key, "nip40_expiration_strict") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_filter") == 0 ||
|
strcmp(key, "nip40_expiration_filter") == 0 ||
|
||||||
strcmp(key, "nip42_auth_required") == 0) {
|
strcmp(key, "nip42_auth_required") == 0 ||
|
||||||
|
strcmp(key, "nip17_admin_enabled") == 0) {
|
||||||
data_type = "boolean";
|
data_type = "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2129,6 +2131,296 @@ int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type
|
|||||||
return (rc == SQLITE_DONE) ? 0 : -1;
|
return (rc == SQLITE_DONE) ? 0 : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================
|
||||||
|
// WEB OF TRUST (WoT) SYNC FUNCTION
|
||||||
|
// ================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Web of Trust whitelist from admin's kind 3 (contact list) event
|
||||||
|
* Extracts p tags from the admin's most recent kind 3 event and creates
|
||||||
|
* wot_whitelist auth rules for each followed pubkey.
|
||||||
|
*
|
||||||
|
* @return 0 on success, -1 on error
|
||||||
|
*/
|
||||||
|
int wot_sync_from_admin_kind3(void) {
|
||||||
|
if (!g_db) {
|
||||||
|
DEBUG_ERROR("WoT sync: database not available");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if WoT is enabled
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level <= 0) {
|
||||||
|
DEBUG_INFO("WoT sync: wot_enabled is %d, skipping sync", wot_level);
|
||||||
|
return 0; // WoT disabled, nothing to do
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin pubkey from config
|
||||||
|
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||||
|
if (!admin_pubkey) {
|
||||||
|
DEBUG_ERROR("WoT sync: admin_pubkey not configured");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relay pubkey from config
|
||||||
|
const char* relay_pubkey = get_config_value("relay_pubkey");
|
||||||
|
if (!relay_pubkey) {
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
DEBUG_ERROR("WoT sync: relay_pubkey not configured");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
DEBUG_INFO("WoT sync: Starting sync from admin's kind 3 event (level %d)", wot_level);
|
||||||
|
|
||||||
|
sqlite3_stmt* stmt = NULL;
|
||||||
|
int rc;
|
||||||
|
int whitelisted_count = 0;
|
||||||
|
|
||||||
|
// Step 1: Query p tags from admin's most recent kind 3 event
|
||||||
|
const char* query_sql =
|
||||||
|
"SELECT DISTINCT et.tag_value FROM event_tags et "
|
||||||
|
"JOIN events e ON et.event_id = e.id "
|
||||||
|
"WHERE e.kind = 3 AND e.pubkey = ? AND et.tag_name = 'p' "
|
||||||
|
"AND e.created_at = (SELECT MAX(created_at) FROM events WHERE kind = 3 AND pubkey = ?)";
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(g_db, query_sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to prepare p-tag query: %s", sqlite3_errmsg(g_db));
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 2, admin_pubkey, -1, SQLITE_STATIC);
|
||||||
|
|
||||||
|
// Collect all p tags
|
||||||
|
char** p_tags = NULL;
|
||||||
|
int p_tag_count = 0;
|
||||||
|
int p_tag_capacity = 16;
|
||||||
|
|
||||||
|
p_tags = malloc(p_tag_capacity * sizeof(char*));
|
||||||
|
if (!p_tags) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to allocate p_tag array");
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||||||
|
const char* p_tag = (const char*)sqlite3_column_text(stmt, 0);
|
||||||
|
if (p_tag) {
|
||||||
|
// Expand array if needed
|
||||||
|
if (p_tag_count >= p_tag_capacity) {
|
||||||
|
p_tag_capacity *= 2;
|
||||||
|
char** new_tags = realloc(p_tags, p_tag_capacity * sizeof(char*));
|
||||||
|
if (!new_tags) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to expand p_tag array");
|
||||||
|
// Free existing tags
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
p_tags = new_tags;
|
||||||
|
}
|
||||||
|
p_tags[p_tag_count] = strdup(p_tag);
|
||||||
|
p_tag_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
// If no kind 3 event found, just log and continue (admin and relay will still be whitelisted)
|
||||||
|
if (p_tag_count == 0) {
|
||||||
|
DEBUG_WARN("WoT sync: No p tags found in admin's kind 3 event");
|
||||||
|
} else {
|
||||||
|
DEBUG_INFO("WoT sync: Found %d followed pubkeys in kind 3 event", p_tag_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Begin transaction
|
||||||
|
rc = sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to begin transaction: %s", sqlite3_errmsg(g_db));
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Clear existing wot_whitelist rules
|
||||||
|
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
|
||||||
|
rc = sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to clear existing wot_whitelist rules: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
DEBUG_TRACE("WoT sync: Cleared existing wot_whitelist rules");
|
||||||
|
|
||||||
|
// Step 4: Insert admin pubkey as wot_whitelist (always allowed)
|
||||||
|
const char* insert_sql =
|
||||||
|
"INSERT INTO auth_rules (rule_type, pattern_type, pattern_value, active) "
|
||||||
|
"VALUES ('wot_whitelist', 'pubkey', ?, 1)";
|
||||||
|
|
||||||
|
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to prepare insert statement: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_bind_text(stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
|
||||||
|
rc = sqlite3_step(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to insert admin pubkey whitelist: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
whitelisted_count++;
|
||||||
|
DEBUG_TRACE("WoT sync: Whitelisted admin pubkey: %.16s...", admin_pubkey);
|
||||||
|
|
||||||
|
// Step 5: Insert relay pubkey as wot_whitelist (needed for DM responses)
|
||||||
|
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to prepare insert statement for relay: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_bind_text(stmt, 1, relay_pubkey, -1, SQLITE_STATIC);
|
||||||
|
rc = sqlite3_step(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to insert relay pubkey whitelist: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
whitelisted_count++;
|
||||||
|
DEBUG_TRACE("WoT sync: Whitelisted relay pubkey: %.16s...", relay_pubkey);
|
||||||
|
|
||||||
|
// Step 6: Insert each p tag from kind 3 event
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
rc = sqlite3_prepare_v2(g_db, insert_sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to prepare insert for p_tag %d: %s", i, sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
// Free remaining tags
|
||||||
|
for (int j = i; j < p_tag_count; j++) {
|
||||||
|
free(p_tags[j]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_bind_text(stmt, 1, p_tags[i], -1, SQLITE_STATIC);
|
||||||
|
rc = sqlite3_step(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to insert p_tag %d: %s", i, sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
// Free remaining tags
|
||||||
|
for (int j = i; j < p_tag_count; j++) {
|
||||||
|
free(p_tags[j]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
whitelisted_count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Commit transaction
|
||||||
|
rc = sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("WoT sync: Failed to commit transaction: %s", sqlite3_errmsg(g_db));
|
||||||
|
sqlite3_exec(g_db, "ROLLBACK", NULL, NULL, NULL);
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 8: Enable auth_enabled and optionally nip42_auth_required_subscriptions
|
||||||
|
if (update_config_in_table("auth_enabled", "true") != 0) {
|
||||||
|
DEBUG_WARN("WoT sync: Failed to set auth_enabled");
|
||||||
|
} else {
|
||||||
|
DEBUG_INFO("WoT sync: Enabled auth_enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wot_level == 2) {
|
||||||
|
if (update_config_in_table("nip42_auth_required_subscriptions", "true") != 0) {
|
||||||
|
DEBUG_WARN("WoT sync: Failed to set nip42_auth_required_subscriptions");
|
||||||
|
} else {
|
||||||
|
DEBUG_INFO("WoT sync: Enabled nip42_auth_required_subscriptions for level 2");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Level 1: ensure nip42_auth_required_subscriptions is false
|
||||||
|
if (update_config_in_table("nip42_auth_required_subscriptions", "false") != 0) {
|
||||||
|
DEBUG_WARN("WoT sync: Failed to reset nip42_auth_required_subscriptions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
for (int i = 0; i < p_tag_count; i++) {
|
||||||
|
free(p_tags[i]);
|
||||||
|
}
|
||||||
|
free(p_tags);
|
||||||
|
free((char*)admin_pubkey);
|
||||||
|
free((char*)relay_pubkey);
|
||||||
|
|
||||||
|
DEBUG_INFO("WoT sync: Complete - whitelisted %d pubkeys (admin + relay + %d follows)",
|
||||||
|
whitelisted_count, p_tag_count);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// ================================
|
// ================================
|
||||||
// UNIFIED TAG PARSING UTILITIES
|
// UNIFIED TAG PARSING UTILITIES
|
||||||
// ================================
|
// ================================
|
||||||
@@ -3025,6 +3317,20 @@ int handle_config_set_unified(cJSON* event, const char* config_key, const char*
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Special handling for wot_enabled changes - trigger sync or cleanup
|
||||||
|
if (strcmp(config_key, "wot_enabled") == 0) {
|
||||||
|
int new_level = atoi(config_value);
|
||||||
|
if (new_level > 0) {
|
||||||
|
DEBUG_INFO("Config set: wot_enabled changed to %d, triggering WoT sync", new_level);
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
} else {
|
||||||
|
// Level 0: clear wot_whitelist rules and reset auth flags
|
||||||
|
DEBUG_INFO("Config set: wot_enabled changed to 0, clearing wot_whitelist rules");
|
||||||
|
sqlite3_exec(g_db, "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'", NULL, NULL, NULL);
|
||||||
|
update_config_in_table("auth_enabled", "false");
|
||||||
|
update_config_in_table("nip42_auth_required_subscriptions", "false");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build response
|
// Build response
|
||||||
cJSON* response = cJSON_CreateObject();
|
cJSON* response = cJSON_CreateObject();
|
||||||
@@ -3292,6 +3598,118 @@ int handle_system_command_unified(cJSON* event, const char* command, char* error
|
|||||||
snprintf(error_message, error_size, "failed to send restart acknowledgment");
|
snprintf(error_message, error_size, "failed to send restart acknowledgment");
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
else if (strcmp(command, "wot_status") == 0) {
|
||||||
|
// Get admin pubkey from event
|
||||||
|
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||||
|
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
|
||||||
|
|
||||||
|
if (!admin_pubkey) {
|
||||||
|
snprintf(error_message, error_size, "missing admin pubkey for WoT status");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current WoT level
|
||||||
|
int wot_enabled = get_config_int("wot_enabled", 0);
|
||||||
|
|
||||||
|
// Check if admin's kind 3 event exists
|
||||||
|
int admin_kind3_exists = 0;
|
||||||
|
const char* kind3_sql = "SELECT COUNT(*) FROM events WHERE kind = 3 AND pubkey = ?";
|
||||||
|
sqlite3_stmt* kind3_stmt;
|
||||||
|
|
||||||
|
int rc = sqlite3_prepare_v2(g_db, kind3_sql, -1, &kind3_stmt, NULL);
|
||||||
|
if (rc == SQLITE_OK) {
|
||||||
|
sqlite3_bind_text(kind3_stmt, 1, admin_pubkey, -1, SQLITE_STATIC);
|
||||||
|
if (sqlite3_step(kind3_stmt) == SQLITE_ROW) {
|
||||||
|
admin_kind3_exists = sqlite3_column_int(kind3_stmt, 0) > 0;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(kind3_stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count WoT whitelist entries
|
||||||
|
int wot_whitelist_count = 0;
|
||||||
|
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
|
||||||
|
sqlite3_stmt* count_stmt;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
|
||||||
|
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(count_stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
cJSON* response = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(response, "command", "wot_status");
|
||||||
|
cJSON_AddStringToObject(response, "status", "success");
|
||||||
|
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
|
||||||
|
cJSON_AddBoolToObject(response, "admin_kind3_exists", admin_kind3_exists);
|
||||||
|
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
|
||||||
|
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||||
|
|
||||||
|
// Send response as signed kind 23457 event
|
||||||
|
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
|
||||||
|
cJSON_Delete(response);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(response);
|
||||||
|
snprintf(error_message, error_size, "failed to send WoT status response");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
else if (strcmp(command, "wot_sync") == 0) {
|
||||||
|
// Get admin pubkey from event
|
||||||
|
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||||
|
const char* admin_pubkey = pubkey_obj ? cJSON_GetStringValue(pubkey_obj) : NULL;
|
||||||
|
|
||||||
|
if (!admin_pubkey) {
|
||||||
|
snprintf(error_message, error_size, "missing admin pubkey for WoT sync");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if WoT is enabled
|
||||||
|
int wot_enabled = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_enabled <= 0) {
|
||||||
|
snprintf(error_message, error_size, "WoT is not enabled (wot_enabled=0)");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger WoT sync
|
||||||
|
int sync_result = wot_sync_from_admin_kind3();
|
||||||
|
|
||||||
|
// Get updated whitelist count
|
||||||
|
int wot_whitelist_count = 0;
|
||||||
|
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
|
||||||
|
sqlite3_stmt* count_stmt;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(g_db, count_sql, -1, &count_stmt, NULL) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(count_stmt) == SQLITE_ROW) {
|
||||||
|
wot_whitelist_count = sqlite3_column_int(count_stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(count_stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
cJSON* response = cJSON_CreateObject();
|
||||||
|
cJSON_AddStringToObject(response, "command", "wot_sync");
|
||||||
|
cJSON_AddStringToObject(response, "status", sync_result == 0 ? "success" : "error");
|
||||||
|
cJSON_AddNumberToObject(response, "wot_enabled", wot_enabled);
|
||||||
|
cJSON_AddNumberToObject(response, "wot_whitelist_count", wot_whitelist_count);
|
||||||
|
cJSON_AddNumberToObject(response, "timestamp", (double)time(NULL));
|
||||||
|
|
||||||
|
if (sync_result != 0) {
|
||||||
|
cJSON_AddStringToObject(response, "message", "WoT sync failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response as signed kind 23457 event
|
||||||
|
if (send_admin_response_event(response, admin_pubkey, wsi) == 0) {
|
||||||
|
cJSON_Delete(response);
|
||||||
|
return sync_result;
|
||||||
|
}
|
||||||
|
|
||||||
|
cJSON_Delete(response);
|
||||||
|
snprintf(error_message, error_size, "failed to send WoT sync response");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
snprintf(error_message, error_size, "invalid: unknown system command '%s'", command);
|
snprintf(error_message, error_size, "invalid: unknown system command '%s'", command);
|
||||||
return -1;
|
return -1;
|
||||||
@@ -4089,7 +4507,8 @@ int populate_all_config_values_atomic(const char* admin_pubkey, const char* rela
|
|||||||
strcmp(key, "nip40_expiration_filter") == 0 ||
|
strcmp(key, "nip40_expiration_filter") == 0 ||
|
||||||
strcmp(key, "nip42_auth_required_events") == 0 ||
|
strcmp(key, "nip42_auth_required_events") == 0 ||
|
||||||
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
|
strcmp(key, "nip42_auth_required_subscriptions") == 0 ||
|
||||||
strcmp(key, "nip70_protected_events_enabled") == 0) {
|
strcmp(key, "nip70_protected_events_enabled") == 0 ||
|
||||||
|
strcmp(key, "nip17_admin_enabled") == 0) {
|
||||||
data_type = "boolean";
|
data_type = "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4374,7 +4793,8 @@ int populate_config_table_from_event(const cJSON* event) {
|
|||||||
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
strcmp(key, "nip40_expiration_enabled") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_strict") == 0 ||
|
strcmp(key, "nip40_expiration_strict") == 0 ||
|
||||||
strcmp(key, "nip40_expiration_filter") == 0 ||
|
strcmp(key, "nip40_expiration_filter") == 0 ||
|
||||||
strcmp(key, "nip42_auth_required") == 0) {
|
strcmp(key, "nip42_auth_required") == 0 ||
|
||||||
|
strcmp(key, "nip17_admin_enabled") == 0) {
|
||||||
data_type = "boolean";
|
data_type = "boolean";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -116,7 +116,10 @@ cJSON* build_query_response(const char* query_type, cJSON* results_array, int to
|
|||||||
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
int add_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
||||||
const char* pattern_value);
|
const char* pattern_value);
|
||||||
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
int remove_auth_rule_from_config(const char* rule_type, const char* pattern_type,
|
||||||
const char* pattern_value);
|
const char* pattern_value);
|
||||||
|
|
||||||
|
// Web of Trust (WoT) sync function
|
||||||
|
int wot_sync_from_admin_kind3(void);
|
||||||
|
|
||||||
// Unified configuration cache management
|
// Unified configuration cache management
|
||||||
void force_config_cache_refresh(void);
|
void force_config_cache_refresh(void);
|
||||||
|
|||||||
@@ -87,7 +87,16 @@ static const struct {
|
|||||||
{"nip59_timestamp_max_delay_sec", "0"},
|
{"nip59_timestamp_max_delay_sec", "0"},
|
||||||
|
|
||||||
// Kind 1 Status Posts
|
// Kind 1 Status Posts
|
||||||
{"kind_1_status_posts_hours", "1"}
|
{"kind_1_status_posts_hours", "1"},
|
||||||
|
|
||||||
|
// Web of Trust Settings
|
||||||
|
// 0 = off, 1 = write-only (followed pubkeys can publish), 2 = full (followed pubkeys can publish AND subscribe)
|
||||||
|
{"wot_enabled", "0"},
|
||||||
|
|
||||||
|
// NIP-17 Admin DM Settings
|
||||||
|
// When false (default), Kind 1059 gift wrap events are stored normally without expensive decryption attempts.
|
||||||
|
// Enable only if you intend to use NIP-17 DMs to send admin commands to the relay.
|
||||||
|
{"nip17_admin_enabled", "false"}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Number of default configuration values
|
// Number of default configuration values
|
||||||
|
|||||||
+152
@@ -37,10 +37,13 @@ extern const char* get_tag_value(cJSON* event, const char* tag_name, int value_i
|
|||||||
extern char* get_relay_private_key(void);
|
extern char* get_relay_private_key(void);
|
||||||
extern const char* get_config_value(const char* key);
|
extern const char* get_config_value(const char* key);
|
||||||
extern int get_config_bool(const char* key, int default_value);
|
extern int get_config_bool(const char* key, int default_value);
|
||||||
|
extern int get_config_int(const char* key, int default_value);
|
||||||
|
extern int update_config_in_table(const char* key, const char* value);
|
||||||
|
|
||||||
// Forward declarations for database functions
|
// Forward declarations for database functions
|
||||||
extern int store_event(cJSON* event);
|
extern int store_event(cJSON* event);
|
||||||
extern int broadcast_event_to_subscriptions(cJSON* event);
|
extern int broadcast_event_to_subscriptions(cJSON* event);
|
||||||
|
extern int store_event_tags(const char* event_id, cJSON* tags);
|
||||||
|
|
||||||
// Forward declarations for stats generation (moved to api.c)
|
// Forward declarations for stats generation (moved to api.c)
|
||||||
extern char* generate_stats_json(void);
|
extern char* generate_stats_json(void);
|
||||||
@@ -598,6 +601,155 @@ int process_nip17_admin_command(cJSON* dm_event, char* error_message, size_t err
|
|||||||
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
|
DEBUG_INFO("DM_ADMIN: Status command processed successfully");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
// Check for WoT (Web of Trust) commands
|
||||||
|
else if (strstr(content_lower, "wot") != NULL) {
|
||||||
|
DEBUG_INFO("DM_ADMIN: Processing WoT command");
|
||||||
|
|
||||||
|
// Skip "wot" prefix and find the subcommand
|
||||||
|
char* wot_cmd = strstr(content_lower, "wot");
|
||||||
|
if (wot_cmd) {
|
||||||
|
wot_cmd += 3; // Skip "wot"
|
||||||
|
while (*wot_cmd == ' ') wot_cmd++; // Skip spaces
|
||||||
|
|
||||||
|
char response_msg[1024];
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
extern int wot_sync_from_admin_kind3(void);
|
||||||
|
|
||||||
|
if (strcmp(wot_cmd, "0") == 0 || strcmp(wot_cmd, "off") == 0) {
|
||||||
|
// Disable WoT
|
||||||
|
update_config_in_table("wot_enabled", "0");
|
||||||
|
update_config_in_table("auth_enabled", "false");
|
||||||
|
update_config_in_table("nip42_auth_required_subscriptions", "false");
|
||||||
|
|
||||||
|
// Clear wot_whitelist rules
|
||||||
|
const char* delete_sql = "DELETE FROM auth_rules WHERE rule_type = 'wot_whitelist'";
|
||||||
|
sqlite3_exec(g_db, delete_sql, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"🔓 Web of Trust Disabled\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"WoT has been turned off.\n"
|
||||||
|
"\n"
|
||||||
|
"The relay is now open to all pubkeys for reading and writing.\n"
|
||||||
|
"Manual whitelist/blacklist rules are preserved.");
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
DEBUG_INFO("DM_ADMIN: WoT disabled");
|
||||||
|
}
|
||||||
|
else if (strcmp(wot_cmd, "1") == 0 || strcmp(wot_cmd, "write") == 0) {
|
||||||
|
// Write-only mode
|
||||||
|
update_config_in_table("wot_enabled", "1");
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
|
||||||
|
wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"✍️ Web of Trust: Write-Only Mode\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"Level: 1 (Write-only restriction)\n"
|
||||||
|
"\n"
|
||||||
|
"Only pubkeys you follow can publish events.\n"
|
||||||
|
"Anyone can still subscribe and read events.\n"
|
||||||
|
"\n"
|
||||||
|
"The relay will now sync from your kind 3 (contact list) event.");
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
DEBUG_INFO("DM_ADMIN: WoT set to write-only mode");
|
||||||
|
}
|
||||||
|
else if (strcmp(wot_cmd, "2") == 0 || strcmp(wot_cmd, "full") == 0) {
|
||||||
|
// Full mode (write + read restriction)
|
||||||
|
update_config_in_table("wot_enabled", "2");
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
|
||||||
|
wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"🔒 Web of Trust: Full Mode\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"Level: 2 (Full restriction)\n"
|
||||||
|
"\n"
|
||||||
|
"Only pubkeys you follow can:\n"
|
||||||
|
" • Publish events\n"
|
||||||
|
" • Subscribe to events (requires NIP-42 auth)\n"
|
||||||
|
"\n"
|
||||||
|
"This eliminates anonymous subscription churn.\n"
|
||||||
|
"\n"
|
||||||
|
"The relay will now sync from your kind 3 (contact list) event.");
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
DEBUG_INFO("DM_ADMIN: WoT set to full mode");
|
||||||
|
}
|
||||||
|
else if (strcmp(wot_cmd, "sync") == 0) {
|
||||||
|
// Force resync
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"🔄 Web of Trust: Sync Complete\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"WoT whitelist has been refreshed from your\n"
|
||||||
|
"kind 3 (contact list) event.\n"
|
||||||
|
"\n"
|
||||||
|
"Current level: %d", wot_level);
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
DEBUG_INFO("DM_ADMIN: WoT manual sync completed");
|
||||||
|
}
|
||||||
|
else if (strcmp(wot_cmd, "status") == 0 || strlen(wot_cmd) == 0) {
|
||||||
|
// Show status
|
||||||
|
// Count whitelisted pubkeys
|
||||||
|
sqlite3_stmt* stmt;
|
||||||
|
const char* count_sql = "SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'wot_whitelist' AND active = 1";
|
||||||
|
int whitelist_count = 0;
|
||||||
|
|
||||||
|
if (sqlite3_prepare_v2(g_db, count_sql, -1, &stmt, NULL) == SQLITE_OK) {
|
||||||
|
if (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
whitelist_count = sqlite3_column_int(stmt, 0);
|
||||||
|
}
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* level_str;
|
||||||
|
if (wot_level == 0) level_str = "Off (open relay)";
|
||||||
|
else if (wot_level == 1) level_str = "Write-only (followed pubkeys can publish)";
|
||||||
|
else if (wot_level == 2) level_str = "Full (followed pubkeys can publish AND subscribe)";
|
||||||
|
else level_str = "Unknown";
|
||||||
|
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"📊 Web of Trust Status\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"Level: %d\n"
|
||||||
|
"%s\n"
|
||||||
|
"\n"
|
||||||
|
"Whitelisted pubkeys: %d\n"
|
||||||
|
"\n"
|
||||||
|
"Commands:\n"
|
||||||
|
" wot 0/off - Disable WoT\n"
|
||||||
|
" wot 1/write - Write-only mode\n"
|
||||||
|
" wot 2/full - Full restriction mode\n"
|
||||||
|
" wot sync - Force resync from kind 3\n"
|
||||||
|
" wot status - Show this status",
|
||||||
|
wot_level, level_str, whitelist_count);
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
DEBUG_INFO("DM_ADMIN: WoT status displayed");
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Unknown wot subcommand
|
||||||
|
snprintf(response_msg, sizeof(response_msg),
|
||||||
|
"❌ Unknown WoT Command\n"
|
||||||
|
"━━━━━━━━━━━━━━━━━━━━━━\n"
|
||||||
|
"\n"
|
||||||
|
"Usage: wot [command]\n"
|
||||||
|
"\n"
|
||||||
|
"Commands:\n"
|
||||||
|
" 0, off - Disable WoT\n"
|
||||||
|
" 1, write - Write-only mode\n"
|
||||||
|
" 2, full - Full restriction mode\n"
|
||||||
|
" sync - Force resync from kind 3\n"
|
||||||
|
" status - Show current status");
|
||||||
|
send_nip17_response(sender_pubkey, response_msg, NULL, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
|
DEBUG_INFO("DM_ADMIN: Checking for confirmation or config change requests");
|
||||||
// Check if it's a confirmation response (yes/no)
|
// Check if it's a confirmation response (yes/no)
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+127
-7
@@ -657,6 +657,46 @@ const char* extract_d_tag_value(cJSON* tags) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Insert denormalized tags into event_tags table for fast indexed lookups
|
||||||
|
int store_event_tags(const char* event_id, cJSON* tags) {
|
||||||
|
if (!g_db || !event_id || !tags || !cJSON_IsArray(tags)) {
|
||||||
|
return 0; // Not an error if no tags
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* sql = "INSERT INTO event_tags (event_id, tag_name, tag_value, tag_index) VALUES (?, ?, ?, ?)";
|
||||||
|
sqlite3_stmt* stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) {
|
||||||
|
DEBUG_ERROR("Failed to prepare event_tags insert: %s", sqlite3_errmsg(g_db));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
int tag_index = 0;
|
||||||
|
cJSON* tag = NULL;
|
||||||
|
cJSON_ArrayForEach(tag, tags) {
|
||||||
|
if (cJSON_IsArray(tag) && cJSON_GetArraySize(tag) >= 2) {
|
||||||
|
cJSON* name = cJSON_GetArrayItem(tag, 0);
|
||||||
|
cJSON* value = cJSON_GetArrayItem(tag, 1);
|
||||||
|
|
||||||
|
if (cJSON_IsString(name) && cJSON_IsString(value)) {
|
||||||
|
sqlite3_reset(stmt);
|
||||||
|
sqlite3_bind_text(stmt, 1, event_id, -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 2, cJSON_GetStringValue(name), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_text(stmt, 3, cJSON_GetStringValue(value), -1, SQLITE_STATIC);
|
||||||
|
sqlite3_bind_int(stmt, 4, tag_index);
|
||||||
|
|
||||||
|
rc = sqlite3_step(stmt);
|
||||||
|
if (rc != SQLITE_DONE) {
|
||||||
|
DEBUG_ERROR("Failed to insert event tag: %s", sqlite3_errmsg(g_db));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tag_index++;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Store event in database
|
// Store event in database
|
||||||
int store_event(cJSON* event) {
|
int store_event(cJSON* event) {
|
||||||
@@ -738,7 +778,9 @@ int store_event(cJSON* event) {
|
|||||||
if (rc != SQLITE_DONE) {
|
if (rc != SQLITE_DONE) {
|
||||||
const char* err_msg = sqlite3_errmsg(g_db);
|
const char* err_msg = sqlite3_errmsg(g_db);
|
||||||
int extended_errcode = sqlite3_extended_errcode(g_db);
|
int extended_errcode = sqlite3_extended_errcode(g_db);
|
||||||
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
if (rc != SQLITE_CONSTRAINT) {
|
||||||
|
DEBUG_ERROR("INSERT failed: rc=%d, extended_errcode=%d, msg=%s", rc, extended_errcode, err_msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
|
|
||||||
@@ -783,6 +825,71 @@ int store_event(cJSON* event) {
|
|||||||
// Call monitoring hook after successful event storage
|
// Call monitoring hook after successful event storage
|
||||||
monitoring_on_event_stored();
|
monitoring_on_event_stored();
|
||||||
|
|
||||||
|
// After successful event storage, insert denormalized tags
|
||||||
|
store_event_tags(cJSON_GetStringValue(id), tags);
|
||||||
|
|
||||||
|
// Check if this is a kind 3 event from the admin — trigger WoT sync
|
||||||
|
cJSON* kind_obj = cJSON_GetObjectItem(event, "kind");
|
||||||
|
cJSON* pubkey_obj = cJSON_GetObjectItem(event, "pubkey");
|
||||||
|
if (kind_obj && pubkey_obj && cJSON_GetNumberValue(kind_obj) == 3) {
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level > 0) {
|
||||||
|
const char* admin_pubkey = get_config_value("admin_pubkey");
|
||||||
|
if (admin_pubkey && strcmp(cJSON_GetStringValue(pubkey_obj), admin_pubkey) == 0) {
|
||||||
|
DEBUG_INFO("Admin kind 3 event stored — triggering WoT sync");
|
||||||
|
extern int wot_sync_from_admin_kind3(void);
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
}
|
||||||
|
if (admin_pubkey) free((char*)admin_pubkey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate event_tags from existing events (run once at startup)
|
||||||
|
int populate_event_tags_from_existing(void) {
|
||||||
|
if (!g_db) return -1;
|
||||||
|
|
||||||
|
// Check if event_tags is already populated
|
||||||
|
sqlite3_stmt* check_stmt;
|
||||||
|
sqlite3_prepare_v2(g_db, "SELECT COUNT(*) FROM event_tags", -1, &check_stmt, NULL);
|
||||||
|
if (sqlite3_step(check_stmt) == SQLITE_ROW && sqlite3_column_int(check_stmt, 0) > 0) {
|
||||||
|
sqlite3_finalize(check_stmt);
|
||||||
|
DEBUG_INFO("event_tags already populated, skipping");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
sqlite3_finalize(check_stmt);
|
||||||
|
|
||||||
|
DEBUG_INFO("Populating event_tags from existing events...");
|
||||||
|
|
||||||
|
const char* sql = "SELECT id, tags FROM events WHERE tags != '[]'";
|
||||||
|
sqlite3_stmt* stmt;
|
||||||
|
int rc = sqlite3_prepare_v2(g_db, sql, -1, &stmt, NULL);
|
||||||
|
if (rc != SQLITE_OK) return -1;
|
||||||
|
|
||||||
|
// Use a transaction for bulk insert performance
|
||||||
|
sqlite3_exec(g_db, "BEGIN TRANSACTION", NULL, NULL, NULL);
|
||||||
|
|
||||||
|
int event_count = 0;
|
||||||
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
const char* event_id = (const char*)sqlite3_column_text(stmt, 0);
|
||||||
|
const char* tags_json = (const char*)sqlite3_column_text(stmt, 1);
|
||||||
|
|
||||||
|
if (event_id && tags_json) {
|
||||||
|
cJSON* tags = cJSON_Parse(tags_json);
|
||||||
|
if (tags) {
|
||||||
|
store_event_tags(event_id, tags);
|
||||||
|
cJSON_Delete(tags);
|
||||||
|
event_count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
sqlite3_exec(g_db, "COMMIT", NULL, NULL, NULL);
|
||||||
|
|
||||||
|
DEBUG_INFO("Populated event_tags for %d events", event_count);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,8 +1347,8 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (tag_value_count > 0) {
|
if (tag_value_count > 0) {
|
||||||
// Use EXISTS with parameterized query
|
// Use indexed event_tags table instead of json_each()
|
||||||
snprintf(sql_ptr, remaining, " AND EXISTS (SELECT 1 FROM json_each(json(tags)) WHERE json_extract(value, '$[0]') = ? AND json_extract(value, '$[1]') IN (");
|
snprintf(sql_ptr, remaining, " AND id IN (SELECT event_id FROM event_tags WHERE tag_name = ? AND tag_value IN (");
|
||||||
sql_ptr += strlen(sql_ptr);
|
sql_ptr += strlen(sql_ptr);
|
||||||
remaining = sizeof(sql) - strlen(sql);
|
remaining = sizeof(sql) - strlen(sql);
|
||||||
|
|
||||||
@@ -1372,7 +1479,11 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
|||||||
for (int i = 0; i < bind_param_count; i++) {
|
for (int i = 0; i < bind_param_count; i++) {
|
||||||
sqlite3_bind_text(stmt, i + 1, bind_params[i], -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, i + 1, bind_params[i], -1, SQLITE_TRANSIENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache config values outside the row loop (performance fix)
|
||||||
|
int expiration_enabled = get_config_bool("expiration_enabled", 1);
|
||||||
|
int filter_responses = get_config_bool("expiration_filter", 1);
|
||||||
|
|
||||||
int row_count = 0;
|
int row_count = 0;
|
||||||
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
row_count++;
|
row_count++;
|
||||||
@@ -1397,9 +1508,7 @@ int handle_req_message(const char* sub_id, cJSON* filters, struct lws *wsi, stru
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check expiration filtering (NIP-40) at application level
|
// Check expiration filtering (NIP-40) at application level
|
||||||
int expiration_enabled = get_config_bool("expiration_enabled", 1);
|
// (expiration_enabled and filter_responses are cached outside the loop)
|
||||||
int filter_responses = get_config_bool("expiration_filter", 1);
|
|
||||||
|
|
||||||
if (expiration_enabled && filter_responses) {
|
if (expiration_enabled && filter_responses) {
|
||||||
time_t current_time = time(NULL);
|
time_t current_time = time(NULL);
|
||||||
if (is_event_expired(event, current_time)) {
|
if (is_event_expired(event, current_time)) {
|
||||||
@@ -1989,6 +2098,17 @@ int main(int argc, char* argv[]) {
|
|||||||
// Initialize kind-based index for fast subscription lookup
|
// Initialize kind-based index for fast subscription lookup
|
||||||
init_kind_index();
|
init_kind_index();
|
||||||
|
|
||||||
|
// Populate event_tags from existing events (for tag-based lookups)
|
||||||
|
populate_event_tags_from_existing();
|
||||||
|
|
||||||
|
// Sync Web of Trust whitelist if enabled
|
||||||
|
int wot_level = get_config_int("wot_enabled", 0);
|
||||||
|
if (wot_level > 0) {
|
||||||
|
DEBUG_INFO("WoT enabled (level %d) - syncing from admin's kind 3 event", wot_level);
|
||||||
|
extern int wot_sync_from_admin_kind3(void);
|
||||||
|
wot_sync_from_admin_kind3();
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup orphaned subscriptions from previous runs
|
// Cleanup orphaned subscriptions from previous runs
|
||||||
cleanup_all_subscriptions_on_startup();
|
cleanup_all_subscriptions_on_startup();
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -13,8 +13,8 @@
|
|||||||
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
// Using CRELAY_ prefix to avoid conflicts with nostr_core_lib VERSION macros
|
||||||
#define CRELAY_VERSION_MAJOR 1
|
#define CRELAY_VERSION_MAJOR 1
|
||||||
#define CRELAY_VERSION_MINOR 2
|
#define CRELAY_VERSION_MINOR 2
|
||||||
#define CRELAY_VERSION_PATCH 4
|
#define CRELAY_VERSION_PATCH 5
|
||||||
#define CRELAY_VERSION "v1.2.4"
|
#define CRELAY_VERSION "v1.2.5"
|
||||||
|
|
||||||
// Relay metadata (authoritative source for NIP-11 information)
|
// Relay metadata (authoritative source for NIP-11 information)
|
||||||
#define RELAY_NAME "C-Relay"
|
#define RELAY_NAME "C-Relay"
|
||||||
|
|||||||
@@ -595,10 +595,10 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Check pubkey whitelist
|
// Step 3: Check pubkey whitelist (includes wot_whitelist)
|
||||||
const char *whitelist_sql =
|
const char *whitelist_sql =
|
||||||
"SELECT rule_type FROM auth_rules WHERE rule_type = "
|
"SELECT rule_type FROM auth_rules WHERE rule_type IN "
|
||||||
"'whitelist' AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
|
"('whitelist', 'wot_whitelist') AND pattern_type = 'pubkey' AND pattern_value = ? AND active = 1 LIMIT 1";
|
||||||
rc = sqlite3_prepare_v2(db, whitelist_sql, -1, &stmt, NULL);
|
rc = sqlite3_prepare_v2(db, whitelist_sql, -1, &stmt, NULL);
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
|
sqlite3_bind_text(stmt, 1, pubkey, -1, SQLITE_STATIC);
|
||||||
@@ -611,9 +611,9 @@ int check_database_auth_rules(const char *pubkey, const char *operation __attrib
|
|||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Check if any whitelist rules exist - if yes, deny by default
|
// Step 4: Check if any whitelist rules exist - if yes, deny by default (includes wot_whitelist)
|
||||||
const char *whitelist_exists_sql =
|
const char *whitelist_exists_sql =
|
||||||
"SELECT COUNT(*) FROM auth_rules WHERE rule_type = 'whitelist' "
|
"SELECT COUNT(*) FROM auth_rules WHERE rule_type IN ('whitelist', 'wot_whitelist') "
|
||||||
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
|
"AND pattern_type = 'pubkey' AND active = 1 LIMIT 1";
|
||||||
rc = sqlite3_prepare_v2(db, whitelist_exists_sql, -1, &stmt, NULL);
|
rc = sqlite3_prepare_v2(db, whitelist_exists_sql, -1, &stmt, NULL);
|
||||||
if (rc == SQLITE_OK) {
|
if (rc == SQLITE_OK) {
|
||||||
|
|||||||
+26
-7
@@ -1,11 +1,11 @@
|
|||||||
/* Embedded SQL Schema for C Nostr Relay
|
/* Embedded SQL Schema for C Nostr Relay
|
||||||
* Schema Version: 11
|
* Schema Version: 12
|
||||||
*/
|
*/
|
||||||
#ifndef SQL_SCHEMA_H
|
#ifndef SQL_SCHEMA_H
|
||||||
#define SQL_SCHEMA_H
|
#define SQL_SCHEMA_H
|
||||||
|
|
||||||
/* Schema version constant */
|
/* Schema version constant */
|
||||||
#define EMBEDDED_SCHEMA_VERSION "11"
|
#define EMBEDDED_SCHEMA_VERSION "12"
|
||||||
|
|
||||||
/* Embedded SQL schema as C string literal */
|
/* Embedded SQL schema as C string literal */
|
||||||
static const char* const EMBEDDED_SCHEMA_SQL =
|
static const char* const EMBEDDED_SCHEMA_SQL =
|
||||||
@@ -14,7 +14,7 @@ static const char* const EMBEDDED_SCHEMA_SQL =
|
|||||||
-- Configuration system using config table\n\
|
-- Configuration system using config table\n\
|
||||||
\n\
|
\n\
|
||||||
-- Schema version tracking\n\
|
-- Schema version tracking\n\
|
||||||
PRAGMA user_version = 11;\n\
|
PRAGMA user_version = 12;\n\
|
||||||
\n\
|
\n\
|
||||||
-- Enable foreign key support\n\
|
-- Enable foreign key support\n\
|
||||||
PRAGMA foreign_keys = ON;\n\
|
PRAGMA foreign_keys = ON;\n\
|
||||||
@@ -49,6 +49,25 @@ CREATE INDEX idx_events_kind_created_at ON events(kind, created_at DESC);\n\
|
|||||||
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n\
|
CREATE INDEX idx_events_pubkey_created_at ON events(pubkey, created_at DESC);\n\
|
||||||
CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);\n\
|
CREATE INDEX idx_events_pubkey_kind ON events(pubkey, kind);\n\
|
||||||
\n\
|
\n\
|
||||||
|
-- Denormalized event tags for fast indexed lookups\n\
|
||||||
|
-- Replaces json_each(json(tags)) queries which cause full JSON parsing per row\n\
|
||||||
|
CREATE TABLE event_tags (\n\
|
||||||
|
event_id TEXT NOT NULL,\n\
|
||||||
|
tag_name TEXT NOT NULL,\n\
|
||||||
|
tag_value TEXT NOT NULL,\n\
|
||||||
|
tag_index INTEGER NOT NULL DEFAULT 0,\n\
|
||||||
|
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE\n\
|
||||||
|
);\n\
|
||||||
|
\n\
|
||||||
|
-- Primary lookup index: find events by tag name + value\n\
|
||||||
|
CREATE INDEX idx_event_tags_lookup ON event_tags(tag_name, tag_value);\n\
|
||||||
|
\n\
|
||||||
|
-- Reverse lookup: find all tags for an event (for cleanup)\n\
|
||||||
|
CREATE INDEX idx_event_tags_event ON event_tags(event_id);\n\
|
||||||
|
\n\
|
||||||
|
-- Composite index for common query pattern: tag + kind (via join)\n\
|
||||||
|
CREATE INDEX idx_event_tags_value_name ON event_tags(tag_value, tag_name);\n\
|
||||||
|
\n\
|
||||||
-- Schema information table\n\
|
-- Schema information table\n\
|
||||||
CREATE TABLE schema_info (\n\
|
CREATE TABLE schema_info (\n\
|
||||||
key TEXT PRIMARY KEY,\n\
|
key TEXT PRIMARY KEY,\n\
|
||||||
@@ -58,8 +77,8 @@ CREATE TABLE schema_info (\n\
|
|||||||
\n\
|
\n\
|
||||||
-- Insert schema metadata\n\
|
-- Insert schema metadata\n\
|
||||||
INSERT INTO schema_info (key, value) VALUES\n\
|
INSERT INTO schema_info (key, value) VALUES\n\
|
||||||
('version', '11'),\n\
|
('version', '12'),\n\
|
||||||
('description', 'Added event_json column for 2500x performance improvement in event retrieval'),\n\
|
('description', 'Added event_tags table for fast indexed tag lookups, replacing json_each() correlated subqueries'),\n\
|
||||||
('created_at', strftime('%s', 'now'));\n\
|
('created_at', strftime('%s', 'now'));\n\
|
||||||
\n\
|
\n\
|
||||||
-- Helper views for common queries\n\
|
-- Helper views for common queries\n\
|
||||||
@@ -129,7 +148,7 @@ CREATE TABLE relay_seckey (\n\
|
|||||||
-- Used by request_validator.c for unified validation\n\
|
-- Used by request_validator.c for unified validation\n\
|
||||||
CREATE TABLE auth_rules (\n\
|
CREATE TABLE auth_rules (\n\
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
|
id INTEGER PRIMARY KEY AUTOINCREMENT,\n\
|
||||||
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required')),\n\
|
rule_type TEXT NOT NULL CHECK (rule_type IN ('whitelist', 'blacklist', 'rate_limit', 'auth_required', 'wot_whitelist')),\n\
|
||||||
pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),\n\
|
pattern_type TEXT NOT NULL CHECK (pattern_type IN ('pubkey', 'kind', 'ip', 'global')),\n\
|
||||||
pattern_value TEXT,\n\
|
pattern_value TEXT,\n\
|
||||||
active INTEGER NOT NULL DEFAULT 1,\n\
|
active INTEGER NOT NULL DEFAULT 1,\n\
|
||||||
@@ -311,4 +330,4 @@ SELECT\n\
|
|||||||
FROM events\n\
|
FROM events\n\
|
||||||
WHERE created_at >= (strftime('%s', 'now') - 2592000);";
|
WHERE created_at >= (strftime('%s', 'now') - 2592000);";
|
||||||
|
|
||||||
#endif /* SQL_SCHEMA_H */
|
#endif /* SQL_SCHEMA_H */
|
||||||
|
|||||||
+91
-49
@@ -768,44 +768,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event_kind == 1059) {
|
} else if (event_kind == 1059) {
|
||||||
// Check for NIP-17 gift wrap admin messages
|
// NIP-17 gift wrap events
|
||||||
|
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
|
||||||
|
// to prevent expensive decryption on every incoming gift wrap event
|
||||||
|
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
|
||||||
|
if (nip17_admin_enabled) {
|
||||||
|
char nip17_error[512] = {0};
|
||||||
|
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
|
||||||
|
|
||||||
char nip17_error[512] = {0};
|
if (!response_event) {
|
||||||
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
|
// Check if this is an error or if the command was already handled
|
||||||
|
if (strlen(nip17_error) > 0) {
|
||||||
if (!response_event) {
|
// There was an actual error
|
||||||
// Check if this is an error or if the command was already handled
|
DEBUG_ERROR("NIP-17 admin message processing failed");
|
||||||
if (strlen(nip17_error) > 0) {
|
result = -1;
|
||||||
// There was an actual error
|
size_t error_len = strlen(nip17_error);
|
||||||
DEBUG_ERROR("NIP-17 admin message processing failed");
|
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
|
||||||
result = -1;
|
memcpy(error_message, nip17_error, copy_len);
|
||||||
size_t error_len = strlen(nip17_error);
|
error_message[copy_len] = '\0';
|
||||||
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
|
|
||||||
memcpy(error_message, nip17_error, copy_len);
|
|
||||||
error_message[copy_len] = '\0';
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No error message means the command was already handled (plain text commands)
|
||||||
|
// Store the original gift wrap event in database
|
||||||
|
if (store_event(event) != 0) {
|
||||||
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
|
result = -1;
|
||||||
|
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No error message means the command was already handled (plain text commands)
|
// Store the original gift wrap event in database (unlike kind 23456)
|
||||||
// Store the original gift wrap event in database
|
|
||||||
if (store_event(event) != 0) {
|
if (store_event(event) != 0) {
|
||||||
DEBUG_ERROR("Failed to store gift wrap event in database");
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
result = -1;
|
result = -1;
|
||||||
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
||||||
|
cJSON_Delete(response_event);
|
||||||
|
} else {
|
||||||
|
// Broadcast RESPONSE event to matching persistent subscriptions
|
||||||
|
broadcast_event_to_subscriptions(response_event);
|
||||||
|
|
||||||
|
// Clean up response event
|
||||||
|
cJSON_Delete(response_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Store the original gift wrap event in database (unlike kind 23456)
|
// NIP-17 admin DMs disabled: store gift wrap as a regular event
|
||||||
|
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
|
||||||
if (store_event(event) != 0) {
|
if (store_event(event) != 0) {
|
||||||
DEBUG_ERROR("Failed to store gift wrap event in database");
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
result = -1;
|
result = -1;
|
||||||
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
|
||||||
cJSON_Delete(response_event);
|
|
||||||
} else {
|
} else {
|
||||||
// Broadcast RESPONSE event to matching persistent subscriptions
|
broadcast_event_to_subscriptions(event);
|
||||||
broadcast_event_to_subscriptions(response_event);
|
|
||||||
|
|
||||||
// Clean up response event
|
|
||||||
cJSON_Delete(response_event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event_kind == 14) {
|
} else if (event_kind == 14) {
|
||||||
@@ -913,7 +927,21 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
|||||||
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
|
// and should not be freed here - it will be cleaned up in LWS_CALLBACK_CLOSED
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WoT read restriction check (wot_enabled == 2)
|
||||||
|
// After NIP-42 auth, check if authenticated pubkey is in WoT whitelist
|
||||||
|
if (pss && pss->authenticated && get_config_int("wot_enabled", 0) == 2) {
|
||||||
|
// Client is authenticated - check if their pubkey is in the WoT whitelist
|
||||||
|
extern int check_database_auth_rules(const char* pubkey, const char* operation, const char* resource_hash);
|
||||||
|
int wot_result = check_database_auth_rules(pss->authenticated_pubkey, "subscription", NULL);
|
||||||
|
if (wot_result != 0) {
|
||||||
|
send_notice_message(wsi, pss, "restricted: your pubkey is not in this relay's web of trust");
|
||||||
|
DEBUG_INFO("REQ rejected: pubkey %s not in WoT whitelist", pss->authenticated_pubkey);
|
||||||
|
cJSON_Delete(json);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
DEBUG_TRACE("REQ message passed authentication check");
|
DEBUG_TRACE("REQ message passed authentication check");
|
||||||
|
|
||||||
// Handle REQ message
|
// Handle REQ message
|
||||||
@@ -1476,44 +1504,58 @@ static int nostr_relay_callback(struct lws *wsi, enum lws_callback_reasons reaso
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event_kind == 1059) {
|
} else if (event_kind == 1059) {
|
||||||
// Check for NIP-17 gift wrap admin messages
|
// NIP-17 gift wrap events
|
||||||
|
// Admin DM processing is opt-in via nip17_admin_enabled config (default: off)
|
||||||
|
// to prevent expensive decryption on every incoming gift wrap event
|
||||||
|
int nip17_admin_enabled = get_config_bool("nip17_admin_enabled", 0);
|
||||||
|
if (nip17_admin_enabled) {
|
||||||
|
char nip17_error[512] = {0};
|
||||||
|
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
|
||||||
|
|
||||||
char nip17_error[512] = {0};
|
if (!response_event) {
|
||||||
cJSON* response_event = process_nip17_admin_message(event, nip17_error, sizeof(nip17_error), wsi);
|
// Check if this is an error or if the command was already handled
|
||||||
|
if (strlen(nip17_error) > 0) {
|
||||||
if (!response_event) {
|
// There was an actual error
|
||||||
// Check if this is an error or if the command was already handled
|
DEBUG_ERROR("NIP-17 admin message processing failed");
|
||||||
if (strlen(nip17_error) > 0) {
|
result = -1;
|
||||||
// There was an actual error
|
size_t error_len = strlen(nip17_error);
|
||||||
DEBUG_ERROR("NIP-17 admin message processing failed");
|
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
|
||||||
result = -1;
|
memcpy(error_message, nip17_error, copy_len);
|
||||||
size_t error_len = strlen(nip17_error);
|
error_message[copy_len] = '\0';
|
||||||
size_t copy_len = (error_len < sizeof(error_message) - 1) ? error_len : sizeof(error_message) - 1;
|
|
||||||
memcpy(error_message, nip17_error, copy_len);
|
|
||||||
error_message[copy_len] = '\0';
|
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No error message means the command was already handled (plain text commands)
|
||||||
|
// Store the original gift wrap event in database
|
||||||
|
if (store_event(event) != 0) {
|
||||||
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
|
result = -1;
|
||||||
|
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No error message means the command was already handled (plain text commands)
|
// Store the original gift wrap event in database (unlike kind 23456)
|
||||||
// Store the original gift wrap event in database
|
|
||||||
if (store_event(event) != 0) {
|
if (store_event(event) != 0) {
|
||||||
DEBUG_ERROR("Failed to store gift wrap event in database");
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
result = -1;
|
result = -1;
|
||||||
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
||||||
|
cJSON_Delete(response_event);
|
||||||
|
} else {
|
||||||
|
// Broadcast RESPONSE event to matching persistent subscriptions
|
||||||
|
broadcast_event_to_subscriptions(response_event);
|
||||||
|
|
||||||
|
// Clean up response event
|
||||||
|
cJSON_Delete(response_event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Store the original gift wrap event in database (unlike kind 23456)
|
// NIP-17 admin DMs disabled: store gift wrap as a regular event
|
||||||
|
DEBUG_TRACE("NIP-17 admin DMs disabled - storing kind 1059 event without decryption");
|
||||||
if (store_event(event) != 0) {
|
if (store_event(event) != 0) {
|
||||||
DEBUG_ERROR("Failed to store gift wrap event in database");
|
DEBUG_ERROR("Failed to store gift wrap event in database");
|
||||||
result = -1;
|
result = -1;
|
||||||
strncpy(error_message, "error: failed to store gift wrap event", sizeof(error_message) - 1);
|
strncpy(error_message, "error: failed to store event", sizeof(error_message) - 1);
|
||||||
cJSON_Delete(response_event);
|
|
||||||
} else {
|
} else {
|
||||||
// Broadcast RESPONSE event to matching persistent subscriptions
|
broadcast_event_to_subscriptions(event);
|
||||||
broadcast_event_to_subscriptions(response_event);
|
|
||||||
|
|
||||||
// Clean up response event
|
|
||||||
cJSON_Delete(response_event);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event_kind == 14) {
|
} else if (event_kind == 14) {
|
||||||
|
|||||||
+22
-7
@@ -329,12 +329,12 @@ run_count_test() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 5: Count events with specific tags
|
# Test 5: Count events with specific tags
|
||||||
# NOTE: Tag filtering is currently not working in the relay - should return the tagged events
|
# Tag filtering now works with event_tags table
|
||||||
local expected_type_regular=$((0 + BASELINE_TYPE_REGULAR)) # Currently returns 0 due to tag filtering bug
|
local expected_type_regular=$((3 + BASELINE_TYPE_REGULAR)) # 3 new regular events have type=regular tag
|
||||||
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "$expected_type_regular"; then
|
if ! test_count "count_tag_type" '{"#type":["regular"]}' "Count events with type=regular tag" "$expected_type_regular"; then
|
||||||
((test_failures++))
|
((test_failures++))
|
||||||
fi
|
fi
|
||||||
local expected_test_nip45=$((0 + BASELINE_TEST_NIP45)) # Currently returns 0 due to tag filtering bug
|
local expected_test_nip45=$((3 + BASELINE_TEST_NIP45)) # 3 new regular events have test=nip45 tag
|
||||||
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "$expected_test_nip45"; then
|
if ! test_count "count_tag_test" '{"#test":["nip45"]}' "Count events with test=nip45 tag" "$expected_test_nip45"; then
|
||||||
((test_failures++))
|
((test_failures++))
|
||||||
fi
|
fi
|
||||||
@@ -362,8 +362,8 @@ run_count_test() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 9: Count with multiple filters combined
|
# Test 9: Count with multiple filters combined
|
||||||
# NOTE: Combined tag filtering is currently not working in the relay
|
# Combined tag+kind filtering now works with event_tags table
|
||||||
local expected_combined=$((0 + BASELINE_COMBINED)) # Currently returns 0 due to tag filtering bug
|
local expected_combined=$((3 + BASELINE_COMBINED)) # 3 new regular events match all criteria
|
||||||
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "$expected_combined"; then
|
if ! test_count "count_combined" '{"kinds":[1],"#type":["regular"],"#test":["nip45"]}' "Count with combined filters" "$expected_combined"; then
|
||||||
((test_failures++))
|
((test_failures++))
|
||||||
fi
|
fi
|
||||||
@@ -379,8 +379,23 @@ run_count_test() {
|
|||||||
((test_failures++))
|
((test_failures++))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Test 12: Count non-existent kind
|
# Test 12: Count non-existent kind (kind 99999 is NDK ping, rejected with NOTICE)
|
||||||
if ! test_count "count_nonexistent" '{"kinds":[99999]}' "Count non-existent kind" "0"; then
|
# The relay correctly rejects invalid kinds with a NOTICE instead of COUNT
|
||||||
|
print_step "Testing COUNT: Count non-existent kind (kind 99999 - NDK ping)"
|
||||||
|
local count_message='["COUNT","count_nonexistent",{"kinds":[99999]}]'
|
||||||
|
local response=$(echo "$count_message" | timeout 3s websocat "$RELAY_URL" 2>/dev/null || echo "")
|
||||||
|
if echo "$response" | grep -q '"NOTICE"'; then
|
||||||
|
print_success "Count non-existent kind - Correctly rejected with NOTICE"
|
||||||
|
elif echo "$response" | grep -q '"COUNT"'; then
|
||||||
|
local actual_count=$(echo "$response" | jq -r '.[2].count' 2>/dev/null)
|
||||||
|
if [[ "$actual_count" == "0" ]]; then
|
||||||
|
print_success "Count non-existent kind - Expected: 0, Got: 0"
|
||||||
|
else
|
||||||
|
print_error "Count non-existent kind - Expected: 0, Got: $actual_count"
|
||||||
|
((test_failures++))
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "Count non-existent kind - No response from relay"
|
||||||
((test_failures++))
|
((test_failures++))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user