feat(上传): 添加文件上传进度显示和测试用例

为文件上传功能添加进度条、状态显示和重试机制
添加全面的上传功能测试用例,包括大文件和多文件上传测试
This commit is contained in:
mengshon1-boop 2026-04-25 19:59:52 +08:00
parent d570f04d26
commit 154e3a5c9f
2 changed files with 520 additions and 3 deletions

View file

@ -31,11 +31,21 @@
<div id="op_bup" class="opview opbox {% if not ls0 %}act{% endif %}">
<div id="u2err"></div>
<form method="post" enctype="multipart/form-data" accept-charset="utf-8" action="{{ url_suf }}">
<form id="bup_form" method="post" enctype="multipart/form-data" accept-charset="utf-8" action="{{ url_suf }}">
<input type="hidden" name="act" value="bput" />
<input type="file" name="f" multiple /><br />
<input type="submit" value="start upload">
<input type="file" id="bup_files" name="f" multiple /><br />
<input type="submit" id="bup_submit" value="start upload">
</form>
<div id="bup_progress" style="display:none; margin-top:10px;">
<div id="bup_status" style="margin-bottom:5px; font-weight:bold;"></div>
<div id="bup_fileinfo" style="margin-bottom:5px; font-size:0.9em;"></div>
<div style="width:100%; background:#333; height:20px; border-radius:10px; overflow:hidden;">
<div id="bup_bar" style="width:0%; height:100%; background:linear-gradient(90deg, #09d, #4b0); transition:width 0.1s;"></div>
</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>
<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>
</div>
@ -163,6 +173,237 @@
jsldp("J_BRW","browser");
jsldp("J_U2K","up2k");
</script>
<script>
(function() {
var MAX_RETRIES = 3,
RETRY_DELAY = 1000,
progressDiv = null,
progressBar = null,
progressPercent = null,
progressSpeed = null,
progressStatus = null,
progressFileinfo = null,
resultDiv = null,
form = null,
submitBtn = null,
fileInput = null,
uploadStartTime = 0,
currentRetry = 0,
currentFiles = [],
currentFileIndex = 0,
totalFilesSize = 0;
function formatSize(bytes) {
if (bytes === 0) return '0 B';
var k = 1024,
sizes = ['B', 'KB', 'MB', 'GB', 'TB'],
i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatSpeed(bytesPerSec) {
if (bytesPerSec === 0) return '0 B/s';
var k = 1024,
sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'],
i = Math.floor(Math.log(bytesPerSec) / Math.log(k));
return parseFloat((bytesPerSec / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function showProgress(show) {
if (progressDiv) {
progressDiv.style.display = show ? 'block' : 'none';
}
}
function showResult(success, message) {
if (resultDiv) {
resultDiv.style.display = 'block';
resultDiv.style.background = success ? '#050' : '#500';
resultDiv.style.color = '#fff';
resultDiv.innerHTML = message;
}
}
function hideResult() {
if (resultDiv) {
resultDiv.style.display = 'none';
}
}
function updateProgress(loaded, total) {
if (!progressBar || !progressPercent) return;
var percent = total > 0 ? (loaded / total * 100) : 0;
progressBar.style.width = percent + '%';
progressPercent.textContent = percent.toFixed(1) + '%';
if (uploadStartTime > 0 && loaded > 0) {
var elapsed = (Date.now() - uploadStartTime) / 1000;
var speed = loaded / elapsed;
if (progressSpeed) {
progressSpeed.textContent = formatSpeed(speed);
}
}
}
function setStatus(text) {
if (progressStatus) {
progressStatus.textContent = text;
}
}
function setFileinfo(text) {
if (progressFileinfo) {
progressFileinfo.textContent = text;
}
}
function setFormEnabled(enabled) {
if (submitBtn) {
submitBtn.disabled = !enabled;
}
if (fileInput) {
fileInput.disabled = !enabled;
}
}
function uploadFile(file, formAction, callback) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('act', 'bput');
formData.append('f', file);
uploadStartTime = Date.now();
currentRetry = 0;
setStatus('Uploading: ' + file.name);
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES);
updateProgress(0, file.size);
function doUpload() {
xhr.open('POST', formAction, true);
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
updateProgress(e.loaded, e.total);
}
};
xhr.onload = function() {
if (xhr.status === 200) {
var responseText = xhr.responseText || '';
if (responseText.indexOf('error') === -1 && responseText.indexOf('ERR') === -1) {
callback(null, xhr);
} else {
callback(new Error('Server error: ' + responseText), xhr);
}
} else {
callback(new Error('HTTP error: ' + xhr.status), xhr);
}
};
xhr.onerror = function() {
if (currentRetry < MAX_RETRIES) {
currentRetry++;
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES + ' - retrying...');
setTimeout(doUpload, RETRY_DELAY * currentRetry);
} else {
callback(new Error('Network error after ' + MAX_RETRIES + ' retries'), xhr);
}
};
xhr.ontimeout = function() {
if (currentRetry < MAX_RETRIES) {
currentRetry++;
setFileinfo('Size: ' + formatSize(file.size) + ' | Retry: ' + currentRetry + '/' + MAX_RETRIES + ' - timeout, retrying...');
setTimeout(doUpload, RETRY_DELAY * currentRetry);
} else {
callback(new Error('Timeout after ' + MAX_RETRIES + ' retries'), xhr);
}
};
xhr.timeout = 300000;
xhr.send(formData);
}
doUpload();
}
function uploadNextFile(formAction) {
if (currentFileIndex >= currentFiles.length) {
showProgress(false);
showResult(true, 'All files uploaded successfully! (' + currentFiles.length + ' file(s), ' + formatSize(totalFilesSize) + ')');
setFormEnabled(true);
return;
}
var file = currentFiles[currentFileIndex];
uploadFile(file, formAction, function(err, xhr) {
if (err) {
showProgress(false);
showResult(false, 'Upload failed for "' + file.name + '": ' + err.message);
setFormEnabled(true);
} else {
currentFileIndex++;
uploadNextFile(formAction);
}
});
}
function initBupUpload() {
form = document.getElementById('bup_form');
submitBtn = document.getElementById('bup_submit');
fileInput = document.getElementById('bup_files');
progressDiv = document.getElementById('bup_progress');
progressBar = document.getElementById('bup_bar');
progressPercent = document.getElementById('bup_percent');
progressSpeed = document.getElementById('bup_speed');
progressStatus = document.getElementById('bup_status');
progressFileinfo = document.getElementById('bup_fileinfo');
resultDiv = document.getElementById('bup_result');
if (!form || !submitBtn || !fileInput) {
console.log('Basic upload form not found');
return;
}
form.addEventListener('submit', function(e) {
e.preventDefault();
if (!fileInput.files || fileInput.files.length === 0) {
showResult(false, 'Please select at least one file to upload.');
return;
}
hideResult();
showProgress(true);
setFormEnabled(false);
currentFiles = Array.from(fileInput.files);
currentFileIndex = 0;
totalFilesSize = 0;
for (var i = 0; i < currentFiles.length; i++) {
totalFilesSize += currentFiles[i].size;
}
setStatus('Preparing upload...');
setFileinfo('Total: ' + currentFiles.length + ' file(s), ' + formatSize(totalFilesSize));
var formAction = form.action || window.location.href;
uploadNextFile(formAction);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBupUpload);
} else {
initBupUpload();
}
})();
</script>
</body>
</html>

