Some checks failed
CI Pipeline / Run Tests (push) Has been cancelled
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Security Scan (push) Has been cancelled
CI Pipeline / Build Docker Images (push) Has been cancelled
CI Pipeline / E2E Tests (push) Has been cancelled
1147 lines
47 KiB
HTML
1147 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Admin Dashboard - Blossom-BitTorrent Gateway</title>
|
|
<link rel="stylesheet" href="/static/style.css">
|
|
<style>
|
|
.admin-nav {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 30px;
|
|
display: flex;
|
|
gap: 15px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.admin-nav-btn {
|
|
padding: 10px 20px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.admin-nav-btn.active,
|
|
.admin-nav-btn:hover {
|
|
background: var(--accent-primary);
|
|
color: white;
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.admin-section {
|
|
display: none;
|
|
}
|
|
|
|
.admin-section.active {
|
|
display: block;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.admin-table {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.admin-table table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.admin-table th,
|
|
.admin-table td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border-color);
|
|
word-wrap: break-word;
|
|
word-break: break-word;
|
|
max-width: 200px;
|
|
}
|
|
|
|
.admin-table th:first-child,
|
|
.admin-table td:first-child {
|
|
max-width: 50px;
|
|
}
|
|
|
|
.admin-table .hash-short {
|
|
max-width: 120px;
|
|
font-family: monospace;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.admin-table .owner-cell,
|
|
.admin-table .user-profile-cell {
|
|
max-width: 180px;
|
|
overflow: visible;
|
|
}
|
|
|
|
.admin-table th {
|
|
background: var(--bg-tertiary);
|
|
font-weight: 600;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.admin-table td {
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.admin-table tr:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.admin-controls {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.admin-form {
|
|
background: var(--bg-secondary);
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-weight: 500;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group input,
|
|
.form-group select,
|
|
.form-group textarea {
|
|
width: 100%;
|
|
padding: 10px;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.form-group textarea {
|
|
min-height: 80px;
|
|
resize: vertical;
|
|
}
|
|
|
|
.status-badge {
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.status-badge.banned {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.active {
|
|
background: var(--success);
|
|
color: white;
|
|
}
|
|
|
|
.status-badge.pending {
|
|
background: var(--warning);
|
|
color: black;
|
|
}
|
|
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1 id="admin-site-title">🛡️ Admin Dashboard</h1>
|
|
<nav>
|
|
<a href="/">← Back to Gateway</a>
|
|
<div id="admin-auth-status" class="auth-status">
|
|
<span id="admin-user-info">Not logged in</span>
|
|
<button id="admin-logout-btn" onclick="adminLogout()" style="display: none;">Logout</button>
|
|
</div>
|
|
</nav>
|
|
</header>
|
|
|
|
<main id="admin-content" style="display: none;">
|
|
<div class="admin-nav">
|
|
<button class="admin-nav-btn active" onclick="showAdminSection('overview')">Overview</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('users')">Users</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('files')">Files</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('reports')">Reports</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('cleanup')">Cleanup</button>
|
|
<button class="admin-nav-btn" onclick="showAdminSection('logs')">Audit Log</button>
|
|
</div>
|
|
|
|
<!-- Overview Section -->
|
|
<div id="overview-section" class="admin-section active">
|
|
<h2>System Overview</h2>
|
|
<div class="stats-grid" id="admin-stats">
|
|
<!-- Dynamic content -->
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<h3>Recent Uploads (24h)</h3>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>File Name</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Owner</th>
|
|
<th>Upload Time</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="recent-uploads-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Users Section -->
|
|
<div id="users-section" class="admin-section">
|
|
<h2>User Management</h2>
|
|
|
|
<div class="admin-controls">
|
|
<button class="action-btn" onclick="refreshUsers()">↻ Refresh</button>
|
|
<button class="action-btn" onclick="exportUsers()">📥 Export</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Public Key</th>
|
|
<th>Display Name</th>
|
|
<th>Files</th>
|
|
<th>Storage</th>
|
|
<th>Last Login</th>
|
|
<th>Status</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="users-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Files Section -->
|
|
<div id="files-section" class="admin-section">
|
|
<h2>File Management</h2>
|
|
|
|
<div class="admin-controls">
|
|
<select id="file-storage-filter">
|
|
<option value="">All Storage Types</option>
|
|
<option value="blob">Blobs</option>
|
|
<option value="torrent">Torrents</option>
|
|
</select>
|
|
<select id="file-access-filter">
|
|
<option value="">All Access Levels</option>
|
|
<option value="public">Public</option>
|
|
<option value="private">Private</option>
|
|
</select>
|
|
<button class="action-btn" onclick="refreshFiles()">↻ Refresh</button>
|
|
<button class="action-btn danger" onclick="bulkDeleteFiles()">🗑 Bulk Delete</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th><input type="checkbox" id="select-all-files" onchange="toggleSelectAll()"></th>
|
|
<th>Name</th>
|
|
<th>Hash</th>
|
|
<th>Size</th>
|
|
<th>Type</th>
|
|
<th>Access</th>
|
|
<th>Owner</th>
|
|
<th>Reports</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="files-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Reports Section -->
|
|
<div id="reports-section" class="admin-section">
|
|
<h2>Content Reports</h2>
|
|
|
|
<div class="admin-controls">
|
|
<select id="report-status-filter">
|
|
<option value="">All Reports</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="resolved">Resolved</option>
|
|
<option value="dismissed">Dismissed</option>
|
|
</select>
|
|
<button class="action-btn" onclick="refreshReports()">↻ Refresh</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>File</th>
|
|
<th>Reporter</th>
|
|
<th>Reason</th>
|
|
<th>Status</th>
|
|
<th>Date</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="reports-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cleanup Section -->
|
|
<div id="cleanup-section" class="admin-section">
|
|
<h2>System Cleanup</h2>
|
|
|
|
<div class="admin-form">
|
|
<h3>Cleanup Operations</h3>
|
|
|
|
<div class="form-group">
|
|
<label>Operation Type:</label>
|
|
<select id="cleanup-operation">
|
|
<option value="old_files">Remove Old Files</option>
|
|
<option value="orphaned_chunks">Clean Orphaned Chunks</option>
|
|
<option value="inactive_users">Remove Inactive Users</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Max Age / Days:</label>
|
|
<input type="text" id="cleanup-age" placeholder="e.g., 90d or 365 (days)">
|
|
</div>
|
|
|
|
<button class="action-btn danger" onclick="executeCleanup()">🧹 Execute Cleanup</button>
|
|
</div>
|
|
|
|
<div id="cleanup-results" class="admin-table" style="display: none;">
|
|
<h3>Cleanup Results</h3>
|
|
<div id="cleanup-output"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Logs Section -->
|
|
<div id="logs-section" class="admin-section">
|
|
<h2>Admin Action Log</h2>
|
|
|
|
<div class="admin-controls">
|
|
<button class="action-btn" onclick="refreshLogs()">↻ Refresh</button>
|
|
<button class="action-btn" onclick="exportLogs()">📥 Export</button>
|
|
</div>
|
|
|
|
<div class="admin-table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Admin</th>
|
|
<th>Action</th>
|
|
<th>Target</th>
|
|
<th>Reason</th>
|
|
<th>Timestamp</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="logs-table">
|
|
<!-- Dynamic content -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Admin Login Form -->
|
|
<div id="admin-login" class="modal" style="position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center;">
|
|
<div class="admin-form" style="max-width: 500px; width: 90%;">
|
|
<h2>Admin Access Required</h2>
|
|
<p>Please authenticate with your admin Nostr key to access the admin dashboard.</p>
|
|
|
|
<button id="admin-nip07-login" class="login-btn">
|
|
Login with Browser Extension (NIP-07)
|
|
</button>
|
|
|
|
<div id="admin-login-status" class="status" style="display: none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast-container" class="toast-container"></div>
|
|
|
|
<!-- Ban User Modal -->
|
|
<div id="ban-modal" class="modal" style="display: none;">
|
|
<div class="modal-content">
|
|
<span class="close" onclick="hideBanModal()">×</span>
|
|
<h2>Ban User</h2>
|
|
<div class="form-group">
|
|
<label>User Public Key:</label>
|
|
<input type="text" id="ban-user-pubkey" readonly>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Reason for Ban:</label>
|
|
<textarea id="ban-reason" placeholder="Enter reason for banning this user..."></textarea>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="action-btn danger" onclick="confirmBanUser()">Ban User</button>
|
|
<button class="action-btn" onclick="hideBanModal()">Cancel</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/nostr-auth.js"></script>
|
|
<script>
|
|
let adminUser = null;
|
|
let currentAdminSection = 'overview';
|
|
|
|
// Admin authentication
|
|
async function checkAdminAuth() {
|
|
if (!window.nostrAuth || !window.nostrAuth.isAuthenticated()) {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
|
|
// Check if user is admin by trying to access admin stats
|
|
try {
|
|
const response = await fetch('/api/admin/stats', {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
adminUser = window.nostrAuth.getCurrentUser();
|
|
showAdminDashboard();
|
|
return true;
|
|
} else {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
showAdminLogin();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function showAdminLogin() {
|
|
document.getElementById('admin-login').style.display = 'block';
|
|
document.getElementById('admin-content').style.display = 'none';
|
|
}
|
|
|
|
async function showAdminDashboard() {
|
|
document.getElementById('admin-login').style.display = 'none';
|
|
document.getElementById('admin-content').style.display = 'block';
|
|
document.getElementById('admin-logout-btn').style.display = 'block';
|
|
|
|
// Set initial fallback display
|
|
document.getElementById('admin-user-info').textContent = `Admin: ${adminUser.substring(0, 8)}...`;
|
|
|
|
// Fetch admin profile information
|
|
try {
|
|
const response = await fetch(`/api/profile/${adminUser}`);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
const profile = data.profile;
|
|
const displayName = profile.display_name || profile.name || (adminUser.substring(0, 8) + '...');
|
|
|
|
if (profile.picture) {
|
|
document.getElementById('admin-user-info').innerHTML = `
|
|
<img src="${profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px; vertical-align: middle;">
|
|
Admin: ${displayName}
|
|
`;
|
|
} else {
|
|
document.getElementById('admin-user-info').textContent = `Admin: ${displayName}`;
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not fetch admin profile, using fallback');
|
|
}
|
|
|
|
loadAdminStats();
|
|
}
|
|
|
|
async function adminLogout() {
|
|
if (window.nostrAuth) {
|
|
await window.nostrAuth.logout();
|
|
adminUser = null;
|
|
showAdminLogin();
|
|
}
|
|
}
|
|
|
|
// Section navigation
|
|
function showAdminSection(section) {
|
|
currentAdminSection = section;
|
|
|
|
// Update navigation
|
|
document.querySelectorAll('.admin-nav-btn').forEach(btn => btn.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
|
|
// Show section
|
|
document.querySelectorAll('.admin-section').forEach(sec => sec.classList.remove('active'));
|
|
document.getElementById(section + '-section').classList.add('active');
|
|
|
|
// Load section data
|
|
switch (section) {
|
|
case 'overview': loadAdminStats(); break;
|
|
case 'users': loadUsers(); break;
|
|
case 'files': loadFiles(); break;
|
|
case 'reports': loadReports(); break;
|
|
case 'logs': loadLogs(); break;
|
|
}
|
|
}
|
|
|
|
// Admin data loading functions
|
|
async function loadAdminStats() {
|
|
// Load stats cards
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/stats');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load admin stats: ' + (data.error || 'Unknown error'), 'error');
|
|
} else {
|
|
const stats = data;
|
|
const statsGrid = document.getElementById('admin-stats');
|
|
statsGrid.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.total_files}</div>
|
|
<div class="stat-label">Total Files</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.total_users}</div>
|
|
<div class="stat-label">Total Users</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${formatBytes(stats.total_storage)}</div>
|
|
<div class="stat-label">Total Storage</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.banned_users}</div>
|
|
<div class="stat-label">Banned Users</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-number">${stats.pending_reports}</div>
|
|
<div class="stat-label">Pending Reports</div>
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to load admin stats: ' + error.message, 'error');
|
|
}
|
|
|
|
// Load recent uploads table
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/files?limit=20');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load recent uploads: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(data) ? data : [];
|
|
const tbody = document.getElementById('recent-uploads-table');
|
|
tbody.innerHTML = files.map(file => {
|
|
const ownerName = file.owner_profile?.display_name || file.owner_profile?.name ||
|
|
(file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System');
|
|
const ownerPic = file.owner_profile?.picture ?
|
|
`<img src="${file.owner_profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
|
|
|
|
return `
|
|
<tr>
|
|
<td>${file.name}</td>
|
|
<td>${formatBytes(file.size)}</td>
|
|
<td>${file.storage_type}</td>
|
|
<td>${ownerPic}${ownerName}</td>
|
|
<td>${new Date(file.created_at).toLocaleDateString()}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load recent uploads: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadUsers() {
|
|
const tbody = document.getElementById('users-table');
|
|
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/users');
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load users: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const users = Array.isArray(data) ? data : [];
|
|
|
|
// Render immediately with fallback display
|
|
tbody.innerHTML = users.map(user => {
|
|
const displayName = user.display_name || (user.pubkey.substring(0, 8) + '...');
|
|
|
|
return `
|
|
<tr data-user-pubkey="${user.pubkey}">
|
|
<td class="hash-short">${user.pubkey.substring(0, 16)}...</td>
|
|
<td class="user-profile-cell" data-pubkey="${user.pubkey}">${displayName}</td>
|
|
<td>${user.file_count}</td>
|
|
<td>${formatBytes(user.storage_used)}</td>
|
|
<td>${new Date(user.last_login).toLocaleDateString()}</td>
|
|
<td>
|
|
<span class="status-badge ${user.is_banned ? 'banned' : 'active'}">
|
|
${user.is_banned ? 'Banned' : 'Active'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${user.is_banned
|
|
? `<button class="action-btn" onclick="unbanUser('${user.pubkey}')">Unban</button>`
|
|
: `<button class="action-btn danger" onclick="banUser('${user.pubkey}')">Ban</button>`
|
|
}
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Fetch profiles in background
|
|
const pubkeys = users.map(user => user.pubkey);
|
|
if (pubkeys.length > 0) {
|
|
fetchUsersProfiles(pubkeys);
|
|
}
|
|
|
|
} catch (error) {
|
|
showToast('Failed to load users: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchUsersProfiles(pubkeys, retryCount = 0) {
|
|
const maxRetries = 2;
|
|
const timeout = 4000;
|
|
|
|
if (pubkeys.length === 0) return;
|
|
|
|
try {
|
|
console.log(`Fetching user profiles (attempt ${retryCount + 1}/${maxRetries})...`);
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
const promises = pubkeys.slice(0, 10).map(async pubkey => {
|
|
try {
|
|
const response = await fetch(`/api/profile/${pubkey}`, {
|
|
signal: controller.signal
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
return { pubkey, profile: data.profile };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Individual profile fetch failed, skip it
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
clearTimeout(timeoutId);
|
|
|
|
// Update UI with successful profile fetches
|
|
results.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
const { pubkey, profile } = result.value;
|
|
updateUserProfile(pubkey, profile);
|
|
}
|
|
});
|
|
|
|
// Process remaining pubkeys if we had to limit batch size
|
|
if (pubkeys.length > 10) {
|
|
setTimeout(() => {
|
|
fetchUsersProfiles(pubkeys.slice(10), retryCount);
|
|
}, 500);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`User profile fetch attempt ${retryCount + 1} failed:`, error.message);
|
|
|
|
if (retryCount < maxRetries - 1) {
|
|
const delay = Math.pow(2, retryCount) * 1000;
|
|
setTimeout(() => {
|
|
fetchUsersProfiles(pubkeys, retryCount + 1);
|
|
}, delay);
|
|
} else {
|
|
console.log('User profile fetching failed after all retries');
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateUserProfile(pubkey, profile) {
|
|
const profileCells = document.querySelectorAll(`[data-pubkey="${pubkey}"].user-profile-cell`);
|
|
const displayName = profile.display_name || profile.name || (pubkey.substring(0, 8) + '...');
|
|
const profilePic = profile.picture ?
|
|
`<img src="${profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px;">` : '';
|
|
|
|
profileCells.forEach(cell => {
|
|
cell.innerHTML = `${profilePic}${displayName}`;
|
|
});
|
|
}
|
|
|
|
async function loadFiles() {
|
|
const tbody = document.getElementById('files-table');
|
|
|
|
try {
|
|
const storageFilter = document.getElementById('file-storage-filter').value;
|
|
const accessFilter = document.getElementById('file-access-filter').value;
|
|
|
|
let url = '/api/admin/files?limit=50';
|
|
if (storageFilter) url += `&storage_type=${storageFilter}`;
|
|
if (accessFilter) url += `&access_level=${accessFilter}`;
|
|
|
|
const response = await makeAdminRequest(url);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load files: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const files = Array.isArray(data) ? data : [];
|
|
|
|
// Render immediately with fallback display
|
|
tbody.innerHTML = files.map(file => {
|
|
const ownerName = file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System';
|
|
|
|
return `
|
|
<tr data-file-hash="${file.hash}">
|
|
<td><input type="checkbox" class="file-select" value="${file.hash}"></td>
|
|
<td>${file.name}</td>
|
|
<td class="hash-short">${file.hash.substring(0, 12)}...</td>
|
|
<td>${formatBytes(file.size)}</td>
|
|
<td>${file.storage_type}</td>
|
|
<td>
|
|
<span class="status-badge ${file.access_level === 'private' ? 'banned' : 'active'}">
|
|
${file.access_level}
|
|
</span>
|
|
</td>
|
|
<td class="owner-cell" data-pubkey="${file.owner_pubkey || ''}">${ownerName}</td>
|
|
<td>${file.report_count > 0 ? `<span class="status-badge pending">${file.report_count}</span>` : '0'}</td>
|
|
<td>
|
|
<button class="action-btn danger" onclick="deleteFile('${file.hash}', '${file.name}')">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
// Fetch profiles in background for files with owners
|
|
const ownerPubkeys = files.filter(file => file.owner_pubkey).map(file => file.owner_pubkey);
|
|
if (ownerPubkeys.length > 0) {
|
|
fetchFileOwnersProfiles(ownerPubkeys);
|
|
}
|
|
|
|
} catch (error) {
|
|
showToast('Failed to load files: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function fetchFileOwnersProfiles(pubkeys, retryCount = 0) {
|
|
const maxRetries = 2;
|
|
const timeout = 4000;
|
|
|
|
if (pubkeys.length === 0) return;
|
|
|
|
try {
|
|
console.log(`Fetching file owner profiles (attempt ${retryCount + 1}/${maxRetries})...`);
|
|
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
|
|
const promises = pubkeys.slice(0, 10).map(async pubkey => {
|
|
try {
|
|
const response = await fetch(`/api/profile/${pubkey}`, {
|
|
signal: controller.signal
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
if (data.success && data.profile) {
|
|
return { pubkey, profile: data.profile };
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Individual profile fetch failed, skip it
|
|
}
|
|
return null;
|
|
});
|
|
|
|
const results = await Promise.allSettled(promises);
|
|
clearTimeout(timeoutId);
|
|
|
|
// Update UI with successful profile fetches
|
|
results.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
const { pubkey, profile } = result.value;
|
|
updateFileOwnerProfile(pubkey, profile);
|
|
}
|
|
});
|
|
|
|
// Process remaining pubkeys if we had to limit batch size
|
|
if (pubkeys.length > 10) {
|
|
setTimeout(() => {
|
|
fetchFileOwnersProfiles(pubkeys.slice(10), retryCount);
|
|
}, 500);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log(`File owner profile fetch attempt ${retryCount + 1} failed:`, error.message);
|
|
|
|
if (retryCount < maxRetries - 1) {
|
|
const delay = Math.pow(2, retryCount) * 1000;
|
|
setTimeout(() => {
|
|
fetchFileOwnersProfiles(pubkeys, retryCount + 1);
|
|
}, delay);
|
|
} else {
|
|
console.log('File owner profile fetching failed after all retries');
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateFileOwnerProfile(pubkey, profile) {
|
|
const ownerCells = document.querySelectorAll(`[data-pubkey="${pubkey}"]`);
|
|
const displayName = profile.display_name || profile.name || (pubkey.substring(0, 8) + '...');
|
|
const ownerPic = profile.picture ?
|
|
`<img src="${profile.picture}" style="width: 20px; height: 20px; border-radius: 50%; margin-right: 6px;">` : '';
|
|
|
|
ownerCells.forEach(cell => {
|
|
cell.innerHTML = `${ownerPic}${displayName}`;
|
|
});
|
|
}
|
|
|
|
async function loadReports() {
|
|
try {
|
|
const statusFilter = document.getElementById('report-status-filter').value;
|
|
let url = '/api/admin/reports?limit=50';
|
|
if (statusFilter) url += `&status=${statusFilter}`;
|
|
|
|
const response = await makeAdminRequest(url);
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || data.success === false) {
|
|
showToast('Failed to load reports: ' + (data.error || 'Unknown error'), 'error');
|
|
return;
|
|
}
|
|
|
|
const reports = Array.isArray(data) ? data : [];
|
|
const tbody = document.getElementById('reports-table');
|
|
tbody.innerHTML = reports.map(report => `
|
|
<tr>
|
|
<td>${report.id}</td>
|
|
<td>${report.file_name || 'Unknown'}</td>
|
|
<td class="hash-short">${report.reporter_pubkey.substring(0, 8)}...</td>
|
|
<td>${report.reason}</td>
|
|
<td>
|
|
<span class="status-badge ${report.status}">
|
|
${report.status}
|
|
</span>
|
|
</td>
|
|
<td>${new Date(report.created_at).toLocaleDateString()}</td>
|
|
<td>
|
|
<button class="action-btn danger" onclick="deleteReportedFile('${report.file_hash}')">Delete File</button>
|
|
<button class="action-btn" onclick="dismissReport(${report.id})">Dismiss</button>
|
|
</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load reports: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function loadLogs() {
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/logs?limit=100');
|
|
const logs = await response.json();
|
|
|
|
const tbody = document.getElementById('logs-table');
|
|
tbody.innerHTML = logs.map(log => `
|
|
<tr>
|
|
<td>${log.id}</td>
|
|
<td class="hash-short">${log.admin_pubkey.substring(0, 8)}...</td>
|
|
<td>${log.action_type}</td>
|
|
<td class="hash-short">${log.target_id ? log.target_id.substring(0, 12) + '...' : '-'}</td>
|
|
<td>${log.reason || '-'}</td>
|
|
<td>${new Date(log.timestamp).toLocaleString()}</td>
|
|
</tr>
|
|
`).join('');
|
|
} catch (error) {
|
|
showToast('Failed to load logs: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Admin actions
|
|
function banUser(pubkey) {
|
|
document.getElementById('ban-user-pubkey').value = pubkey;
|
|
document.getElementById('ban-modal').style.display = 'block';
|
|
}
|
|
|
|
function hideBanModal() {
|
|
document.getElementById('ban-modal').style.display = 'none';
|
|
document.getElementById('ban-reason').value = '';
|
|
}
|
|
|
|
async function confirmBanUser() {
|
|
const pubkey = document.getElementById('ban-user-pubkey').value;
|
|
const reason = document.getElementById('ban-reason').value;
|
|
|
|
if (!reason.trim()) {
|
|
showToast('Please provide a reason for the ban', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/ban`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: reason })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('User banned successfully', 'success');
|
|
hideBanModal();
|
|
loadUsers();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to ban user: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error banning user: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function unbanUser(pubkey) {
|
|
const reason = prompt('Reason for unbanning (optional):');
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/users/${pubkey}/unban`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason: reason || 'Admin unban' })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('User unbanned successfully', 'success');
|
|
loadUsers();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to unban user: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error unbanning user: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteFile(hash, name) {
|
|
const reason = prompt(`Reason for deleting "${name}":`);
|
|
if (!reason) return;
|
|
|
|
try {
|
|
const response = await makeAdminRequest(`/api/admin/files/${hash}`, {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ reason: reason })
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast('File deleted successfully', 'success');
|
|
loadFiles();
|
|
} else {
|
|
const error = await response.text();
|
|
showToast('Failed to delete file: ' + error, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error deleting file: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
async function executeCleanup() {
|
|
const operation = document.getElementById('cleanup-operation').value;
|
|
const maxAge = document.getElementById('cleanup-age').value;
|
|
|
|
if (!confirm(`Are you sure you want to execute cleanup operation: ${operation}?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await makeAdminRequest('/api/admin/cleanup', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
operation: operation,
|
|
max_age: maxAge
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
const resultsDiv = document.getElementById('cleanup-results');
|
|
const outputDiv = document.getElementById('cleanup-output');
|
|
|
|
outputDiv.innerHTML = `
|
|
<p><strong>Operation:</strong> ${result.operation}</p>
|
|
<p><strong>Items Deleted:</strong> ${result.result.deleted_count}</p>
|
|
<pre>${JSON.stringify(result.result, null, 2)}</pre>
|
|
`;
|
|
|
|
resultsDiv.style.display = 'block';
|
|
showToast('Cleanup completed successfully', 'success');
|
|
} else {
|
|
showToast('Cleanup failed: ' + result.message, 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Error executing cleanup: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Utility functions
|
|
async function makeAdminRequest(url, options = {}) {
|
|
const defaultOptions = {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Authorization': `Bearer ${window.nostrAuth.sessionToken}`,
|
|
'Content-Type': 'application/json',
|
|
...options.headers
|
|
}
|
|
};
|
|
|
|
return fetch(url, { ...defaultOptions, ...options });
|
|
}
|
|
|
|
function refreshUsers() { loadUsers(); }
|
|
function refreshFiles() { loadFiles(); }
|
|
function refreshReports() { loadReports(); }
|
|
function refreshLogs() { loadLogs(); }
|
|
|
|
function formatBytes(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const container = document.getElementById('toast-container');
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
|
|
container.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.remove();
|
|
}, 5000);
|
|
}
|
|
|
|
// Load branding configuration
|
|
async function loadAdminBranding() {
|
|
try {
|
|
const response = await fetch('/api/branding');
|
|
if (response.ok) {
|
|
const branding = await response.json();
|
|
|
|
// Update admin dashboard title
|
|
if (branding.site_name) {
|
|
document.getElementById('admin-site-title').textContent = `🛡️ ${branding.site_name} Admin`;
|
|
document.title = `${branding.site_name} Admin`;
|
|
}
|
|
|
|
// Add logo if configured
|
|
if (branding.logo_url) {
|
|
const title = document.getElementById('admin-site-title');
|
|
const logo = document.createElement('img');
|
|
logo.src = branding.logo_url;
|
|
logo.alt = branding.site_name || 'Logo';
|
|
logo.style.height = '28px';
|
|
logo.style.width = 'auto';
|
|
logo.style.marginRight = '10px';
|
|
logo.style.verticalAlign = 'middle';
|
|
|
|
// Replace text with logo + text
|
|
title.innerHTML = '';
|
|
title.appendChild(logo);
|
|
title.appendChild(document.createTextNode(`🛡️ ${branding.site_name || 'BitTorrent Gateway'} Admin`));
|
|
}
|
|
|
|
// Update favicon if configured
|
|
if (branding.favicon_url) {
|
|
let favicon = document.querySelector('link[rel="icon"]');
|
|
if (!favicon) {
|
|
favicon = document.createElement('link');
|
|
favicon.rel = 'icon';
|
|
document.head.appendChild(favicon);
|
|
}
|
|
favicon.href = branding.favicon_url;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log('Could not load branding configuration for admin, using defaults');
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadAdminBranding();
|
|
// Admin login event listener
|
|
document.getElementById('admin-nip07-login').addEventListener('click', async () => {
|
|
const result = await window.nostrAuth.loginNIP07();
|
|
if (result.success) {
|
|
checkAdminAuth();
|
|
} else {
|
|
document.getElementById('admin-login-status').textContent = result.message;
|
|
document.getElementById('admin-login-status').className = 'status error';
|
|
document.getElementById('admin-login-status').style.display = 'block';
|
|
}
|
|
});
|
|
|
|
// File filter change listeners
|
|
document.getElementById('file-storage-filter').addEventListener('change', loadFiles);
|
|
document.getElementById('file-access-filter').addEventListener('change', loadFiles);
|
|
document.getElementById('report-status-filter').addEventListener('change', loadReports);
|
|
|
|
// Check admin auth on load
|
|
checkAdminAuth();
|
|
});
|
|
|
|
// Close modals when clicking outside
|
|
window.addEventListener('click', (event) => {
|
|
const banModal = document.getElementById('ban-modal');
|
|
if (event.target === banModal) {
|
|
hideBanModal();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |