From ac38227dac3714f1d0a205e0adff161efe52cf1c Mon Sep 17 00:00:00 2001 From: jessikitty Date: Thu, 24 Sep 2026 20:31:21 +1000 Subject: [PATCH] Add drag-and-drop archive upload with progress to the backup page --- app/templates/admin/backup.html | 159 +++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 2 deletions(-) diff --git a/app/templates/admin/backup.html b/app/templates/admin/backup.html index 961e74d..2b1cf1d 100644 --- a/app/templates/admin/backup.html +++ b/app/templates/admin/backup.html @@ -106,6 +106,47 @@ + +
+
+
Upload an archive
+
+
+

+ Drop a .zip taken from another LIM instance here, then restore it + from the table below. The file streams straight to + {{ backup_folder }} — it is not held in memory, and the + 50MB limit on instruction uploads does not apply. + {% if max_upload %}Ceiling: {{ max_upload }} bytes.{% endif %} +

+ +
+ + Drop a backup archive here, or click to choose one + +
+ +
+ + + +
+ +
+
+
0%
+
+
+
+ +
+
+
+
@@ -201,8 +242,8 @@
  1. Create a backup here and download the .zip.
  2. -
  3. Copy it into the new instance's backup folder (or bind-mount it).
  4. -
  5. On the new instance run: +
  6. Upload it to the new instance with the box above (or copy it into the backup folder).
  7. +
  8. Restore it from the table — or, on a fresh instance with no admin user yet:
    flask backup verify lim-backup-....zip
     flask backup restore lim-backup-....zip --mode fresh
  9. @@ -354,6 +395,120 @@ flask backup restore lim-backup-....zip --mode fresh }); }); + // ---- upload ---------------------------------------------------------- + const dropZone = document.getElementById('dropZone'); + const archiveInput = document.getElementById('archiveInput'); + const chosenWrap = document.getElementById('chosenWrap'); + const chosenName = document.getElementById('chosenName'); + const chosenSize = document.getElementById('chosenSize'); + const startUpload = document.getElementById('startUpload'); + const upWrap = document.getElementById('uploadProgressWrap'); + const upBar = document.getElementById('uploadBar'); + const upNote = document.getElementById('uploadNote'); + const upResult = document.getElementById('uploadResult'); + let chosenFile = null; + + function humanSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let size = bytes, i = 0; + while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } + return size.toFixed(1) + ' ' + units[i]; + } + + function chooseFile(file) { + if (!file) { return; } + if (!file.name.toLowerCase().endsWith('.zip')) { + upResult.innerHTML = '
    That is not a .zip archive.
    '; + upResult.classList.remove('d-none'); + return; + } + chosenFile = file; + chosenName.textContent = file.name; + chosenSize.textContent = humanSize(file.size); + chosenWrap.classList.remove('d-none'); + upResult.classList.add('d-none'); + } + + dropZone.addEventListener('click', () => archiveInput.click()); + archiveInput.addEventListener('change', () => chooseFile(archiveInput.files[0])); + ['dragenter', 'dragover'].forEach(evt => + dropZone.addEventListener(evt, e => { + e.preventDefault(); + dropZone.classList.add('bg-light'); + })); + ['dragleave', 'drop'].forEach(evt => + dropZone.addEventListener(evt, e => { + e.preventDefault(); + dropZone.classList.remove('bg-light'); + })); + dropZone.addEventListener('drop', e => chooseFile(e.dataTransfer.files[0])); + + startUpload.addEventListener('click', function () { + if (!chosenFile) { return; } + startUpload.disabled = true; + upWrap.classList.remove('d-none'); + upResult.classList.add('d-none'); + upBar.classList.add('progress-bar-animated'); + upBar.classList.remove('bg-success', 'bg-danger'); + upBar.style.width = '0%'; + upBar.textContent = '0%'; + upNote.textContent = 'Uploading ' + chosenFile.name + '...'; + + // Raw body, not multipart: keeps the whole file out of Flask's + // form parser (and its 50MB ceiling) and off the temp disk. + const xhr = new XMLHttpRequest(); + xhr.open('POST', "{{ url_for('backup.upload') }}?filename=" + + encodeURIComponent(chosenFile.name)); + xhr.setRequestHeader('Content-Type', 'application/octet-stream'); + + xhr.upload.addEventListener('progress', function (e) { + if (!e.lengthComputable) { return; } + const pct = Math.round((e.loaded / e.total) * 100); + upBar.style.width = pct + '%'; + upBar.textContent = pct + '%'; + upNote.textContent = humanSize(e.loaded) + ' of ' + humanSize(e.total); + }); + + xhr.addEventListener('load', function () { + upBar.classList.remove('progress-bar-animated'); + startUpload.disabled = false; + let data = {}; + try { data = JSON.parse(xhr.responseText); } catch (err) { /* below */ } + + if (xhr.status === 200) { + upBar.classList.add('bg-success'); + upBar.style.width = '100%'; + upBar.textContent = '100%'; + upNote.textContent = 'Validated as a LIM backup.'; + const c = data.counts || {}; + upResult.innerHTML = '
    ' + + 'Uploaded: ' + data.filename + ' (' + + data.size_human + ')
    ' + + (c.sets || 0) + ' sets, ' + (c.instructions || 0) + ' instructions, ' + + (c.extra_files || 0) + ' extra files' + + (data.source ? ' — from ' + data.source : '') + + '
    ' + + 'Reload to restore it
    '; + } else { + upBar.classList.add('bg-danger'); + upResult.innerHTML = '
    Upload failed: ' + + (data.error || ('HTTP ' + xhr.status)) + '
    '; + } + upResult.classList.remove('d-none'); + }); + + xhr.addEventListener('error', function () { + upBar.classList.remove('progress-bar-animated'); + upBar.classList.add('bg-danger'); + startUpload.disabled = false; + upResult.innerHTML = '
    ' + + 'Upload failed. If you are behind a reverse proxy, check its max body size.
    '; + upResult.classList.remove('d-none'); + }); + + xhr.send(chosenFile); + }); + // Verify buttons document.querySelectorAll('.verify-btn').forEach(function (btn) { btn.addEventListener('click', function () {