mirror of
https://github.com/9001/copyparty.git
synced 2026-06-22 22:12:52 -06:00
feat(上传): 增强文件上传功能和用户体验
test: 添加多个上传测试用例包括大小限制和错误处理 refactor(前端): 重构上传界面代码,增加上传进度统计和错误重试机制
This commit is contained in:
parent
154e3a5c9f
commit
e25f861c9c
|
|
@ -43,7 +43,9 @@
|
||||||
<div id="bup_bar" style="width:0%; height:100%; background:linear-gradient(90deg, #09d, #4b0); transition:width 0.1s;"></div>
|
<div id="bup_bar" style="width:0%; height:100%; background:linear-gradient(90deg, #09d, #4b0); transition:width 0.1s;"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bup_percent" style="text-align:center; margin-top:5px; font-size:0.9em;"></div>
|
<div id="bup_percent" style="text-align:center; margin-top:5px; font-size:0.9em;"></div>
|
||||||
<div id="bup_speed" style="text-align:center; margin-top:5px; font-size:0.9em; color:#888;"></div>
|
<div id="bup_stats" style="text-align:center; margin-top:5px; font-size:0.9em; color:#888;">
|
||||||
|
<span id="bup_speed"></span> | <span id="bup_eta"></span> | <span id="bup_remaining"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="bup_result" style="display:none; margin-top:10px; padding:10px; border-radius:5px;"></div>
|
<div id="bup_result" style="display:none; margin-top:10px; padding:10px; border-radius:5px;"></div>
|
||||||
<a id="bbsw" href="?b=u" rel="nofollow"><br />switch to basic browser</a>
|
<a id="bbsw" href="?b=u" rel="nofollow"><br />switch to basic browser</a>
|
||||||
|
|
@ -175,23 +177,46 @@
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(function() {
|
||||||
var MAX_RETRIES = 3,
|
var CONFIG = {
|
||||||
RETRY_DELAY = 1000,
|
MAX_RETRIES: 3,
|
||||||
progressDiv = null,
|
RETRY_DELAY_BASE: 1000,
|
||||||
progressBar = null,
|
RETRY_HTTP_CODES: [0, 408, 500, 502, 503, 504],
|
||||||
progressPercent = null,
|
SPEED_SMOOTHING_FACTOR: 0.3,
|
||||||
progressSpeed = null,
|
MIN_SPEED_SAMPLES: 3,
|
||||||
progressStatus = null,
|
PROGRESS_UPDATE_MIN_INTERVAL: 50,
|
||||||
progressFileinfo = null,
|
TIMEOUT: 300000
|
||||||
resultDiv = null,
|
};
|
||||||
form = null,
|
|
||||||
submitBtn = null,
|
var UI = {
|
||||||
fileInput = null,
|
progressDiv: null,
|
||||||
uploadStartTime = 0,
|
progressBar: null,
|
||||||
currentRetry = 0,
|
progressPercent: null,
|
||||||
currentFiles = [],
|
progressSpeed: null,
|
||||||
currentFileIndex = 0,
|
progressEta: null,
|
||||||
totalFilesSize = 0;
|
progressRemaining: null,
|
||||||
|
progressStatus: null,
|
||||||
|
progressFileinfo: null,
|
||||||
|
resultDiv: null,
|
||||||
|
form: null,
|
||||||
|
submitBtn: null,
|
||||||
|
fileInput: null
|
||||||
|
};
|
||||||
|
|
||||||
|
var uploadState = {
|
||||||
|
currentFiles: [],
|
||||||
|
currentFileIndex: 0,
|
||||||
|
totalFilesSize: 0,
|
||||||
|
isUploading: false,
|
||||||
|
shouldCancel: false
|
||||||
|
};
|
||||||
|
|
||||||
|
var progressTracker = {
|
||||||
|
lastUpdateTime: 0,
|
||||||
|
lastLoadedBytes: 0,
|
||||||
|
smoothedSpeed: 0,
|
||||||
|
speedSamples: [],
|
||||||
|
uploadStartTime: 0
|
||||||
|
};
|
||||||
|
|
||||||
function formatSize(bytes) {
|
function formatSize(bytes) {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
|
|
@ -209,191 +234,432 @@
|
||||||
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!isFinite(seconds) || seconds < 0) return '--:--';
|
||||||
|
|
||||||
|
seconds = Math.ceil(seconds);
|
||||||
|
|
||||||
|
if (seconds < 60) {
|
||||||
|
return seconds + 's';
|
||||||
|
} else if (seconds < 3600) {
|
||||||
|
var mins = Math.floor(seconds / 60);
|
||||||
|
var secs = seconds % 60;
|
||||||
|
return mins + 'm ' + secs + 's';
|
||||||
|
} else {
|
||||||
|
var hours = Math.floor(seconds / 3600);
|
||||||
|
var mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
return hours + 'h ' + mins + 'm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initProgressTracker() {
|
||||||
|
progressTracker = {
|
||||||
|
lastUpdateTime: Date.now(),
|
||||||
|
lastLoadedBytes: 0,
|
||||||
|
smoothedSpeed: 0,
|
||||||
|
speedSamples: [],
|
||||||
|
uploadStartTime: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateSpeed(loadedBytes, totalBytes) {
|
||||||
|
var now = Date.now();
|
||||||
|
var timeDiff = (now - progressTracker.lastUpdateTime) / 1000;
|
||||||
|
|
||||||
|
if (timeDiff <= 0) {
|
||||||
|
return progressTracker.smoothedSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytesDiff = loadedBytes - progressTracker.lastLoadedBytes;
|
||||||
|
var instantSpeed = bytesDiff / timeDiff;
|
||||||
|
|
||||||
|
progressTracker.speedSamples.push(instantSpeed);
|
||||||
|
if (progressTracker.speedSamples.length > 10) {
|
||||||
|
progressTracker.speedSamples.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressTracker.speedSamples.length >= CONFIG.MIN_SPEED_SAMPLES) {
|
||||||
|
var avgSpeed = progressTracker.speedSamples.reduce(function(a, b) { return a + b; }, 0) /
|
||||||
|
progressTracker.speedSamples.length;
|
||||||
|
|
||||||
|
if (progressTracker.smoothedSpeed === 0) {
|
||||||
|
progressTracker.smoothedSpeed = avgSpeed;
|
||||||
|
} else {
|
||||||
|
progressTracker.smoothedSpeed =
|
||||||
|
CONFIG.SPEED_SMOOTHING_FACTOR * avgSpeed +
|
||||||
|
(1 - CONFIG.SPEED_SMOOTHING_FACTOR) * progressTracker.smoothedSpeed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progressTracker.lastUpdateTime = now;
|
||||||
|
progressTracker.lastLoadedBytes = loadedBytes;
|
||||||
|
|
||||||
|
return progressTracker.smoothedSpeed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateETA(loadedBytes, totalBytes, speed) {
|
||||||
|
if (speed <= 0 || loadedBytes <= 0) {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
var remainingBytes = totalBytes - loadedBytes;
|
||||||
|
return remainingBytes / speed;
|
||||||
|
}
|
||||||
|
|
||||||
function showProgress(show) {
|
function showProgress(show) {
|
||||||
if (progressDiv) {
|
if (UI.progressDiv) {
|
||||||
progressDiv.style.display = show ? 'block' : 'none';
|
UI.progressDiv.style.display = show ? 'block' : 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function showResult(success, message) {
|
function showResult(success, message) {
|
||||||
if (resultDiv) {
|
if (UI.resultDiv) {
|
||||||
resultDiv.style.display = 'block';
|
UI.resultDiv.style.display = 'block';
|
||||||
resultDiv.style.background = success ? '#050' : '#500';
|
UI.resultDiv.style.background = success ? '#050' : '#500';
|
||||||
resultDiv.style.color = '#fff';
|
UI.resultDiv.style.color = '#fff';
|
||||||
resultDiv.innerHTML = message;
|
UI.resultDiv.innerHTML = message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideResult() {
|
function hideResult() {
|
||||||
if (resultDiv) {
|
if (UI.resultDiv) {
|
||||||
resultDiv.style.display = 'none';
|
UI.resultDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgress(loaded, total) {
|
function updateProgress(loaded, total, forceUpdate) {
|
||||||
if (!progressBar || !progressPercent) return;
|
if (!UI.progressBar || !UI.progressPercent) return;
|
||||||
|
|
||||||
|
var now = Date.now();
|
||||||
|
if (!forceUpdate && now - progressTracker.lastUpdateTime < CONFIG.PROGRESS_UPDATE_MIN_INTERVAL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var percent = total > 0 ? (loaded / total * 100) : 0;
|
var percent = total > 0 ? (loaded / total * 100) : 0;
|
||||||
progressBar.style.width = percent + '%';
|
UI.progressBar.style.width = percent + '%';
|
||||||
progressPercent.textContent = percent.toFixed(1) + '%';
|
UI.progressPercent.textContent = percent.toFixed(1) + '%';
|
||||||
|
|
||||||
if (uploadStartTime > 0 && loaded > 0) {
|
var speed = calculateSpeed(loaded, total);
|
||||||
var elapsed = (Date.now() - uploadStartTime) / 1000;
|
var eta = calculateETA(loaded, total, speed);
|
||||||
var speed = loaded / elapsed;
|
|
||||||
if (progressSpeed) {
|
if (UI.progressSpeed) {
|
||||||
progressSpeed.textContent = formatSpeed(speed);
|
UI.progressSpeed.textContent = formatSpeed(speed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (UI.progressEta) {
|
||||||
|
UI.progressEta.textContent = 'ETA: ' + formatDuration(eta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatus(text) {
|
function setStatus(text) {
|
||||||
if (progressStatus) {
|
if (UI.progressStatus) {
|
||||||
progressStatus.textContent = text;
|
UI.progressStatus.textContent = text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFileinfo(text) {
|
function setFileinfo(text) {
|
||||||
if (progressFileinfo) {
|
if (UI.progressFileinfo) {
|
||||||
progressFileinfo.textContent = text;
|
UI.progressFileinfo.textContent = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRemainingFiles() {
|
||||||
|
if (UI.progressRemaining) {
|
||||||
|
var remaining = uploadState.currentFiles.length - uploadState.currentFileIndex - 1;
|
||||||
|
UI.progressRemaining.textContent =
|
||||||
|
'File ' + (uploadState.currentFileIndex + 1) +
|
||||||
|
'/' + uploadState.currentFiles.length +
|
||||||
|
(remaining > 0 ? ' (' + remaining + ' remaining)' : '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setFormEnabled(enabled) {
|
function setFormEnabled(enabled) {
|
||||||
if (submitBtn) {
|
if (UI.submitBtn) {
|
||||||
submitBtn.disabled = !enabled;
|
UI.submitBtn.disabled = !enabled;
|
||||||
|
UI.submitBtn.value = enabled ? 'start upload' : 'uploading...';
|
||||||
}
|
}
|
||||||
if (fileInput) {
|
if (UI.fileInput) {
|
||||||
fileInput.disabled = !enabled;
|
UI.fileInput.disabled = !enabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRetryableError(status, responseText) {
|
||||||
|
if (CONFIG.RETRY_HTTP_CODES.indexOf(status) !== -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status >= 500 && status < 600) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 200 && responseText) {
|
||||||
|
var lowerText = responseText.toLowerCase();
|
||||||
|
if (lowerText.indexOf('error') !== -1 ||
|
||||||
|
lowerText.indexOf('timeout') !== -1 ||
|
||||||
|
lowerText.indexOf('failed') !== -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldRetryImmediately(status) {
|
||||||
|
return status === 0 || status === 408 || status === 503;
|
||||||
|
}
|
||||||
|
|
||||||
function uploadFile(file, formAction, callback) {
|
function uploadFile(file, formAction, callback) {
|
||||||
var xhr = new XMLHttpRequest();
|
var currentRetry = 0;
|
||||||
var formData = new FormData();
|
var isAborted = false;
|
||||||
|
var currentXhr = null;
|
||||||
|
|
||||||
formData.append('act', 'bput');
|
function createFormData() {
|
||||||
formData.append('f', file);
|
var formData = new FormData();
|
||||||
|
formData.append('act', 'bput');
|
||||||
uploadStartTime = Date.now();
|
formData.append('f', file);
|
||||||
currentRetry = 0;
|
return formData;
|
||||||
|
}
|
||||||
setStatus('Uploading: ' + file.name);
|
|
||||||
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES);
|
|
||||||
updateProgress(0, file.size);
|
|
||||||
|
|
||||||
function doUpload() {
|
function doUpload() {
|
||||||
xhr.open('POST', formAction, true);
|
if (isAborted || uploadState.shouldCancel) {
|
||||||
|
callback(new Error('Upload cancelled'), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
currentXhr = xhr;
|
||||||
|
var formData = createFormData();
|
||||||
|
|
||||||
|
initProgressTracker();
|
||||||
|
|
||||||
|
setStatus('Uploading: ' + file.name);
|
||||||
|
setFileinfo(
|
||||||
|
'Size: ' + formatSize(file.size) +
|
||||||
|
' | Retry: ' + currentRetry + '/' + CONFIG.MAX_RETRIES
|
||||||
|
);
|
||||||
|
updateProgress(0, file.size, true);
|
||||||
|
updateRemainingFiles();
|
||||||
|
|
||||||
xhr.upload.onprogress = function(e) {
|
xhr.upload.onprogress = function(e) {
|
||||||
if (e.lengthComputable) {
|
if (e.lengthComputable && !isAborted) {
|
||||||
updateProgress(e.loaded, e.total);
|
updateProgress(e.loaded, e.total);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onload = function() {
|
xhr.onload = function() {
|
||||||
if (xhr.status === 200) {
|
if (isAborted) return;
|
||||||
var responseText = xhr.responseText || '';
|
|
||||||
if (responseText.indexOf('error') === -1 && responseText.indexOf('ERR') === -1) {
|
var status = xhr.status;
|
||||||
|
var responseText = xhr.responseText || '';
|
||||||
|
|
||||||
|
if (status === 200) {
|
||||||
|
var lowerText = responseText.toLowerCase();
|
||||||
|
if (lowerText.indexOf('error') === -1 &&
|
||||||
|
lowerText.indexOf('ERR') === -1 &&
|
||||||
|
lowerText.indexOf('failed') === -1) {
|
||||||
|
updateProgress(file.size, file.size, true);
|
||||||
callback(null, xhr);
|
callback(null, xhr);
|
||||||
} else {
|
return;
|
||||||
callback(new Error('Server error: ' + responseText), xhr);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRetryableError(status, responseText) && currentRetry < CONFIG.MAX_RETRIES) {
|
||||||
|
currentRetry++;
|
||||||
|
var delay = shouldRetryImmediately(status) ? 0 : CONFIG.RETRY_DELAY_BASE * currentRetry;
|
||||||
|
|
||||||
|
setFileinfo(
|
||||||
|
'Size: ' + formatSize(file.size) +
|
||||||
|
' | Retry: ' + currentRetry + '/' + CONFIG.MAX_RETRIES +
|
||||||
|
' - ' + (delay > 0 ? 'retrying in ' + delay + 'ms...' : 'retrying now...')
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Retry ' + currentRetry + ' for file: ' + file.name + ', status: ' + status);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
doUpload();
|
||||||
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
callback(new Error('HTTP error: ' + xhr.status), xhr);
|
var errorMsg;
|
||||||
|
if (status === 0) {
|
||||||
|
errorMsg = 'Network error (connection lost)';
|
||||||
|
} else if (status === 403) {
|
||||||
|
errorMsg = 'Access denied (no permission to upload)';
|
||||||
|
} else if (status === 404) {
|
||||||
|
errorMsg = 'Target folder not found';
|
||||||
|
} else if (status >= 500) {
|
||||||
|
errorMsg = 'Server error (' + status + ')';
|
||||||
|
} else {
|
||||||
|
errorMsg = 'Upload failed (HTTP ' + status + ')';
|
||||||
|
if (responseText && responseText.length < 200) {
|
||||||
|
errorMsg += ': ' + responseText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback(new Error(errorMsg), xhr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onerror = function() {
|
xhr.onerror = function() {
|
||||||
if (currentRetry < MAX_RETRIES) {
|
if (isAborted) return;
|
||||||
|
|
||||||
|
console.log('XHR error for file: ' + file.name);
|
||||||
|
|
||||||
|
if (currentRetry < CONFIG.MAX_RETRIES) {
|
||||||
currentRetry++;
|
currentRetry++;
|
||||||
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES + ' - retrying...');
|
var delay = CONFIG.RETRY_DELAY_BASE * currentRetry;
|
||||||
setTimeout(doUpload, RETRY_DELAY * currentRetry);
|
|
||||||
|
setFileinfo(
|
||||||
|
'Size: ' + formatSize(file.size) +
|
||||||
|
' | Retry: ' + currentRetry + '/' + CONFIG.MAX_RETRIES +
|
||||||
|
' - network error, retrying in ' + delay + 'ms...'
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
doUpload();
|
||||||
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
callback(new Error('Network error after ' + MAX_RETRIES + ' retries'), xhr);
|
callback(new Error('Network error after ' + CONFIG.MAX_RETRIES + ' retries'), xhr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.ontimeout = function() {
|
xhr.ontimeout = function() {
|
||||||
if (currentRetry < MAX_RETRIES) {
|
if (isAborted) return;
|
||||||
|
|
||||||
|
console.log('XHR timeout for file: ' + file.name);
|
||||||
|
|
||||||
|
if (currentRetry < CONFIG.MAX_RETRIES) {
|
||||||
currentRetry++;
|
currentRetry++;
|
||||||
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES + ' - timeout, retrying...');
|
var delay = CONFIG.RETRY_DELAY_BASE * currentRetry;
|
||||||
setTimeout(doUpload, RETRY_DELAY * currentRetry);
|
|
||||||
|
setFileinfo(
|
||||||
|
'Size: ' + formatSize(file.size) +
|
||||||
|
' | Retry: ' + currentRetry + '/' + CONFIG.MAX_RETRIES +
|
||||||
|
' - timeout, retrying in ' + delay + 'ms...'
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
doUpload();
|
||||||
|
}, delay);
|
||||||
} else {
|
} else {
|
||||||
callback(new Error('Timeout after ' + MAX_RETRIES + ' retries'), xhr);
|
callback(new Error('Timeout after ' + CONFIG.MAX_RETRIES + ' retries'), xhr);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.timeout = 300000;
|
xhr.open('POST', formAction, true);
|
||||||
|
xhr.timeout = CONFIG.TIMEOUT;
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
doUpload();
|
doUpload();
|
||||||
|
|
||||||
|
return {
|
||||||
|
abort: function() {
|
||||||
|
isAborted = true;
|
||||||
|
if (currentXhr) {
|
||||||
|
currentXhr.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadNextFile(formAction) {
|
function uploadNextFile(formAction) {
|
||||||
if (currentFileIndex >= currentFiles.length) {
|
if (uploadState.shouldCancel) {
|
||||||
showProgress(false);
|
showProgress(false);
|
||||||
showResult(true, 'All files uploaded successfully! (' + currentFiles.length + ' file(s), ' + formatSize(totalFilesSize) + ')');
|
showResult(false, 'Upload cancelled by user');
|
||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
|
uploadState.isUploading = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var file = currentFiles[currentFileIndex];
|
if (uploadState.currentFileIndex >= uploadState.currentFiles.length) {
|
||||||
|
showProgress(false);
|
||||||
|
showResult(
|
||||||
|
true,
|
||||||
|
'All files uploaded successfully! (' +
|
||||||
|
uploadState.currentFiles.length + ' file(s), ' +
|
||||||
|
formatSize(uploadState.totalFilesSize) + ')'
|
||||||
|
);
|
||||||
|
setFormEnabled(true);
|
||||||
|
uploadState.isUploading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = uploadState.currentFiles[uploadState.currentFileIndex];
|
||||||
|
updateRemainingFiles();
|
||||||
|
|
||||||
uploadFile(file, formAction, function(err, xhr) {
|
uploadFile(file, formAction, function(err, xhr) {
|
||||||
if (err) {
|
if (err) {
|
||||||
showProgress(false);
|
showProgress(false);
|
||||||
showResult(false, 'Upload failed for "' + file.name + '": ' + err.message);
|
showResult(false, 'Upload failed for "' + file.name + '": ' + err.message);
|
||||||
setFormEnabled(true);
|
setFormEnabled(true);
|
||||||
|
uploadState.isUploading = false;
|
||||||
} else {
|
} else {
|
||||||
currentFileIndex++;
|
uploadState.currentFileIndex++;
|
||||||
uploadNextFile(formAction);
|
uploadNextFile(formAction);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initBupUpload() {
|
function initUIElements() {
|
||||||
form = document.getElementById('bup_form');
|
UI.form = document.getElementById('bup_form');
|
||||||
submitBtn = document.getElementById('bup_submit');
|
UI.submitBtn = document.getElementById('bup_submit');
|
||||||
fileInput = document.getElementById('bup_files');
|
UI.fileInput = document.getElementById('bup_files');
|
||||||
progressDiv = document.getElementById('bup_progress');
|
UI.progressDiv = document.getElementById('bup_progress');
|
||||||
progressBar = document.getElementById('bup_bar');
|
UI.progressBar = document.getElementById('bup_bar');
|
||||||
progressPercent = document.getElementById('bup_percent');
|
UI.progressPercent = document.getElementById('bup_percent');
|
||||||
progressSpeed = document.getElementById('bup_speed');
|
UI.progressSpeed = document.getElementById('bup_speed');
|
||||||
progressStatus = document.getElementById('bup_status');
|
UI.progressEta = document.getElementById('bup_eta');
|
||||||
progressFileinfo = document.getElementById('bup_fileinfo');
|
UI.progressRemaining = document.getElementById('bup_remaining');
|
||||||
resultDiv = document.getElementById('bup_result');
|
UI.progressStatus = document.getElementById('bup_status');
|
||||||
|
UI.progressFileinfo = document.getElementById('bup_fileinfo');
|
||||||
|
UI.resultDiv = document.getElementById('bup_result');
|
||||||
|
}
|
||||||
|
|
||||||
if (!form || !submitBtn || !fileInput) {
|
function initBupUpload() {
|
||||||
|
initUIElements();
|
||||||
|
|
||||||
|
if (!UI.form || !UI.submitBtn || !UI.fileInput) {
|
||||||
console.log('Basic upload form not found');
|
console.log('Basic upload form not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
form.addEventListener('submit', function(e) {
|
UI.form.addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!fileInput.files || fileInput.files.length === 0) {
|
if (!UI.fileInput.files || UI.fileInput.files.length === 0) {
|
||||||
showResult(false, 'Please select at least one file to upload.');
|
showResult(false, 'Please select at least one file to upload.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (uploadState.isUploading) {
|
||||||
|
console.log('Upload already in progress');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadState.isUploading = true;
|
||||||
|
uploadState.shouldCancel = false;
|
||||||
|
|
||||||
hideResult();
|
hideResult();
|
||||||
showProgress(true);
|
showProgress(true);
|
||||||
setFormEnabled(false);
|
setFormEnabled(false);
|
||||||
|
|
||||||
currentFiles = Array.from(fileInput.files);
|
uploadState.currentFiles = Array.from(UI.fileInput.files);
|
||||||
currentFileIndex = 0;
|
uploadState.currentFileIndex = 0;
|
||||||
totalFilesSize = 0;
|
uploadState.totalFilesSize = 0;
|
||||||
|
|
||||||
for (var i = 0; i < currentFiles.length; i++) {
|
for (var i = 0; i < uploadState.currentFiles.length; i++) {
|
||||||
totalFilesSize += currentFiles[i].size;
|
uploadState.totalFilesSize += uploadState.currentFiles[i].size;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatus('Preparing upload...');
|
setStatus('Preparing upload...');
|
||||||
setFileinfo('Total: ' + currentFiles.length + ' file(s), ' + formatSize(totalFilesSize));
|
setFileinfo(
|
||||||
|
'Total: ' + uploadState.currentFiles.length + ' file(s), ' +
|
||||||
|
formatSize(uploadState.totalFilesSize)
|
||||||
|
);
|
||||||
|
|
||||||
var formAction = form.action || window.location.href;
|
var formAction = UI.form.action || window.location.href;
|
||||||
uploadNextFile(formAction);
|
|
||||||
|
setTimeout(function() {
|
||||||
|
uploadNextFile(formAction);
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,193 @@ class TestUpload(unittest.TestCase):
|
||||||
|
|
||||||
self.conn.shutdown()
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_partial_file_cleanup_on_size_limit(self):
|
||||||
|
"""Test that partial files are cleaned up when upload exceeds size limit"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::a"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"], smax=50)
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
large_content = b"A" * 100
|
||||||
|
files = [("large.txt", large_content)]
|
||||||
|
h, body = self.bup("up", files)
|
||||||
|
|
||||||
|
partial_files = [f for f in os.listdir("up") if "PARTIAL" in f]
|
||||||
|
self.assertEqual(len(partial_files), 0, "Partial files should be cleaned up on failure")
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists("up/large.txt"))
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_json_error_response(self):
|
||||||
|
"""Test that upload failures return proper JSON error response"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["ro/::r"]
|
||||||
|
os.makedirs("ro")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"])
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
files = [("test.txt", "This should fail")]
|
||||||
|
h, body = self.bup("ro?j", files)
|
||||||
|
|
||||||
|
self.assertIn("HTTP/1.1 403", h)
|
||||||
|
|
||||||
|
self.assertFalse(os.path.exists("ro/test.txt"))
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_reservation_file_creation(self):
|
||||||
|
"""Test that reservation files are created before upload"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::a"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"])
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
files = [("test.txt", "Content")]
|
||||||
|
h, body = self.bup("up", files)
|
||||||
|
|
||||||
|
self.assertIn("HTTP/1.1 201", h)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists("up/test.txt"))
|
||||||
|
with open("up/test.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), "Content")
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_multiple_files_atomicity(self):
|
||||||
|
"""Test that multi-file upload handles each file properly"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::a"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"])
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
files = [
|
||||||
|
("file1.txt", "Content 1"),
|
||||||
|
("file2.txt", "Content 2"),
|
||||||
|
]
|
||||||
|
h, body = self.bup("up?j", files)
|
||||||
|
|
||||||
|
self.assertIn("HTTP/1.1 201", h)
|
||||||
|
|
||||||
|
response = json.loads(body)
|
||||||
|
self.assertEqual(response["status"], "OK")
|
||||||
|
self.assertEqual(len(response["files"]), 2)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists("up/file1.txt"))
|
||||||
|
self.assertTrue(os.path.exists("up/file2.txt"))
|
||||||
|
|
||||||
|
with open("up/file1.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), "Content 1")
|
||||||
|
with open("up/file2.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), "Content 2")
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_empty_file(self):
|
||||||
|
"""Test uploading an empty file"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::a"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"])
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
files = [("empty.txt", "")]
|
||||||
|
h, body = self.bup("up?j", files)
|
||||||
|
|
||||||
|
self.assertIn("HTTP/1.1 201", h)
|
||||||
|
|
||||||
|
response = json.loads(body)
|
||||||
|
self.assertEqual(response["status"], "OK")
|
||||||
|
self.assertEqual(response["sz"], 0)
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists("up/empty.txt"))
|
||||||
|
with open("up/empty.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), "")
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_with_hash_verification(self):
|
||||||
|
"""Test upload with hash verification enabled"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::a"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"], bup_ck="sha256")
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
content = "Test content for hash verification"
|
||||||
|
files = [("hash_test.txt", content)]
|
||||||
|
h, body = self.bup("up?j", files)
|
||||||
|
|
||||||
|
self.assertIn("HTTP/1.1 201", h)
|
||||||
|
|
||||||
|
response = json.loads(body)
|
||||||
|
self.assertEqual(response["status"], "OK")
|
||||||
|
self.assertIn("sha256", response["files"][0])
|
||||||
|
self.assertIn("sha_b64", response["files"][0])
|
||||||
|
|
||||||
|
self.assertTrue(os.path.exists("up/hash_test.txt"))
|
||||||
|
with open("up/hash_test.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), content)
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
|
def test_upload_overwrite_protection_without_permission(self):
|
||||||
|
"""Test that overwrite is prevented without delete permission"""
|
||||||
|
td = os.path.join(self.td, "vfs")
|
||||||
|
os.mkdir(td)
|
||||||
|
os.chdir(td)
|
||||||
|
|
||||||
|
vcfg = ["up/::w"]
|
||||||
|
os.makedirs("up")
|
||||||
|
|
||||||
|
with open("up/existing.txt", "w") as f:
|
||||||
|
f.write("original content")
|
||||||
|
|
||||||
|
self.args = Cfg(v=vcfg, a=["o:o", "x:x"])
|
||||||
|
self.asrv = AuthSrv(self.args, self.log)
|
||||||
|
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
|
||||||
|
|
||||||
|
files = [("existing.txt", "new content")]
|
||||||
|
h, body = self.bup("up", files)
|
||||||
|
|
||||||
|
with open("up/existing.txt", "r") as f:
|
||||||
|
self.assertEqual(f.read(), "original content")
|
||||||
|
|
||||||
|
self.conn.shutdown()
|
||||||
|
|
||||||
def bup(self, url, files):
|
def bup(self, url, files):
|
||||||
"""
|
"""
|
||||||
Perform a basic upload (bput) request.
|
Perform a basic upload (bput) request.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue