enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

692 lines
25 KiB
JavaScript

// Upload functionality and UI management
class GatewayUI {
constructor() {
this.currentUpload = null;
this.recentUploads = JSON.parse(localStorage.getItem('recentUploads') || '[]');
this.serviceStatus = {};
this.initializeElements();
this.attachEventListeners();
this.initializeTheme();
this.loadRecentUploads();
this.checkServiceStatus();
this.loadServerFiles();
// Update service status every 30 seconds
setInterval(() => this.checkServiceStatus(), 30000);
}
initializeElements() {
// Upload elements
this.uploadArea = document.getElementById('upload-area');
this.fileInput = document.getElementById('file-input');
this.uploadProgress = document.getElementById('upload-progress');
this.progressFill = document.getElementById('progress-fill');
this.progressPercent = document.getElementById('progress-percent');
this.progressSpeed = document.getElementById('progress-speed');
this.progressEta = document.getElementById('progress-eta');
this.uploadFilename = document.getElementById('upload-filename');
// Options
this.announceDht = document.getElementById('announce-dht');
this.storeBlossom = document.getElementById('store-blossom');
// Lists
this.uploadsList = document.getElementById('uploads-list');
// Toast container
this.toastContainer = document.getElementById('toast-container');
}
attachEventListeners() {
// File upload - only add if not already attached
if (!this.uploadArea.hasAttribute('data-events-attached')) {
this.uploadArea.addEventListener('click', (e) => {
// Prevent double clicks
if (e.detail === 1) {
this.fileInput.click();
}
});
this.uploadArea.setAttribute('data-events-attached', 'true');
}
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e.target.files));
// Drag and drop
this.uploadArea.addEventListener('dragover', (e) => this.handleDragOver(e));
this.uploadArea.addEventListener('dragleave', (e) => this.handleDragLeave(e));
this.uploadArea.addEventListener('drop', (e) => this.handleDrop(e));
// Prevent default drag behaviors on document
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', (e) => e.preventDefault());
}
initializeTheme() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
}
handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
this.uploadArea.classList.add('drag-over');
}
handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
this.uploadArea.classList.remove('drag-over');
}
handleDrop(e) {
e.preventDefault();
e.stopPropagation();
this.uploadArea.classList.remove('drag-over');
const files = Array.from(e.dataTransfer.files);
this.handleFileSelect(files);
}
handleFileSelect(files) {
if (files.length === 0) return;
// For now, handle one file at a time
const file = files[0];
// Validate file
const validation = this.validateFile(file);
if (!validation.valid) {
this.showToast(validation.message, 'error');
this.fileInput.value = ''; // Clear the input
return;
}
this.uploadFile(file);
}
validateFile(file) {
// Check file existence
if (!file) {
return { valid: false, message: 'No file selected' };
}
// Check file size (10GB default limit - server will enforce actual limit)
const maxSize = 10 * 1024 * 1024 * 1024; // 10GB
if (file.size > maxSize) {
return {
valid: false,
message: `File too large. Maximum size is ${this.formatBytes(maxSize)} (selected: ${this.formatBytes(file.size)})`
};
}
if (file.size === 0) {
return { valid: false, message: 'Cannot upload empty file' };
}
// Check filename
if (!file.name || file.name.trim() === '') {
return { valid: false, message: 'File must have a valid name' };
}
if (file.name.length > 255) {
return { valid: false, message: 'Filename too long (max 255 characters)' };
}
// Check for dangerous characters in filename
const dangerousChars = ['..', '/', '\\', ':', '*', '?', '"', '<', '>', '|'];
for (const char of dangerousChars) {
if (file.name.includes(char)) {
return {
valid: false,
message: `Filename cannot contain '${char}' character`
};
}
}
// Check file type (basic validation)
const allowedTypes = [
// Video
'video/mp4', 'video/avi', 'video/mkv', 'video/mov', 'video/webm',
// Audio
'audio/mp3', 'audio/wav', 'audio/flac', 'audio/m4a', 'audio/ogg',
// Images
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp',
// Documents
'application/pdf', 'text/plain', 'application/zip', 'application/x-rar-compressed',
// Archives
'application/x-7z-compressed', 'application/x-tar', 'application/gzip'
];
// If type is provided and not in allowed list, show warning but allow
if (file.type && !allowedTypes.includes(file.type) && !file.type.startsWith('application/')) {
console.warn(`Unusual file type: ${file.type}`);
}
return { valid: true };
}
async uploadFile(file) {
if (this.currentUpload) {
this.showToast('Another upload is in progress', 'warning');
return;
}
// Create FormData
const formData = new FormData();
formData.append('file', file);
// Add options
if (this.announceDht.checked) {
formData.append('announce_dht', 'true');
}
if (this.storeBlossom.checked) {
formData.append('store_blossom', 'true');
}
// Show progress
this.showUploadProgress(file.name);
try {
this.currentUpload = {
file: file,
startTime: Date.now(),
abort: new AbortController()
};
const headers = {};
if (window.nostrAuth && window.nostrAuth.sessionToken) {
headers['Authorization'] = `Bearer ${window.nostrAuth.sessionToken}`;
console.log('Upload with auth token:', window.nostrAuth.sessionToken.substring(0, 20) + '...');
} else {
console.log('Upload without auth - nostrAuth:', !!window.nostrAuth, 'sessionToken:', !!window.nostrAuth?.sessionToken);
}
const response = await fetch('/api/upload', {
method: 'POST',
headers: headers,
body: formData,
signal: this.currentUpload.abort.signal
});
if (!response.ok) {
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
}
const result = await response.json();
this.handleUploadSuccess(file, result);
} catch (error) {
if (error.name === 'AbortError') {
this.showToast('Upload cancelled', 'warning');
} else {
console.error('Upload error:', error);
// Provide user-friendly error messages
let message = 'Upload failed';
if (error.message.includes('413') || error.message.includes('too large')) {
message = 'File too large. Please choose a smaller file.';
} else if (error.message.includes('415') || error.message.includes('unsupported')) {
message = 'File type not supported. Please try a different file.';
} else if (error.message.includes('429') || error.message.includes('rate limit')) {
message = 'Upload rate limit exceeded. Please wait and try again.';
} else if (error.message.includes('401') || error.message.includes('unauthorized')) {
message = 'Please login to upload files.';
} else if (error.message.includes('403') || error.message.includes('forbidden')) {
message = 'Upload not allowed. Check your permissions.';
} else if (error.message.includes('507') || error.message.includes('storage')) {
message = 'Server storage full. Please try again later.';
} else if (error.message.includes('NetworkError') || error.message.includes('fetch')) {
message = 'Network error. Please check your connection and try again.';
} else if (error.message.includes('timeout')) {
message = 'Upload timed out. Please try again with a smaller file.';
}
this.showToast(message, 'error');
}
} finally {
this.hideUploadProgress();
this.currentUpload = null;
}
}
showUploadProgress(filename) {
this.uploadFilename.textContent = filename;
this.uploadProgress.classList.remove('hidden');
this.uploadArea.style.display = 'none';
// Start progress simulation (since we can't track real progress easily)
this.simulateProgress();
}
simulateProgress() {
let progress = 0;
const startTime = Date.now();
const updateProgress = () => {
if (!this.currentUpload) return;
// Simulate realistic progress curve
progress += (100 - progress) * 0.05;
const elapsed = (Date.now() - startTime) / 1000;
const speed = (this.currentUpload.file.size * (progress / 100)) / elapsed;
const remaining = (this.currentUpload.file.size - (this.currentUpload.file.size * (progress / 100))) / speed;
this.progressFill.style.width = `${progress}%`;
this.progressPercent.textContent = `${Math.round(progress)}%`;
this.progressSpeed.textContent = this.formatBytes(speed) + '/s';
this.progressEta.textContent = this.formatTime(remaining);
if (progress < 95 && this.currentUpload) {
setTimeout(updateProgress, 100);
}
};
updateProgress();
}
hideUploadProgress() {
this.uploadProgress.classList.add('hidden');
this.uploadArea.style.display = 'block';
this.progressFill.style.width = '0%';
this.fileInput.value = '';
}
handleUploadSuccess(file, result) {
this.showToast('File uploaded successfully!', 'success');
// Add to recent uploads
const uploadRecord = {
id: result.file_hash || result.hash,
name: file.name,
size: file.size,
hash: result.file_hash || result.hash,
torrentHash: result.torrent_hash,
magnetLink: result.magnet_link,
timestamp: Date.now(),
type: file.type,
isVideo: file.type.startsWith('video/')
};
this.recentUploads.unshift(uploadRecord);
this.recentUploads = this.recentUploads.slice(0, 10); // Keep only last 10
localStorage.setItem('recentUploads', JSON.stringify(this.recentUploads));
this.loadServerFiles();
}
async loadServerFiles() {
// Show loading state
if (this.uploadsList) {
this.uploadsList.innerHTML = '<div class="loading-state"><div class="spinner"></div>Loading files...</div>';
}
try {
const response = await fetch('/api/files');
if (response.ok) {
const data = await response.json();
if (data.files && data.files.length > 0) {
// Merge server files with local uploads, avoiding duplicates
const allFiles = [...data.files];
// Add local uploads that might not be on server yet
this.recentUploads.forEach(localFile => {
if (!allFiles.find(f => f.file_hash === localFile.hash)) {
allFiles.unshift({
file_hash: localFile.hash,
name: localFile.name,
size: localFile.size,
is_video: localFile.isVideo,
torrent_hash: localFile.torrentHash,
magnet_link: localFile.magnetLink
});
}
});
this.displayFiles(allFiles);
return;
}
}
} catch (error) {
console.log('Could not load server files, showing local only:', error);
if (this.uploadsList) {
this.uploadsList.innerHTML = '<div class="error-state">Failed to load server files. Showing local uploads only.</div>';
setTimeout(() => this.loadRecentUploads(), 2000);
return;
}
}
// Fallback to local uploads only
this.loadRecentUploads();
}
loadRecentUploads() {
if (this.recentUploads.length === 0) {
this.uploadsList.innerHTML = '<p class="empty-state">No recent uploads</p>';
return;
}
const files = this.recentUploads.map(upload => ({
file_hash: upload.hash,
name: upload.name,
size: upload.size,
is_video: upload.isVideo,
torrent_hash: upload.torrentHash,
magnet_link: upload.magnetLink
}));
this.displayFiles(files);
}
displayFiles(files) {
if (files.length === 0) {
this.uploadsList.innerHTML = '<p class="empty-state">No files uploaded</p>';
return;
}
this.uploadsList.innerHTML = files.map(file => `
<div class="upload-item" data-hash="${file.file_hash}">
<div class="upload-item-header">
<div class="upload-item-title">${this.escapeHtml(file.name)}</div>
<div class="upload-item-meta">
${this.formatBytes(file.size)} • Hash: ${file.file_hash.substring(0, 8)}...
</div>
</div>
<div class="upload-item-actions">
<button class="action-btn" onclick="gatewayUI.downloadFile('${file.file_hash}')">
⬇️ Download
</button>
<button class="action-btn" onclick="gatewayUI.getTorrent('${file.file_hash}')">
🧲 Torrent
</button>
${file.is_video ? `
<button class="action-btn" onclick="gatewayUI.playVideo('${file.file_hash}', '${this.escapeHtml(file.name)}')">
▶️ Play
</button>
` : ''}
<button class="action-btn" onclick="gatewayUI.shareFile('${file.file_hash}', '${this.escapeHtml(file.name)}')">
📋 Share
</button>
<button class="action-btn" onclick="gatewayUI.deleteFile('${file.file_hash}', '${this.escapeHtml(file.name)}')" style="background-color: var(--danger); color: white;">
🗑️ Delete
</button>
</div>
</div>
`).join('');
}
async checkServiceStatus() {
try {
// Use the stats API which provides comprehensive service information
const response = await fetch('/api/stats');
if (response.ok) {
const data = await response.json();
// Update service status based on stats data
this.updateServiceStatus('gateway', data.gateway && data.gateway.status === 'healthy');
this.updateServiceStatus('blossom', data.blossom && data.blossom.status === 'healthy');
this.updateServiceStatus('dht', data.dht && data.dht.status === 'healthy');
} else {
// If stats API fails, assume all services are down
this.updateServiceStatus('gateway', false);
this.updateServiceStatus('blossom', false);
this.updateServiceStatus('dht', false);
}
} catch (error) {
console.error('Service status check failed:', error);
// If stats API fails, assume all services are down
this.updateServiceStatus('gateway', false);
this.updateServiceStatus('blossom', false);
this.updateServiceStatus('dht', false);
}
this.updateSystemInfo();
}
updateServiceStatus(serviceName, isOnline) {
this.serviceStatus[serviceName] = isOnline;
const statusElement = document.getElementById(`${serviceName}-status`);
if (statusElement) {
statusElement.textContent = isOnline ? '🟢' : '🔴';
statusElement.className = `status-indicator ${isOnline ? 'online' : 'offline'}`;
}
}
updateSystemInfo() {
// Update system information display
const mode = Object.keys(this.serviceStatus).filter(s => this.serviceStatus[s]).length === 3
? 'unified' : 'partial';
const systemMode = document.getElementById('system-mode');
if (systemMode) systemMode.textContent = mode;
const totalStorage = document.getElementById('system-storage');
if (totalStorage) {
const totalSize = this.recentUploads.reduce((sum, upload) => sum + upload.size, 0);
totalStorage.textContent = this.formatBytes(totalSize);
}
const gatewayUploads = document.getElementById('gateway-uploads');
if (gatewayUploads) gatewayUploads.textContent = this.recentUploads.length;
}
cancelUpload() {
if (this.currentUpload) {
this.currentUpload.abort.abort();
}
}
downloadFile(hash) {
const url = `/api/download/${hash}`;
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
getTorrent(hash) {
const url = `/api/torrent/${hash}`;
const a = document.createElement('a');
a.href = url;
a.download = `${hash}.torrent`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
playVideo(hash, name) {
const url = `/player.html?hash=${hash}&name=${encodeURIComponent(name)}`;
window.open(url, '_blank');
}
shareFile(hash, name) {
const baseUrl = window.location.origin;
const shareText = `${name}\n\nDownload: ${baseUrl}/api/download/${hash}\nStream: ${baseUrl}/api/stream/${hash}/playlist.m3u8\nTorrent: ${baseUrl}/api/torrent/${hash}`;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(shareText).then(() => {
this.showToast('Share links copied to clipboard!', 'success');
});
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = shareText;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
this.showToast('Share links copied to clipboard!', 'success');
}
}
async deleteFile(hash, name) {
if (!confirm(`Are you sure you want to delete "${name}"?\n\nThis action cannot be undone.`)) {
return;
}
try {
const headers = {
'Accept': 'application/json'
};
if (window.nostrAuth && window.nostrAuth.sessionToken) {
headers['Authorization'] = `Bearer ${window.nostrAuth.sessionToken}`;
}
const response = await fetch(`/api/delete/${hash}`, {
method: 'DELETE',
headers: headers
});
if (response.ok) {
const result = await response.json();
this.showToast(`File "${name}" deleted successfully!`, 'success');
// Remove from local storage if it exists
this.recentUploads = this.recentUploads.filter(upload => upload.hash !== hash);
localStorage.setItem('recentUploads', JSON.stringify(this.recentUploads));
// Refresh the file list
this.loadServerFiles();
} else {
const error = await response.json();
this.showToast(`Failed to delete file: ${error.error?.message || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Delete error:', error);
this.showToast(`Error deleting file: ${error.message}`, 'error');
}
}
showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.textContent = message;
this.toastContainer.appendChild(toast);
// Remove toast after 3 seconds
setTimeout(() => {
toast.remove();
}, 3000);
}
// Utility functions
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(1)) + ' ' + sizes[i];
}
formatTime(seconds) {
if (!isFinite(seconds) || seconds < 0) return '--:--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
}
formatDate(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString();
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Global functions for navigation and theme
function showServices() {
hideAllSections();
document.getElementById('services-section').classList.add('active');
gatewayUI.checkServiceStatus();
}
function showAbout() {
hideAllSections();
document.getElementById('about-section').classList.add('active');
}
function hideAllSections() {
document.querySelectorAll('.section').forEach(section => {
section.classList.remove('active');
});
}
function showUpload() {
hideAllSections();
document.getElementById('upload-section').classList.add('active');
}
function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
}
function refreshDHTStats() {
gatewayUI.showToast('DHT stats refreshed', 'success');
// In a real implementation, this would fetch DHT statistics
// from a dedicated endpoint
}
function cancelUpload() {
gatewayUI.cancelUpload();
}
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
document.execCommand('copy');
gatewayUI.showToast('Copied to clipboard!', 'success');
}
// Initialize the UI when the page loads
let gatewayUI;
document.addEventListener('DOMContentLoaded', () => {
gatewayUI = new GatewayUI();
});
// Handle browser navigation
window.addEventListener('hashchange', () => {
const hash = window.location.hash.slice(1);
switch (hash) {
case 'services':
showServices();
break;
case 'about':
showAbout();
break;
case 'upload':
showUpload();
break;
case 'files':
showFiles();
break;
default:
showAbout(); // Default to About page instead of Upload
}
});