diff --git a/src/ova2vzdump/templates/index.html b/src/ova2vzdump/templates/index.html index 395503b..fca0497 100644 --- a/src/ova2vzdump/templates/index.html +++ b/src/ova2vzdump/templates/index.html @@ -139,6 +139,48 @@ const bar = document.getElementById('bar'); const msg = document.getElementById('msg'); const result = document.getElementById('result'); +const CHUNK_SIZE = 16 * 1024 * 1024; // 16 MiB per PUT + +function putChunk(uploadId, offset, blob) { + // XHR (not fetch) so we can abort and — if we ever want — observe + // intra-chunk upload progress. One chunk = one HTTP request. + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('PUT', '/api/uploads/' + uploadId + '?offset=' + offset); + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { resolve(JSON.parse(xhr.responseText)); } + catch (e) { reject(new Error('bad server response')); } + } else { + reject(new Error('chunk @' + offset + ' failed: ' + + xhr.status + ' ' + xhr.statusText)); + } + }; + xhr.onerror = () => reject(new Error('network error @' + offset)); + xhr.send(blob); + }); +} + +async function uploadFileChunked(file, onProgress) { + const initResp = await fetch('/api/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: file.name }), + }); + if (!initResp.ok) throw new Error('failed to start upload'); + const { upload_id } = await initResp.json(); + + let offset = 0; + while (offset < file.size) { + const end = Math.min(offset + CHUNK_SIZE, file.size); + const chunk = file.slice(offset, end); + await putChunk(upload_id, offset, chunk); + offset = end; + onProgress(offset, file.size); + } + return upload_id; +} + convertForm.addEventListener('submit', async (e) => { e.preventDefault(); goConvert.disabled = true; @@ -148,17 +190,47 @@ convertForm.addEventListener('submit', async (e) => { msg.textContent = ''; result.innerHTML = ''; - const fd = new FormData(convertForm); + const fileInput = convertForm.querySelector('input[name="ova"]'); + const file = fileInput.files[0]; + if (!file) { + msg.innerHTML = 'no file selected'; + goConvert.disabled = false; + return; + } + const vmid = convertForm.querySelector('[name="vmid"]').value; + const storage = convertForm.querySelector('[name="storage"]').value; + + let upload_id; + try { + upload_id = await uploadFileChunked(file, (sent, total) => { + bar.value = (sent / total) * 100; + stageEl.textContent = 'uploading ' + formatBytes(sent) + + ' / ' + formatBytes(total); + }); + } catch (err) { + msg.innerHTML = 'upload failed: ' + err.message + ''; + goConvert.disabled = false; + return; + } + + stageEl.textContent = 'queued'; + bar.value = 0; + let resp; - try { resp = await fetch('/api/jobs', { method: 'POST', body: fd }); } - catch (err) { - msg.innerHTML = 'upload failed: ' + err + ''; + try { + resp = await fetch('/api/jobs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ upload_id, vmid: Number(vmid), storage }), + }); + } catch (err) { + msg.innerHTML = 'failed to start job: ' + err + ''; goConvert.disabled = false; return; } if (!resp.ok) { const body = await resp.text(); - msg.innerHTML = 'upload failed: ' + body + ''; + msg.innerHTML = 'failed to start job: ' + body + ''; goConvert.disabled = false; return; } diff --git a/src/ova2vzdump/web.py b/src/ova2vzdump/web.py index 34865cb..2928fd3 100644 --- a/src/ova2vzdump/web.py +++ b/src/ova2vzdump/web.py @@ -48,6 +48,16 @@ class Job: _JOBS: dict[str, Job] = {} _JOBS_LOCK = threading.Lock() +# In-memory registry of chunked upload sessions. `path` is the partial +# file on disk; `received` is the highest byte offset actually written +# (so clients can resume after a dropped connection). +_UPLOADS: dict[str, dict] = {} +_UPLOADS_LOCK = threading.Lock() + +# How much we pull from the WSGI input stream per sub-read. Bounds +# per-request memory during a chunk upload. +_STREAM_SUBCHUNK = 4 * 1024 * 1024 + def _run_job(job: Job, ova_path: Path, output_dir: Path) -> None: def progress(stage: str, pct: float) -> None: @@ -93,19 +103,107 @@ def create_app(upload_dir: Path, output_dir: Path) -> Flask: def index() -> str: return render_template("index.html") + @app.post("/api/uploads") + def upload_init(): + """Create a chunked-upload session. Client then PUTs chunks to + /api/uploads/?offset=N until the file is complete.""" + data = request.get_json(silent=True) or {} + filename = secure_filename(data.get("filename", "")) or "input.ova" + upload_id = uuid.uuid4().hex + path = upload_dir / f"partial-{upload_id}-{filename}" + path.touch() + with _UPLOADS_LOCK: + _UPLOADS[upload_id] = { + "filename": filename, "path": path, "received": 0, + } + return jsonify(upload_id=upload_id, filename=filename, received=0) + + @app.get("/api/uploads/") + def upload_status(upload_id: str): + with _UPLOADS_LOCK: + info = _UPLOADS.get(upload_id) + if not info: + return jsonify(error="not found"), 404 + return jsonify( + filename=info["filename"], received=info["received"], + ) + + @app.put("/api/uploads/") + def upload_chunk(upload_id: str): + with _UPLOADS_LOCK: + info = _UPLOADS.get(upload_id) + if not info: + return jsonify(error="not found"), 404 + try: + offset = int(request.args.get("offset", "0")) + except ValueError: + return jsonify(error="bad offset"), 400 + if offset < 0: + return jsonify(error="negative offset"), 400 + + written = 0 + with open(info["path"], "r+b") as f: + f.seek(offset) + while True: + buf = request.stream.read(_STREAM_SUBCHUNK) + if not buf: + break + f.write(buf) + written += len(buf) + # Track the highest byte actually persisted so resume can query. + new_high = offset + written + if new_high > info["received"]: + info["received"] = new_high + return jsonify(received=info["received"]) + + @app.delete("/api/uploads/") + def upload_abort(upload_id: str): + with _UPLOADS_LOCK: + info = _UPLOADS.pop(upload_id, None) + if info: + try: + info["path"].unlink(missing_ok=True) + except OSError: + pass + return jsonify(ok=True) + @app.post("/api/jobs") def create_job(): - if "ova" not in request.files: - return jsonify(error="no file 'ova' in multipart form"), 400 - f = request.files["ova"] - if not f.filename: - return jsonify(error="empty filename"), 400 - vmid = int(request.form.get("vmid", "100")) - storage = request.form.get("storage", "local-lvm") - filename = secure_filename(f.filename) or "input.ova" - job_id = uuid.uuid4().hex - ova_path = upload_dir / f"{job_id}-{filename}" - f.save(ova_path) + # Two input paths, decided by Content-Type: + # - application/json + {upload_id, ...} → finalize a chunked upload + # - multipart/form-data → legacy single-request upload (CLI/curl) + if request.is_json: + data = request.get_json() or {} + upload_id = data.get("upload_id") + if not upload_id: + return jsonify(error="upload_id required"), 400 + with _UPLOADS_LOCK: + info = _UPLOADS.pop(upload_id, None) + if not info: + return jsonify(error="upload session not found"), 404 + + try: + vmid = int(data.get("vmid", 100)) + except (TypeError, ValueError): + return jsonify(error="vmid must be an integer"), 400 + storage = data.get("storage", "local-lvm") + + job_id = uuid.uuid4().hex + ova_path = upload_dir / f"{job_id}-{info['filename']}" + info["path"].rename(ova_path) + filename = info["filename"] + else: + if "ova" not in request.files: + return jsonify(error="no file 'ova' in multipart form"), 400 + f = request.files["ova"] + if not f.filename: + return jsonify(error="empty filename"), 400 + vmid = int(request.form.get("vmid", "100")) + storage = request.form.get("storage", "local-lvm") + filename = secure_filename(f.filename) or "input.ova" + job_id = uuid.uuid4().hex + ova_path = upload_dir / f"{job_id}-{filename}" + f.save(ova_path) job = Job(id=job_id, ova_name=filename, vmid=vmid, storage=storage) with _JOBS_LOCK: