Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
6094f98
feat(flash-backup): background boot backup with compression level
elibosley Jun 22, 2026
d3f7954
refactor(flash-backup): use StartCommand.php background-job + standar…
elibosley Jun 22, 2026
d3f4a7f
fix(flash-backup): CLI translation fallback, faster IO class, pool-fi…
elibosley Jun 23, 2026
c90b023
fix(flash-backup): stage archive in RAM first so it works with the ar…
elibosley Jun 23, 2026
6bcfefe
feat(flash-backup): boot-pool fallback, stale-archive reaper, broader…
elibosley Jun 23, 2026
f1dbf81
feat(flash-backup): stream the archive directly to the browser
elibosley Jun 23, 2026
71ae4cf
feat(flash-backup): real progress bar for the streamed download
elibosley Jun 23, 2026
f53ce1d
fix(flash-backup): download via hidden iframe so progress subscriber …
elibosley Jun 23, 2026
56cfc02
feat(flash-backup): selectable Download vs Save-to-server mode
elibosley Jun 23, 2026
6ad7a4c
fix(flash-backup): per-run token so stale channel messages don't corr…
elibosley Jun 23, 2026
b8a12d4
feat(flash-backup): faster save, tray notification, download-after; d…
elibosley Jun 23, 2026
8ef16fd
fix(flash-backup): don't leave a web-root symlink to the saved backup
elibosley Jun 23, 2026
0fd3c21
feat(flash-backup): show saved backups on the page + file-manager links
elibosley Jun 23, 2026
e5a9ced
style(flash-backup): use Unraid theme tokens + consistent UI
elibosley Jun 23, 2026
afbb87c
fix(flash-backup): keep saved-backups list inside the definition-list…
elibosley Jun 23, 2026
f4bedbe
flash backup: route background save through the shared task tray
elibosley Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion emhttp/languages/en_US/helptext.txt
Original file line number Diff line number Diff line change
Expand Up @@ -442,7 +442,9 @@ array or the cache disk/pool.
:end

:flash_backup_help:
Use *Boot device backup* to create a single zip file of the current contents of the boot device and store it locally on your computer.
Use *Boot device backup* to create a single zip file of the current contents of the boot device and download it to your computer.

The archive is streamed directly to your browser as it is built, so the download begins shortly after you start it and nothing is stored on the server. Choose a *Compression level* first: *None* is the fastest but produces the largest file, while *Maximum* is the slowest but produces the smallest file. The previous OS release and other non-essential files are excluded to keep the backup small.
:end

