"use strict"; // Dragon Wind Chrome Extension — Popup Script let config = { serverUrl: 'http://localhost:3000', username: 'Admin', password: '' }; let authToken = null; let uploadMode = 'http'; let selectedFiles = []; let folderList = []; let connected = false; // ==================== INIT ==================== (async function init() { const stored = await chrome.storage.local.get(['config', 'token', 'mode']); if (stored.config) config = { ...config, ...stored.config }; if (stored.token && stored.token !== null) authToken = stored.token; if (stored.mode) uploadMode = stored.mode; // Default to HTTP if no mode saved if (!stored.mode) uploadMode = 'http'; document.getElementById('cfg-server').value = config.serverUrl; document.getElementById('cfg-user').value = config.username; document.getElementById('conn-server').textContent = config.serverUrl.replace(/https?:\/\//, ''); setMode(uploadMode, false); // If we have a saved token, try using it; otherwise open settings if (authToken) { await tryConnect(); } else { // No saved session — open settings panel so user can enter password document.getElementById('settings-panel').classList.add('open'); setConnStatus('grey', 'Enter credentials in settings below'); } })(); // ==================== EVENT LISTENERS ==================== document.getElementById('settings-toggle').addEventListener('click', () => { document.getElementById('settings-panel').classList.toggle('open'); }); document.getElementById('save-btn').addEventListener('click', saveSettings); document.getElementById('upload-btn').addEventListener('click', startUpload); document.getElementById('btn-http').addEventListener('click', () => setMode('http')); document.getElementById('btn-udp').addEventListener('click', () => setMode('udp')); const dropZone = document.getElementById('drop-zone'); dropZone.addEventListener('click', () => document.getElementById('file-input').click()); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('over'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('over')); dropZone.addEventListener('drop', onDrop); document.getElementById('file-input').addEventListener('change', onFileInputChange); // ==================== CONNECTION ==================== async function tryConnect() { setConnStatus('grey', 'Connecting…'); if (!authToken) { if (!config.username || !config.password) { setConnStatus('red', 'Set credentials in settings'); return; } await login(); return; } try { const r = await apiFetch('GET', '/api/health'); if (r.status === 'ok') { setConnStatus('green', `Connected — ${r.s3Configured ? 'S3 ✓' : 'S3 not configured'}`); connected = true; await loadFolders(); } else { authToken = null; await chrome.storage.local.remove('token'); await login(); } } catch (e) { setConnStatus('red', `Cannot reach server`); } } async function login() { try { const r = await apiFetch('POST', '/api/login', { username: config.username, password: config.password }); if (r.success) { authToken = r.token; await chrome.storage.local.set({ token: authToken }); setConnStatus('green', `Connected as ${r.user}`); connected = true; await loadFolders(); } else { setConnStatus('red', r.error || 'Auth failed'); showSettingsStatus(`❌ ${r.error || 'Auth failed'}`, 'error'); } } catch (e) { setConnStatus('red', 'Connection failed'); showSettingsStatus(`❌ ${e.message || 'Connection failed'}`, 'error'); } } function setConnStatus(color, text) { const dot = document.getElementById('conn-dot'); dot.className = `conn-dot ${color}`; document.getElementById('conn-label').textContent = text; } // ==================== SETTINGS ==================== async function saveSettings() { const serverUrl = document.getElementById('cfg-server').value.trim().replace(/\/$/, ''); const username = document.getElementById('cfg-user').value.trim(); const password = document.getElementById('cfg-pass').value; if (!serverUrl) { showSettingsStatus('Server URL required', 'error'); return; } if (!username) { showSettingsStatus('Username required', 'error'); return; } if (!password) { showSettingsStatus('Password required', 'error'); return; } config = { serverUrl, username, password }; authToken = null; connected = false; // Clear old token and save new config (don't save password to storage for security) await chrome.storage.local.remove('token'); await chrome.storage.local.set({ config: { serverUrl, username } }); document.getElementById('conn-server').textContent = serverUrl.replace(/https?:\/\//, ''); showSettingsStatus('Saved — connecting…', 'loading'); try { await login(); if (connected) { showSettingsStatus('✅ Connected successfully!', 'success'); } } catch (e) { showSettingsStatus(`❌ ${e.message || 'Unknown error'}`, 'error'); setConnStatus('red', e.message || 'Connection failed'); } } function showSettingsStatus(msg, type) { const el = document.getElementById('settings-status'); el.className = `status-bar ${type}`; el.textContent = msg; } // ==================== FOLDERS ==================== async function loadFolders() { try { const d = await apiFetch('GET', '/api/folders'); folderList = []; flattenTree(d.tree || [], '', folderList); const sel = document.getElementById('folder-select'); const current = sel.value; sel.innerHTML = ''; folderList.forEach(f => { const opt = document.createElement('option'); opt.value = f.path; opt.textContent = f.display; sel.appendChild(opt); }); if (current) sel.value = current; } catch (_) {} } function flattenTree(nodes, prefix, out) { nodes.forEach(n => { const path = prefix ? `${prefix}/${n.name}` : n.name; const display = '\u00a0'.repeat(prefix.split('/').filter(Boolean).length * 2) + (prefix ? '↳ ' : '') + n.name; out.push({ path, display }); if (n.children.length) flattenTree(n.children, path, out); }); } // ==================== MODE ==================== function setMode(mode, save = true) { uploadMode = mode; document.getElementById('btn-http').className = `mode-btn${mode === 'http' ? ' active-http' : ''}`; document.getElementById('btn-udp').className = `mode-btn${mode === 'udp' ? ' active-udp' : ''}`; const btn = document.getElementById('upload-btn'); if (mode === 'http') { btn.className = 'upload-btn http'; btn.textContent = 'Upload Files'; } else { btn.className = 'upload-btn'; btn.textContent = '⚡ UDP Upload'; } if (save) chrome.storage.local.set({ mode }); } // ==================== FILES ==================== function onDrop(e) { e.preventDefault(); document.getElementById('drop-zone').classList.remove('over'); addFiles(Array.from(e.dataTransfer.files)); } function onFileInputChange(e) { addFiles(Array.from(e.target.files)); e.target.value = ''; } function addFiles(files) { files.forEach(f => { if (!selectedFiles.find(x => x.name === f.name && x.size === f.size)) { selectedFiles.push({ file: f, name: f.name, size: f.size, status: 'pending' }); } }); renderFileList(); updateBtn(); } function renderFileList() { const list = document.getElementById('file-list'); list.innerHTML = ''; selectedFiles.forEach((item, i) => { const el = document.createElement('div'); el.className = 'file-item'; el.innerHTML = ` ${getIcon(item.name)}
${esc(item.name)}
${fmtSize(item.size)}
${item.status} `; list.appendChild(el); }); } // Delegated listener for remove buttons (avoids inline onclick — CSP compliant) document.getElementById('file-list').addEventListener('click', (e) => { const btn = e.target.closest('.fi-rm'); if (btn) removeFile(parseInt(btn.dataset.idx, 10)); }); function removeFile(i) { selectedFiles.splice(i, 1); renderFileList(); updateBtn(); } function updateBtn() { const n = selectedFiles.filter(f => f.status === 'pending').length; const btn = document.getElementById('upload-btn'); btn.disabled = n === 0 || !connected; if (uploadMode === 'udp') btn.textContent = n > 0 ? `⚡ UDP Upload (${n})` : '⚡ UDP Upload'; else btn.textContent = n > 0 ? `Upload ${n} File${n>1?'s':''}` : 'Upload Files'; } // ==================== UPLOAD ==================== async function startUpload() { if (!connected) { showStatus('Not connected to server', 'error'); return; } // If UDP mode selected but relay not configured, fall back to HTTP silently if (uploadMode === 'udp') { try { const health = await apiFetch('GET', '/api/health'); if (!health.relayConfigured) { showStatus('⚠️ UDP relay not configured — switching to HTTP', 'loading'); setMode('http'); await new Promise(r => setTimeout(r, 1000)); } } catch (_) {} } const pending = selectedFiles.filter(f => f.status === 'pending'); document.getElementById('upload-btn').disabled = true; const prefix = document.getElementById('folder-select').value; let done = 0, failed = 0; for (const item of pending) { const idx = selectedFiles.indexOf(item); setFileStatus(idx, 'uploading', 'Uploading…'); document.getElementById(`fp-${idx}`).style.display = 'block'; try { if (uploadMode === 'http') await uploadHTTP(item, idx, prefix); else await uploadUDP(item, idx, prefix); setFileStatus(idx, 'done', '✓'); item.status = 'done'; done++; } catch (e) { setFileStatus(idx, 'error', '✗'); item.status = 'error'; failed++; console.error(`Upload failed for ${item.name}:`, e); } } const total = done + failed; if (failed === 0) showStatus(`✅ ${done} file${done>1?'s':''} uploaded`, 'success'); else showStatus(`${done}/${total} uploaded, ${failed} failed`, failed === total ? 'error' : 'loading'); updateBtn(); } async function uploadHTTP(item, idx, prefix) { const presigned = await apiFetch('POST', '/api/presigned', { filename: item.name, prefix, contentType: item.file.type || 'application/octet-stream' }); if (!presigned.success) throw new Error(presigned.error); // Use the content type from the server response (matches what was signed) // NOT item.file.type which may differ from what the server determined const signedContentType = presigned.contentType || item.file.type || 'application/octet-stream'; console.log(`[DW] Upload ${item.name} → ${presigned.url.substring(0, 60)}... (${signedContentType})`); await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open('PUT', presigned.url); xhr.setRequestHeader('Content-Type', signedContentType); xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const pct = Math.round((e.loaded / e.total) * 100); document.getElementById(`fpb-${idx}`).style.width = `${pct}%`; setFileStatus(idx, 'uploading', `${pct}%`); } }; xhr.onload = () => { console.log(`[DW] S3 PUT ${item.name} → ${xhr.status}`); xhr.status < 300 ? resolve() : reject(new Error(`S3 error ${xhr.status}: ${xhr.responseText?.substring(0, 200)}`)); }; xhr.onerror = () => { console.error(`[DW] S3 PUT ${item.name} network error`); reject(new Error('Network error — check console for details')); }; xhr.send(item.file); }); document.getElementById(`fpb-${idx}`).style.width = '100%'; } async function uploadUDP(item, idx, prefix) { // Create UDP session on server const sessResp = await apiFetch('POST', '/api/udp/session', { filename: item.name, size: item.size, prefix }); if (!sessResp.success) throw new Error(sessResp.error); const { sessionId, relayUrl } = sessResp; const CHUNK = 64 * 1024; const totalChunks = Math.ceil(item.size / CHUNK); // Send via chunked HTTP fallback (true UDP requires chrome.sockets which needs host app) for (let i = 0; i < totalChunks; i++) { const chunk = item.file.slice(i * CHUNK, (i + 1) * CHUNK); const resp = await fetch(`${relayUrl}/session/${sessionId}/chunk/${i}`, { method: 'POST', body: chunk, headers: { 'Content-Type': 'application/octet-stream' } }); if (!resp.ok) throw new Error(`Chunk ${i} failed`); const pct = Math.round(((i + 1) / totalChunks) * 100); document.getElementById(`fpb-${idx}`).style.width = `${pct}%`; setFileStatus(idx, 'uploading', `⚡ ${pct}%`); } await apiFetch('POST', `/api/udp/session/${sessionId}/complete`, { success: true }); document.getElementById(`fpb-${idx}`).style.width = '100%'; } // ==================== HELPERS ==================== async function apiFetch(method, path, body) { const url = config.serverUrl.replace(/\/$/, '') + path; console.log(`[DW] ${method} ${url}`); const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (authToken) opts.headers['x-auth-token'] = authToken; if (body) opts.body = JSON.stringify(body); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); opts.signal = controller.signal; try { const r = await fetch(url, opts); clearTimeout(timeout); console.log(`[DW] ${method} ${path} → ${r.status}`); // Don't treat 401 on /api/login as session expiry — it's just bad credentials if (r.status === 401 && !path.includes('/api/login')) { authToken = null; await chrome.storage.local.remove('token'); throw new Error('Session expired'); } return r.json(); } catch (e) { clearTimeout(timeout); if (e.name === 'AbortError') throw new Error('Request timed out — check server URL'); console.error(`[DW] ${method} ${path} failed:`, e); throw e; } } function showStatus(msg, type) { const el = document.getElementById('status-bar'); el.className = `status-bar ${type}`; el.textContent = msg; } function setFileStatus(i, cls, text) { const el = document.getElementById(`fs-${i}`); if (el) { el.className = `fi-status ${cls}`; el.textContent = text; } } function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>'); } function fmtSize(b) { if (b < 1024) return `${b}B`; if (b < 1048576) return `${(b/1024).toFixed(0)}KB`; if (b < 1073741824) return `${(b/1048576).toFixed(1)}MB`; return `${(b/1073741824).toFixed(2)}GB`; } function getIcon(name) { const ext = name.split('.').pop().toLowerCase(); if (['mp4','mov','mxf','mkv','avi','r3d','braw','mts','m2ts'].includes(ext)) return '🎬'; if (['mp3','wav','aac','flac','aiff','m4a'].includes(ext)) return '🎵'; if (['jpg','jpeg','png','tiff','exr','dpx','arw','cr2','dng'].includes(ext)) return '🖼️'; return '📄'; }