The presigned URL is signed with a specific Content-Type (determined by the server's MIME map). If the browser's file.type doesn't match (common for broadcast formats like MXF, R3D, BRAW), S3 rejects the PUT with a signature mismatch. Now the extension uses presigned.contentType from the server response instead of item.file.type. Also added console logging for upload requests and detailed error messages from S3 responses on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
"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;
|
||
|
||
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 = '<option value="">Root</option>';
|
||
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 = `
|
||
<span class="fi-icon">${getIcon(item.name)}</span>
|
||
<div class="fi-info">
|
||
<div class="fi-name">${esc(item.name)}</div>
|
||
<div class="fi-size">${fmtSize(item.size)}</div>
|
||
<div class="fi-prog" id="fp-${i}" style="display:none"><div class="fi-prog-bar${uploadMode==='udp'?' udp':''}" id="fpb-${i}" style="width:0%"></div></div>
|
||
</div>
|
||
<span class="fi-status ${item.status}" id="fs-${i}">${item.status}</span>
|
||
<button class="fi-rm" data-idx="${i}">×</button>
|
||
`;
|
||
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; }
|
||
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,'<').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 '📄';
|
||
}
|