:syslinux_cfg_help:
Expand Down
52 changes: 26 additions & 26 deletions emhttp/plugins/dynamix/BootInfo.page
Original file line number Diff line number Diff line change
Expand Up @@ -16,36 +16,28 @@ Tag="usb"
?>
<?$tpmEnabled = !empty($var['flashGUID']) && strpos($var['flashGUID'], '01-') !== 0 && !empty($var['tpmGUID']) ? true : false;?>
<script>
function cleanUp(zip) {
if (document.hasFocus()) {
$('input[value="_(Creating boot device backup)_..."]').val("_(Boot device backup)_").prop('disabled',false);
$('div.spinner').hide('slow');
$('#pleaseWait').hide('slow');
$.post('/webGui/include/Download.php',{cmd:'unlink',file:zip});
} else {
setTimeout(function(){cleanUp(zip);},2000);
}
}
// The backup is streamed straight to the browser as it is built - no staging
// file, no size limit, and it works with the array stopped. We show a light
// status while the archive is being prepared and clear it as soon as the
// download starts, detected via a cookie the server sets once it begins
// streaming the response.
function backup() {
$('input[value="_(Boot device backup)_"]').val('_(Creating boot device backup)_...').prop('disabled',true);
$('div.spinner').show('slow');
$('#pleaseWait').show('slow');
$.post('/webGui/include/Download.php',{cmd:'backup'},function(zip) {
if (zip) {
location = '/'+zip;
setTimeout(function(){cleanUp(zip);},6000);
} else {
var $btn = $('input[value="_(Creating boot device backup)_..."]');
$btn.val("_(Boot device backup)_").prop('disabled',false);
$('div.spinner').hide('slow');
$('#pleaseWait').hide('slow');
swal({title:"_(Creation error)_",text:"_(Insufficient free disk space available)_",type:'error',html:true,confirmButtonText:"_(Ok)_"});
var level = $('#backupLevel').val();
var token = '' + Date.now() + Math.floor(Math.random()*1e6);
swal({title:"_(Boot device backup)_",text:"_(Preparing backup - your download will begin shortly)_... <i class='fa fa-refresh fa-spin'></i>",html:true,animation:'none',showConfirmButton:false});
// Attachment response keeps us on this page while the browser downloads.
window.location = '/webGui/include/FlashBackup.php?level='+level+'&download='+token;
// Dismiss the status once the stream starts (cookie appears) or after a wait.
var tries = 0;
var timer = setInterval(function() {
if (document.cookie.indexOf('flashBackup='+token) >= 0 || ++tries > 120) {
clearInterval(timer);
document.cookie = 'flashBackup=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
swal.close();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});
}, 500);
}
</script>
<div id="pleaseWait" style="display:none;text-align:center;margin-bottom:24px"><span class="red-text strong">_(Please wait)_... _(creating boot device backup zip file (this may take several minutes))_</span></div>

_(Active GUID)_:
: <?if (!empty($var['flashGUID'])):?>
<?=htmlspecialchars($var['flashGUID'], ENT_QUOTES, 'UTF-8');?>
Expand Down Expand Up @@ -81,6 +73,14 @@ _(TPM GUID)_:

:flash_backup_help:

_(Compression level)_:
: <select id="backupLevel">
<option value="0">_(None - fastest)_</option>
<option value="1">_(Low)_</option>
<option value="6" selected>_(Normal)_</option>
<option value="9">_(Maximum - smallest)_</option>
</select>

&nbsp;
: <span class="inline-block">
<input type="button" value="_(Boot device backup)_" onclick="backup()">
Expand Down
3 changes: 0 additions & 3 deletions emhttp/plugins/dynamix/include/Download.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,5 @@ function validpath($file) {
if ($backup = readlink("$docroot/$file")) unlink($backup);
@unlink("$docroot/$file");
break;
case 'backup':
echo exec("$docroot/webGui/scripts/flash_backup");
break;
}
?>
51 changes: 51 additions & 0 deletions emhttp/plugins/dynamix/include/FlashBackup.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?PHP
/* Copyright 2005-2025, Lime Technology
* Copyright 2012-2023, Bergware International.
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License version 2,
* as published by the Free Software Foundation.
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*/
?>
<?
/* Stream a boot-device (flash) backup straight to the browser as it is built,
* so nothing is ever staged on disk or in RAM. The heavy lifting is done by
* webGui/scripts/flash_backup, which writes the zip to stdout; we just set the
* download headers and pass it through.
*
* Query params:
* level zip compression level 0..9 (default 6)
* download opaque token echoed back as a cookie once the stream starts, so
* the GUI can tell the download began and clear its status spinner.
*/
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');

$var = (array)@parse_ini_file('/var/local/emhttp/var.ini');
$level = isset($_GET['level']) && is_numeric($_GET['level']) ? max(0, min(9, (int)$_GET['level'])) : 6;

$server = isset($var['NAME']) ? str_replace(' ', '_', strtolower($var['NAME'])) : 'tower';
$osVersion = $var['version'] ?? 'unknown';
$name = "$server-v$osVersion-boot-backup-".date('Ymd-Hi').".zip";
Comment on lines +53 to +55

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sanitize filename parts before sending Content-Disposition.

Lines 29-31 use config-derived NAME/version directly in the attachment filename. Special characters (quotes, separators, control chars) can break filename parsing across clients.

Suggested patch
-$server = isset($var['NAME']) ? str_replace(' ', '_', strtolower($var['NAME'])) : 'tower';
-$osVersion = $var['version'] ?? 'unknown';
-$name = "$server-v$osVersion-boot-backup-".date('Ymd-Hi').".zip";
+$server = isset($var['NAME']) ? str_replace(' ', '_', strtolower($var['NAME'])) : 'tower';
+$osVersion = $var['version'] ?? 'unknown';
+$safeServer = preg_replace('/[^a-z0-9._-]+/i', '_', $server);
+$safeVersion = preg_replace('/[^a-z0-9._-]+/i', '_', $osVersion);
+$name = "$safeServer-v$safeVersion-boot-backup-".date('Ymd-Hi').".zip";

Also applies to: 44-44

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@emhttp/plugins/dynamix/include/FlashBackup.php` around lines 29 - 31, The
filename construction in the backup file creation (variables $server,
$osVersion, and $name) uses unsanitized values from the config that may contain
special characters like quotes, separators, or control characters which can
break Content-Disposition header parsing. Apply proper filename sanitization to
both the processed $var['NAME'] value (after str_replace and strtolower) and the
$var['version'] value before they are used in the $name variable. Remove or
escape any special characters that are invalid in HTTP headers or filenames.
Apply the same sanitization fix to line 44 which also appears to have similar
filename construction issues.


// Echo the download token back as a (non-HttpOnly) cookie so the page can detect
// the download has started. Numeric-only to keep it harmless. Must be set before
// any body output.
$token = preg_replace('/\D/', '', $_GET['download'] ?? '');
if ($token !== '') setcookie('flashBackup', $token, ['path' => '/']);

// Stream for as long as it takes, and stop zip if the user cancels the download.
set_time_limit(0);
ignore_user_abort(false);

header('Content-Type: application/zip');
header('Content-Disposition: attachment; filename="'.$name.'"');
header('X-Accel-Buffering: no'); // tell nginx to stream, not spool to a temp file
header('Cache-Control: no-store');
while (ob_get_level()) ob_end_clean();

passthru(escapeshellarg("$docroot/webGui/scripts/flash_backup")." ".(int)$level);
exit;
?>
63 changes: 32 additions & 31 deletions emhttp/plugins/dynamix/scripts/flash_backup
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/php -q
<?PHP
/* Copyright 2005-2023, Lime Technology
/* Copyright 2005-2025, Lime Technology
* Copyright 2012-2023, Bergware International.
*
* This program is free software; you can redistribute it and/or
Expand All @@ -12,38 +12,39 @@
*/
?>
<?
$docroot ??= ($_SERVER['DOCUMENT_ROOT'] ?: '/usr/local/emhttp');
require_once "$docroot/webGui/include/Wrappers.php";
/* Stream a zip backup of the boot device (USB flash) to stdout.
*
* Usage: flash_backup [compression-level] > backup.zip
* compression-level 0 (store, fastest) .. 9 (smallest, slowest), default 6
*
* The archive is written straight to stdout so it can be streamed directly to a
* browser (see include/FlashBackup.php) without ever staging a file on disk or
* in RAM - this avoids any size limit, works with the array stopped, and needs
* no cleanup. zip runs under nice + best-effort-low ionice to stay gentle on
* CPU and disk. The prior-release dirs (previous/prev) and desktop junk are
* excluded so the archive stays small.
*/

$var = (array)@parse_ini_file('/var/local/emhttp/var.ini');
$dir = ['system','appdata','isos','domains'];
$out = ['prev','previous'];
// Clamp the requested compression level to a valid zip range (default 6).
$level = isset($argv[1]) && is_numeric($argv[1]) ? max(0, min(9, (int)$argv[1])) : 6;

$server = isset($var['NAME']) ? str_replace(' ','_',strtolower($var['NAME'])) : 'tower';
$mydate = date('Ymd-Hi');
$osVersion = _var($var,'version','_unknown');
$backup = "$server-v$osVersion-boot-backup-$mydate.zip";
// Top-level entries to exclude. 'previous'/'prev' hold the entire prior OS
// release (often >1GB); the rest is desktop junk created when the flash is
// mounted on a Mac/PC.
$out = ['prev','previous','System Volume Information','.Trash-0','.Trashes','.Spotlight-V100','.fseventsd','.TemporaryItems'];

$used = exec("df /boot|awk 'END{print $3}'") * 1.5;
$free = exec("df /|awk 'END{print $4}'");
if ($free > $used) $zip = "/$backup"; else {
foreach ($dir as $share) {
if (!is_dir("/mnt/user/$share")) continue;
$free = exec("df /mnt/user/$share|awk 'END{print $4}'");
if ($free > $used) {$zip = "/mnt/user/$share/$backup"; break;}
}
}
if (isset($zip)) {
chdir("/boot");
foreach (glob("*",GLOB_NOSORT+GLOB_ONLYDIR) as $folder) {
if (in_array($folder,$out)) continue;
exec("zip -qr ".escapeshellarg($zip)." ".escapeshellarg($folder));
}
foreach (glob("*",GLOB_NOSORT) as $file) {
if (is_dir($file)) continue;
exec("zip -q ".escapeshellarg($zip)." ".escapeshellarg($file));
}
symlink($zip,"$docroot/$backup");
echo $backup;
chdir('/boot');
$items = [];
foreach (glob('*', GLOB_NOSORT) as $entry) {
if (in_array($entry, $out)) continue;
if (preg_match('/-boot-backup-.*\.zip$/', $entry)) continue; // stray old backups
$items[] = $entry;
}
if (!$items) exit(1);
Comment on lines +41 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fail fast if /boot cannot be set as the working directory.

Line 36 does not check chdir('/boot'). If /boot is unavailable, glob('*') runs from the current directory and can produce a valid-but-wrong backup payload.

Suggested patch
-chdir('/boot');
+if (!chdir('/boot')) exit(1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
chdir('/boot');
$items = [];
foreach (glob('*', GLOB_NOSORT) as $entry) {
if (in_array($entry, $out)) continue;
if (preg_match('/-boot-backup-.*\.zip$/', $entry)) continue; // stray old backups
$items[] = $entry;
}
if (!$items) exit(1);
chdir('/boot') or exit(1);
$items = [];
foreach (glob('*', GLOB_NOSORT) as $entry) {
if (in_array($entry, $out)) continue;
if (preg_match('/-boot-backup-.*\.zip$/', $entry)) continue; // stray old backups
$items[] = $entry;
}
if (!$items) exit(1);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@emhttp/plugins/dynamix/scripts/flash_backup` around lines 36 - 43, The
chdir('/boot') call on line 36 does not verify that the directory change
succeeded before proceeding with glob('*'). If the chdir fails, glob will
execute from the current working directory instead of /boot, potentially
generating incorrect backup data. Add a check after the chdir('/boot') call to
verify it returns true, and exit with an error code if the directory change
fails. This ensures glob() only runs after successfully changing to the /boot
directory.


// Stream the archive to stdout. zip writes a valid streamable archive (data
// descriptors) when its output is not seekable.
$args = implode(' ', array_map('escapeshellarg', $items));
passthru("nice -n 19 ionice -c2 -n7 zip -$level -qr - $args 2>/dev/null", $rc);
exit($rc);
?>
Loading