276
tests/test_upload.py Normal file
View file

@ -0,0 +1,276 @@
#!/usr/bin/env python3
# coding: utf-8
from __future__ import print_function, unicode_literals
import json
import os
import shutil
import tempfile
import time
import unittest
from copyparty.authsrv import AuthSrv
from copyparty.httpcli import HttpCli
from tests import util as tu
from tests.util import Cfg
def hdr(query):
h = "GET /{} HTTP/1.1\r\nCookie: cppwd=o\r\nConnection: close\r\n\r\n"
return h.format(query).encode("utf-8")
def build_multipart_form(files, boundary="XD"):
"""
Build a multipart/form-data request body for testing uploads.
"""
parts = []
parts.append("--{}\r\n".format(boundary).encode("utf-8"))
parts.append('Content-Disposition: form-data; name="act"\r\n\r\n'.encode("utf-8"))
parts.append("bput\r\n".encode("utf-8"))
for filename, content in files:
parts.append("--{}\r\n".format(boundary).encode("utf-8"))
parts.append(
'Content-Disposition: form-data; name="f"; filename="{}"\r\n\r\n'.format(filename).encode("utf-8")
)
if isinstance(content, str):
parts.append(content.encode("utf-8"))
else:
parts.append(content)
parts.append("\r\n".encode("utf-8"))
parts.append("--{}--\r\n".format(boundary).encode("utf-8"))
return b"".join(parts)
class TestUpload(unittest.TestCase):
def setUp(self):
self.td = tu.get_ramdisk()
self.maxDiff = 99999
def tearDown(self):
os.chdir(tempfile.gettempdir())
shutil.rmtree(self.td)
def test_basic_upload(self):
"""Test basic upload functionality (bput)"""
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", "Hello, World!")]
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(), "Hello, World!")
self.conn.shutdown()
def test_basic_upload_json_response(self):
"""Test basic upload with JSON response"""
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", "Hello, JSON!")]
h, body = self.bup("up?j", files)
self.assertIn("HTTP/1.1 201", h)
self.assertIn("application/json", h)
response = json.loads(body)
self.assertEqual(response["status"], "OK")
self.assertEqual(response["sz"], len("Hello, JSON!"))
self.assertEqual(len(response["files"]), 1)
self.assertEqual(response["files"][0]["fn_orig"], "test.txt")
self.conn.shutdown()
def test_large_file_upload(self):
"""Test uploading a relatively large file (1MB)"""
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"")
large_content = b"A" * 1024 * 1024
files = [("large.bin", large_content)]
h, body = self.bup("up", files)
self.assertIn("HTTP/1.1 201", h)
self.assertTrue(os.path.exists("up/large.bin"))
with open("up/large.bin", "rb") as f:
self.assertEqual(f.read(), large_content)
self.conn.shutdown()
def test_multiple_files_upload(self):
"""Test uploading multiple files in one request"""
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"),
("file3.txt", "Content 3"),
]
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"]), 3)
filenames = [f["fn_orig"] for f in response["files"]]
self.assertIn("file1.txt", filenames)
self.assertIn("file2.txt", filenames)
self.assertIn("file3.txt", filenames)
self.conn.shutdown()
def test_upload_without_write_permission(self):
"""Test upload without write permission - should fail"""
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", files)
self.assertIn("HTTP/1.1 403", h)
self.assertFalse(os.path.exists("ro/test.txt"))
self.conn.shutdown()
def test_upload_file_size_limit(self):
"""Test upload exceeding file 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=100)
self.asrv = AuthSrv(self.args, self.log)
self.conn = tu.VHttpConn(self.args, self.asrv, self.log, b"")
large_content = b"A" * 200
files = [("too_large.txt", large_content)]
h, body = self.bup("up", files)
self.assertIn("HTTP/1.1 400", h) or self.assertIn("ERROR", body)
self.assertFalse(os.path.exists("up/too_large.txt"))
self.conn.shutdown()
def test_upload_special_characters_filename(self):
"""Test upload with special characters in filename"""
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 = [("my file 123.txt", "Content with spaces")]
h, body = self.bup("up?j", files)
self.assertIn("HTTP/1.1 201", h)
response = json.loads(body)
self.assertEqual(response["status"], "OK")
self.conn.shutdown()
def bup(self, url, files):
"""
Perform a basic upload (bput) request.
Args:
url: The URL path to upload to
files: List of (filename, content) tuples
Returns:
(headers, body) tuple
"""
boundary = "XD"
body = build_multipart_form(files, boundary)
hdr_fmt = "POST /{} HTTP/1.1\r\nPW: o\r\nConnection: close\r\nContent-Type: multipart/form-data; boundary={}\r\nContent-Length: {}\r\n\r\n"
hdr_str = hdr_fmt.format(url, boundary, len(body))
full_request = hdr_str.encode("utf-8") + body
print("POST -->", full_request[:200], "...")
conn = self.conn.setbuf(full_request)
HttpCli(conn).run()
response = conn.s._reply
if isinstance(response, bytes):
parts = response.split(b"\r\n\r\n", 1)
h = parts[0].decode("utf-8")
b = parts[1].decode("utf-8") if len(parts) > 1 else ""
else:
parts = response.split("\r\n\r\n", 1)
h = parts[0]
b = parts[1] if len(parts) > 1 else ""
print("POST <--", h[:200], "...")
return h, b
def log(self, src, msg, c=0):
print(msg)
if __name__ == "__main__":
unittest.main()