admin page updates and branding update
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

This commit is contained in:
Enki 2025-08-18 01:38:55 -07:00
parent 958d4e61b4
commit 03c2af56ab
6 changed files with 367 additions and 48 deletions

View File

@ -105,3 +105,25 @@ rate_limiting:
auth:
login_attempts_per_minute: 10 # Login attempts per IP per minute
burst_size: 5 # Login burst allowance per IP
# Site branding configuration
branding:
site_name: "Sovbit Gateway" # Site name shown in headers/titles
logo_url: "https://files.sovbit.host/media/44dc1c2db9c3fbd7bee9257eceb52be3cf8c40baf7b63f46e56b58a131c74f0b/7c96148e8244bfdb0294f44824267b6b08f2dcc29db8c6571d88ab8e22a1abf1.webp" # URL to your logo image (optional)
logo_width: "auto" # Logo width (e.g., "150px", "auto")
logo_height: "32px" # Logo height (e.g., "32px", "auto")
favicon_url: "" # URL to your favicon (optional)
description: "Decentralized file sharing gateway" # Site description
footer_text: "" # Footer text (optional)
support_url: "" # Support/contact URL (optional)
# Example sovbit.host configuration:
# branding:
# site_name: "sovbit.host"
# logo_url: "https://sovbit.host/logo.png"
# logo_width: "120px"
# logo_height: "30px"
# favicon_url: "https://sovbit.host/favicon.ico"
# description: "Sovereign Bitcoin hosting services"
# footer_text: "Powered by sovbit.host"
# support_url: "https://sovbit.host/support"

View File

@ -210,18 +210,7 @@ func (ah *AdminHandlers) AdminUsersHandler(w http.ResponseWriter, r *http.Reques
users = append(users, user)
}
// Fetch profile metadata for all users
pubkeys := make([]string, len(users))
for i, user := range users {
pubkeys[i] = user.Pubkey
}
profiles := ah.profileFetcher.GetBatchProfiles(pubkeys)
for i := range users {
if profile, exists := profiles[users[i].Pubkey]; exists {
users[i].Profile = profile
}
}
// Skip profile fetching - let frontend handle it asynchronously
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_users", "", "Admin viewed user list")
@ -305,24 +294,7 @@ func (ah *AdminHandlers) AdminFilesHandler(w http.ResponseWriter, r *http.Reques
files = append(files, file)
}
// Fetch profile metadata for file owners
ownerPubkeys := make([]string, 0)
for _, file := range files {
if file.OwnerPubkey != "" {
ownerPubkeys = append(ownerPubkeys, file.OwnerPubkey)
}
}
if len(ownerPubkeys) > 0 {
profiles := ah.profileFetcher.GetBatchProfiles(ownerPubkeys)
for i := range files {
if files[i].OwnerPubkey != "" {
if profile, exists := profiles[files[i].OwnerPubkey]; exists {
files[i].OwnerProfile = profile
}
}
}
}
// Skip profile fetching - let frontend handle it asynchronously
// Log admin action
ah.adminAuth.LogAdminAction(adminPubkey, "view_files", "", "Admin viewed file list")

View File

@ -2729,6 +2729,9 @@ func RegisterRoutes(r *mux.Router, cfg *config.Config, storage *storage.Backend)
// System stats endpoint (public)
r.HandleFunc("/stats", systemStatsHandler(storage, trackerInstance)).Methods("GET")
// Branding configuration endpoint (public)
r.HandleFunc("/branding", brandingHandler(cfg)).Methods("GET")
// DHT stats endpoint (public)
r.HandleFunc("/dht/stats", gateway.DHTStatsHandler).Methods("GET")
@ -3129,6 +3132,27 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func brandingHandler(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Return branding configuration
branding := map[string]string{
"site_name": cfg.Branding.SiteName,
"logo_url": cfg.Branding.LogoURL,
"logo_width": cfg.Branding.LogoWidth,
"logo_height": cfg.Branding.LogoHeight,
"favicon_url": cfg.Branding.FaviconURL,
"description": cfg.Branding.Description,
"footer_text": cfg.Branding.FooterText,
"support_url": cfg.Branding.SupportURL,
}
json.NewEncoder(w).Encode(branding)
}
}
func systemStatsHandler(storage *storage.Backend, trackerInstance *tracker.Tracker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

View File

@ -23,6 +23,7 @@ type Config struct {
Proxy ProxyConfig `yaml:"proxy"`
Admin AdminConfig `yaml:"admin"`
RateLimiting RateLimitingConfig `yaml:"rate_limiting"`
Branding BrandingConfig `yaml:"branding"`
}
// GatewayConfig configures the HTTP API gateway
@ -155,6 +156,18 @@ type AuthRateConfig struct {
BurstSize int `yaml:"burst_size"`
}
// BrandingConfig configures site branding and appearance
type BrandingConfig struct {
SiteName string `yaml:"site_name"`
LogoURL string `yaml:"logo_url"`
LogoWidth string `yaml:"logo_width"`
LogoHeight string `yaml:"logo_height"`
FaviconURL string `yaml:"favicon_url"`
Description string `yaml:"description"`
FooterText string `yaml:"footer_text"`
SupportURL string `yaml:"support_url"`
}
// LoadConfig loads configuration from a YAML file
func LoadConfig(filename string) (*Config, error) {
data, err := os.ReadFile(filename)
@ -172,6 +185,14 @@ func LoadConfig(filename string) (*Config, error) {
config.Mode = "unified"
}
// Set branding defaults
if config.Branding.SiteName == "" {
config.Branding.SiteName = "BitTorrent Gateway"
}
if config.Branding.Description == "" {
config.Branding.Description = "Decentralized file sharing gateway"
}
return &config, nil
}

View File

@ -64,6 +64,26 @@
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 {
@ -143,16 +163,12 @@
color: black;
}
.hash-short {
font-family: monospace;
font-size: 0.9rem;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🛡️ Admin Dashboard</h1>
<h1 id="admin-site-title">🛡️ Admin Dashboard</h1>
<nav>
<a href="/">← Back to Gateway</a>
<div id="admin-auth-status" class="auth-status">
@ -575,6 +591,8 @@
}
async function loadUsers() {
const tbody = document.getElementById('users-table');
try {
const response = await makeAdminRequest('/api/admin/users');
const data = await response.json();
@ -585,15 +603,15 @@
}
const users = Array.isArray(data) ? data : [];
const tbody = document.getElementById('users-table');
// Render immediately with fallback display
tbody.innerHTML = users.map(user => {
const displayName = user.profile?.display_name || user.profile?.name || user.display_name || 'Anonymous';
const profilePic = user.profile?.picture ? `<img src="${user.profile.picture}" style="width: 24px; height: 24px; border-radius: 50%; margin-right: 8px;">` : '';
const displayName = user.display_name || (user.pubkey.substring(0, 8) + '...');
return `
<tr>
<tr data-user-pubkey="${user.pubkey}">
<td class="hash-short">${user.pubkey.substring(0, 16)}...</td>
<td>${profilePic}${displayName}</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>
@ -611,12 +629,94 @@
</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;
@ -634,15 +734,13 @@
}
const files = Array.isArray(data) ? data : [];
const tbody = document.getElementById('files-table');
// Render immediately with fallback display
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;">` : '';
const ownerName = file.owner_pubkey ? file.owner_pubkey.substring(0, 8) + '...' : 'System';
return `
<tr>
<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>
@ -653,7 +751,7 @@
${file.access_level}
</span>
</td>
<td>${ownerPic}${ownerName}</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>
@ -661,11 +759,91 @@
</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;
@ -887,8 +1065,55 @@
}, 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();

View File

@ -9,7 +9,7 @@
<body>
<div class="container">
<header>
<h1>⚡ BitTorrent Gateway</h1>
<h1 id="site-title">⚡ BitTorrent Gateway</h1>
<nav>
<a href="#about" onclick="showAbout(); return false;">About</a>
<a href="#services" onclick="showServices(); return false;">Server Stats</a>
@ -1537,8 +1537,63 @@
}, 5000);
}
// Load branding configuration
async function loadBranding() {
try {
const response = await fetch('/api/branding');
if (response.ok) {
const branding = await response.json();
// Update site title
if (branding.site_name) {
document.getElementById('site-title').textContent = branding.site_name;
document.title = branding.site_name;
}
// Add logo if configured
if (branding.logo_url) {
const title = document.getElementById('site-title');
const logo = document.createElement('img');
logo.src = branding.logo_url;
logo.alt = branding.site_name || 'Logo';
logo.style.height = branding.logo_height || '32px';
logo.style.width = branding.logo_width || 'auto';
logo.style.marginRight = '10px';
logo.style.verticalAlign = 'middle';
// Replace text with logo + text or just logo
title.innerHTML = '';
title.appendChild(logo);
if (branding.site_name) {
title.appendChild(document.createTextNode(branding.site_name));
}
}
// 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;
}
// Update description in about section if configured
if (branding.description) {
// This will be used when the about section is shown
window.brandingDescription = branding.description;
}
}
} catch (error) {
console.log('Could not load branding configuration, using defaults');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadBranding();
updateAuthStatus();
// Login event listeners