diff --git a/source/compose.manager/compose.manager.dashboard.page b/source/compose.manager/compose.manager.dashboard.page index 40790af..e4da730 100644 --- a/source/compose.manager/compose.manager.dashboard.page +++ b/source/compose.manager/compose.manager.dashboard.page @@ -70,7 +70,6 @@ EOT; // Debug setting from config $debugEnabled = isset($cfg['DEBUG_TO_LOG']) && $cfg['DEBUG_TO_LOG'] === 'true' ? 'true' : 'false'; $hideComposeContainersJs = $hideDockerComposeContainers ? 'true' : 'false'; - // CSS and JavaScript $configScript = << @@ -166,6 +165,25 @@ $script = <<<'EOT' } } + // HTML escape function to prevent XSS + function escapeHtml(text) { + if (text === null || text === undefined) return ''; + var div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; + } + + // Escape for HTML attributes (more strict) + function escapeAttr(text) { + if (text === null || text === undefined) return ''; + return String(text) + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + } + // Resolve WebUI URL placeholders for a container // As of this version, WebUI URLs are resolved server-side in exec.php // (matching Unraid's DockerClient logic for IP/PORT resolution). @@ -446,10 +464,9 @@ $script = <<<'EOT' var stateText = isRunning ? 'started' : 'stopped'; var ctElId = 'dash-ct-' + ct.ID.substring(0, 12); var imgSrc = ct.Icon || '/plugins/dynamix.docker.manager/images/question.png'; - var escapedName = ct.Name.replace(/'/g, "\\'"); - var webui = resolveContainerWebUI(ct.WebUI).replace(/'/g, "\\'"); - var shell = (ct.Shell || '/bin/bash').replace(/'/g, "\\'"); + var shell = ct.Shell || '/bin/bash'; var shortId = ct.ID.substring(0, 12); + var webui = resolveContainerWebUI(ct.WebUI); // Parse image to get repo and tag var image = ct.Image || ''; var imageDisplay = image.replace(/^.*\//, ''); // Remove registry prefix @@ -471,8 +488,8 @@ $script = <<<'EOT' var ctUptime = formatUptime(ct.StartedAt, isRunning); html += '
'; - html += ''; - html += ''; + html += ''; + html += ''; html += ''; html += ''; html += '
' + ct.Name + '
'; @@ -540,14 +557,13 @@ $script = <<<'EOT' var uptimeText = formatUptime(stack.startedAt, isRunning); // WebUI - var webui = (stack.webui || '').replace(/'/g, "\\'"); + var webui = (stack.webui || ''); - var escapedFolder = stack.folder.replace(/'/g, "\\'"); // Add state class for filtering (like Docker tile) var stateClass = state === 'stopped' ? 'stopped' : 'started'; - html += '
'; + html += '
'; html += ''; - html += ''; + html += ''; html += ''; html += '
' + $('
').text(stack.name).html() + '
'; html += '
' + stateText + '
'; @@ -628,6 +644,32 @@ $script = <<<'EOT' window.composeToggleStack = toggleStack; window.addStackContext = addStackContext; + // Event delegation for container icon clicks (replaces inline onclick) + $(document).on('click', '.compose-dash-ct-icon[data-ct-name]', function(e) { + e.stopPropagation(); + var $el = $(this); + addContainerContext( + $el.attr('id'), + $el.data('ct-name'), + $el.data('ct-id'), + $el.data('ct-running') === '1' || $el.data('ct-running') === 1, + $el.data('ct-webui') || '', + $el.data('ct-shell') || '/bin/bash' + ); + }); + + // Event delegation for stack icon clicks (replaces inline onclick) + $(document).on('click', '.compose-dash-icon[data-stack-folder]', function(e) { + e.stopPropagation(); + var $el = $(this); + addStackContext( + $el.attr('id'), + $el.data('stack-folder'), + $el.data('stack-running') === '1' || $el.data('stack-running') === 1, + $el.data('stack-webui') || '' + ); + }); + // Check if any stacks are visible (for "no stacks" message) function noStacks() { if ($('#compose_dash_content .compose-dash-stack:visible').length === 0 && $('#compose_dash_content .compose-dash-stack').length > 0) { diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index b9f820b..70394b4 100644 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -8,1423 +8,1527 @@ Menu="Utilities" include "/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"; require_once("/usr/local/emhttp/plugins/compose.manager/php/defines.php"); $cfg = parse_plugin_cfg($sName); -$ui_patch_button_type = (!$option_patch_ui && strcmp($cfg['PATCH_UI'],"true") == 0 && !isset($composemanDockerClientPatch)) ? "Button" : "Hidden"; -$ui_unpatch_button_type = (!$option_patch_ui && strcmp($cfg['PATCH_UI'],"false") == 0 && isset($composemanDockerClientPatch)) ? "Button" : "Hidden"; +$ui_patch_button_type = (!$option_patch_ui && strcmp($cfg['PATCH_UI'], "true") == 0 && !isset($composemanDockerClientPatch)) ? "Button" : "Hidden"; +$ui_unpatch_button_type = (!$option_patch_ui && strcmp($cfg['PATCH_UI'], "false") == 0 && isset($composemanDockerClientPatch)) ? "Button" : "Hidden"; -$option_patch_ui = version_compare(parse_ini_file('/etc/unraid-version')['version'],'6.12.0-RC0', '>='); +$option_patch_ui = version_compare(parse_ini_file('/etc/unraid-version')['version'], '6.12.0-RC0', '>='); $ui_patch_help_class = $option_patch_ui ? "hidden" : "inline_help"; // hide help on 6.12+ (integration built-in) // -$projects_exist = intval(shell_exec("ls -l ".$compose_root." | grep ^d | wc -l")) != 0; +$projects_exist = intval(shell_exec("ls -l " . $compose_root . " | grep ^d | wc -l")) != 0; ?> - - + + + function createBackupNow() { + hideBackupStatus('#backup-create-status'); + setBackupSpinner('#btn-create-backup', true); + + $.post(caURL, { + action: 'createBackup' + }, function(data) { + setBackupSpinner('#btn-create-backup', false); + try { + var result = JSON.parse(data); + if (result.result === 'success') { + showBackupStatus('#backup-create-status', + 'Backup created: ' + result.archive + ' (' + result.size + ', ' + result.stacks + ' stacks)', 'success'); + loadBackupArchives(); + } else { + showBackupStatus('#backup-create-status', result.message || 'Backup failed.', 'error'); + } + } catch (e) { + showBackupStatus('#backup-create-status', 'Error parsing response.', 'error'); + } + }).fail(function() { + setBackupSpinner('#btn-create-backup', false); + showBackupStatus('#backup-create-status', 'Request failed.', 'error'); + }); + } - -
-
_(Settings)_
-
_(Backup / Restore)_
-
_(Log)_
-
+ function loadBackupArchives() { + var dir = $('#BACKUP_DESTINATION').val() || ''; + var $list = $('#backup-archive-list'); + $list.html('Loading...'); + + $.post(caURL, { + action: 'listBackups', + directory: dir + }, function(data) { + try { + var result = JSON.parse(data); + if (result.result === 'success') { + renderArchiveList(result.archives); + } else { + $list.html('Error loading archives'); + } + } catch (e) { + $list.html('Error parsing response'); + } + }); + } - -
-
- - - -
_(General Settings)_
- -
-
_(Compose Project Directory)_:
-
- - -
- Choose the folder where compose.manager stores your stacks.
- Warning: Changing this path will not automatically move existing project folders. -
-
-
+ function renderArchiveList(archives) { + var $list = $('#backup-archive-list'); -
-
_(Rich Terminal Output)_:
-
- > -
- Display compose commands in a rich terminal with color output and live progress.
- When disabled, uses simple text output. -
-
-
+ if (!archives || archives.length === 0) { + $list.html('No backups found. Create one using the button above.'); + return; + } -
-
_(Recreate During Autostart)_:
-
- > -
- Use --force-recreate when autostarting stacks.
- Helps when containers fail to start because their attached network no longer exists. -
-
-
+ var html = ''; + archives.forEach(function(a) { + var date = a.modified ? new Date(a.modified).toLocaleString() : a.filename; + html += ''; + html += ''; + html += '' + escapeHtml(a.filename) + ''; + html += '' + escapeHtml(a.size) + ''; + html += '' + escapeHtml(date) + ''; + html += ''; + }); + + $list.html(html); + } -
-
_(Wait for Docker Autostart)_:
-
- > -
- Wait for Docker's autostart containers to finish starting before starting compose stacks.
- Useful if your compose stacks depend on services from non-compose Docker containers.
- When disabled, compose stacks start in parallel with Docker autostart containers. -
-
-
+ function selectArchive(row) { + var $row = $(row); + var archive = $row.data('archive'); + + // Update selection UI + $('#backup-archive-list tr').removeClass('selected').find('input[type="radio"]').prop('checked', false); + $row.addClass('selected').find('input[type="radio"]').prop('checked', true); + + selectedArchive = archive; + selectedManifest = null; + + // Load stack list from archive + $('#restore-stack-checklist').html('Reading archive contents...'); + $('#btn-restore-stacks').prop('disabled', true); + hideBackupStatus('#restore-status'); + + var dir = $('#BACKUP_DESTINATION').val() || ''; + $.post(caURL, { + action: 'readManifest', + archive: archive, + directory: dir + }, function(data) { + try { + var result = JSON.parse(data); + if (result.result === 'success' && result.stacks) { + selectedManifest = result; + renderStackChecklist(result.stacks); + } else { + $('#restore-stack-checklist').html('' + escapeHtml(result.message || 'Failed to read archive.') + ''); + } + } catch (e) { + $('#restore-stack-checklist').html('Error parsing response.'); + } + }).fail(function() { + $('#restore-stack-checklist').html('Request failed. Check server logs.'); + }); + } -
-
_(Docker Wait Timeout)_:
-
- _(seconds)_ -
- Maximum time to wait for Docker autostart containers to stabilize.
- Only applies when "Wait for Docker Autostart" is enabled. -
-
-
+ function renderStackChecklist(stacks) { + var $checklist = $('#restore-stack-checklist'); -
-
_(Stack Startup Timeout)_:
-
- _(seconds)_ -
- Maximum time to wait for each stack to start during autostart.
- If a stack takes longer, it will be logged as a timeout and the next stack will start. -
-
-
+ if (!stacks || stacks.length === 0) { + $checklist.html('No stacks found in this archive.'); + return; + } -
_(Display Options)_
+ var html = ''; -
-
_(Show Compose in Header Menu)_:
-
- > -
- Add a Compose tab to the main Unraid header navigation bar for quick access. -
-
-
+ stacks.forEach(function(stack) { + html += ''; + }); -
-
_(Show Compose Stacks Above Docker)_:
-
- > -
- When the Docker page is displayed without tabs, move the Compose Stacks section above the built-in Docker Containers section.
- This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). -
-
-
+ $checklist.html(html); + updateRestoreButton(); + } -
-
_(Hide Compose Containers from Docker)_:
-
- > -
- When enabled, containers managed by Compose stacks will be hidden from the Docker Containers table on the Docker tab.
- This avoids duplicate display when Compose stacks are shown on the same page.
- This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). -
-
-
+ function toggleRestoreSelectAll(cb) { + var checked = $(cb).is(':checked'); + $('.restore-stack-cb').prop('checked', checked); + updateRestoreButton(); + } -
-
_(Show Dashboard Tile)_:
-
- > -
- Display a Compose Stacks tile on the Dashboard showing stack status at a glance. -
-
-
+ function updateRestoreButton() { + var $stackCbs = $('.restore-stack-cb'); + var count = $stackCbs.filter(':checked').length; + $('#btn-restore-stacks').prop('disabled', count === 0); + $('#restore-selected-count').text(count > 0 ? count + ' selected' : ''); + // Update Select All checkbox state + var $selectAll = $('#restore-select-all'); + if ($selectAll.length) { + if (count === $stackCbs.length && $stackCbs.length > 0) { + $selectAll.prop('checked', true); + } else { + $selectAll.prop('checked', false); + } + } + } -
-
_(Hide Compose Containers from Docker Tile on Dashboard)_:
-
- > -
- Hide containers managed by Compose stacks from Unraid's Docker Containers dashboard tile.
- This avoids duplicate entries when both the Compose Stacks tile and Docker Containers tile are visible on the Dashboard.
- This option requires "Show Dashboard Tile" to be enabled. -
-
-
+ function restoreSelectedStacks() { + var stacks = []; + $('.restore-stack-cb:checked').each(function() { + stacks.push($(this).val()); + }); + + if (stacks.length === 0 || !selectedArchive) return; + + // Show confirmation modal + var stackNames = stacks.join(', '); + swal({ + title: 'Confirm Restore', + text: 'This action will permanently replace existing configurations for the selected stacks (' + stackNames + '). Confirm to proceed.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Restore', + cancelButtonText: 'Cancel', + confirmButtonColor: '#f97316' + }, function() { + doRestore(stacks); + }); + } -
_(Update Checking)_
+ function doRestore(stacks) { + hideBackupStatus('#restore-status'); + setBackupSpinner('#btn-restore-stacks', true); + + $.post(caURL, { + action: 'restoreBackup', + archive: selectedArchive, + stacks: JSON.stringify(stacks) + }, function(data) { + setBackupSpinner('#btn-restore-stacks', false); + try { + var result = JSON.parse(data); + if (result.result === 'success') { + showBackupStatus('#restore-status', result.message, 'success'); + } else if (result.result === 'warning') { + showBackupStatus('#restore-status', result.message, 'info'); + } else { + showBackupStatus('#restore-status', result.message || 'Restore failed.', 'error'); + } + } catch (e) { + showBackupStatus('#restore-status', 'Error parsing response.', 'error'); + } + }).fail(function() { + setBackupSpinner('#btn-restore-stacks', false); + showBackupStatus('#restore-status', 'Request failed.', 'error'); + }); + } -
-
_(Auto Check for Updates)_:
-
- > -
- Automatically check for container image updates when the Compose page loads. -
-
-
+ function deleteSelectedBackup() { + if (!selectedArchive) return; + + swal({ + title: 'Delete Backup', + text: 'Delete "' + selectedArchive + '"? This cannot be undone.', + type: 'warning', + showCancelButton: true, + confirmButtonText: 'Delete', + confirmButtonColor: '#d33' + }, function() { + $.post(caURL, { + action: 'deleteBackup', + archive: selectedArchive + }, function(data) { + try { + var result = JSON.parse(data); + if (result.result === 'success') { + selectedArchive = null; + selectedManifest = null; + $('#restore-stack-checklist').html('Select a backup archive above to see its contents.'); + $('#btn-restore-stacks').prop('disabled', true); + loadBackupArchives(); + } else { + swal('Error', result.message || 'Failed to delete backup.', 'error'); + } + } catch (e) { + swal('Error', 'Error parsing response.', 'error'); + } + }); + }); + } -
-
_(Auto Check Interval (days))_:
-
- > -
- How often to check for updates.
- Examples: 0.04 (hourly), 1 (daily), 7 (weekly). -
-
-
+ function toggleBackupSchedule() { + var enabled = $('#BACKUP_SCHEDULE_ENABLED').is(':checked'); + $('#backup-schedule-options input, #backup-schedule-options select').prop('disabled', !enabled); + toggleScheduleDay(); + } -
-
_(Clear Update Cache)_:
-
- -
- Clear cached update status. Use if update checks show incorrect results. -
-
-
+ function toggleScheduleDay() { + var freq = $('#BACKUP_SCHEDULE_FREQUENCY').val(); + var scheduleEnabled = $('#BACKUP_SCHEDULE_ENABLED').is(':checked'); + $('#BACKUP_SCHEDULE_DAY').prop('disabled', freq !== 'weekly' || !scheduleEnabled); + } -
_(Advanced)_
+ function saveBackupSettings() { + // Collect all backup settings and save via Ajax + var settings = { + 'BACKUP_DESTINATION': $('#BACKUP_DESTINATION').val() || '/boot/config/plugins/compose.manager/backups', + 'BACKUP_RETENTION': $('#BACKUP_RETENTION').val() || '5', + 'BACKUP_SCHEDULE_ENABLED': $('#BACKUP_SCHEDULE_ENABLED').is(':checked') ? 'true' : 'false', + 'BACKUP_SCHEDULE_FREQUENCY': $('#BACKUP_SCHEDULE_FREQUENCY').val() || 'daily', + 'BACKUP_SCHEDULE_TIME': $('#BACKUP_SCHEDULE_TIME').val() || '03:00', + 'BACKUP_SCHEDULE_DAY': $('#BACKUP_SCHEDULE_DAY').val() || '1' + }; + + // Save via Ajax with the new saveBackupSettings action + var $btn = $('#btn-save-backup-settings'); + $btn.prop('disabled', true).val('Saving...'); + + $.post(caURL, { + action: 'saveBackupSettings', + settings: JSON.stringify(settings) + }, function(response) { + var result = JSON.parse(response); + $btn.prop('disabled', false).val('Save Settings'); + + if (result.result === 'success') { + showBackupStatus('#backup-create-status', 'Backup settings saved.', 'success'); + } else { + showBackupStatus('#backup-create-status', result.message || 'Failed to save settings', 'error'); + } + }).fail(function() { + $btn.prop('disabled', false).val('Save Settings'); + showBackupStatus('#backup-create-status', 'Failed to communicate with server', 'error'); + }); + } -
-
_(Debug Logging)_:
-
- > -
- Log detailed compose command information to syslog. View logs in the Log tab. -
-
-
- -
-
_(Patch Docker Page)_:
-
- > - - Not needed on Unraid 6.12+ (compose integration is built-in) - - value="_(Patch Now)_" onclick="patchWebui()" class="slim-button" style="margin-left:15px;"> - value="_(Unpatch)_" onclick="unpatchWebui()" class="slim-button" style="margin-left:15px;"> -
> - (Unraid 6.11 and earlier only) Patch the Docker page to better display compose-managed containers.
- Not needed on Unraid 6.12+ where compose integration is built-in. -
-
-
- -
-
 
-
-
-
+ function uploadBackupArchive(input) { + if (!input.files || !input.files[0]) return; + var file = input.files[0]; + + // Validate extension + if (!file.name.match(/\.(tar\.gz|tgz)$/i)) { + swal('Invalid File', 'Please select a .tar.gz archive file.', 'error'); + input.value = ''; + return; + } + + var formData = new FormData(); + formData.append('action', 'uploadBackup'); + formData.append('file', file); + + showBackupStatus('#restore-status', 'Uploading ' + file.name + '...', 'info'); + + $.ajax({ + url: caURL, + type: 'POST', + data: formData, + processData: false, + contentType: false, + success: function(data) { + try { + var result = JSON.parse(data); + if (result.result === 'success') { + showBackupStatus('#restore-status', 'Uploaded: ' + file.name, 'success'); + loadBackupArchives(); + } else { + showBackupStatus('#restore-status', result.message || 'Upload failed.', 'error'); + } + } catch (e) { + showBackupStatus('#restore-status', 'Error parsing upload response.', 'error'); + } + }, + error: function() { + showBackupStatus('#restore-status', 'Upload request failed.', 'error'); + }, + complete: function() { + input.value = ''; + } + }); + } + + + +
+
_(Settings)_
+
_(Backup / Restore)_
+
_(Log)_
+
+ + +
+
+ + + +
_(General Settings)_
+ +
+
_(Compose Project Directory)_:
+
+ + +
+ Choose the folder where compose.manager stores your stacks.
+ Warning: Changing this path will not automatically move existing project folders. +
+
+
+ +
+
_(Rich Terminal Output)_:
+
+ > +
+ Display compose commands in a rich terminal with color output and live progress.
+ When disabled, uses simple text output. +
+
+
+ +
+
_(Recreate During Autostart)_:
+
+ > +
+ Use --force-recreate when autostarting stacks.
+ Helps when containers fail to start because their attached network no longer exists. +
+
+
+ +
+
_(Wait for Docker Autostart)_:
+
+ > +
+ Wait for Docker's autostart containers to finish starting before starting compose stacks.
+ Useful if your compose stacks depend on services from non-compose Docker containers.
+ When disabled, compose stacks start in parallel with Docker autostart containers. +
+
+
+ +
+
_(Docker Wait Timeout)_:
+
+ _(seconds)_ +
+ Maximum time to wait for Docker autostart containers to stabilize.
+ Only applies when "Wait for Docker Autostart" is enabled. +
+
+
+ +
+
_(Stack Startup Timeout)_:
+
+ _(seconds)_ +
+ Maximum time to wait for each stack to start during autostart.
+ If a stack takes longer, it will be logged as a timeout and the next stack will start. +
+
+
+ +
_(Display Options)_
+ +
+
_(Show Compose in Header Menu)_:
+
+ > +
+ Add a Compose tab to the main Unraid header navigation bar for quick access. +
+
+
+ +
+
_(Show Compose Stacks Above Docker)_:
+
+ > +
+ When the Docker page is displayed without tabs, move the Compose Stacks section above the built-in Docker Containers section.
+ This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). +
+
+
+ +
+
_(Hide Compose Containers from Docker)_:
+
+ > +
+ When enabled, containers managed by Compose stacks will be hidden from the Docker Containers table on the Docker tab.
+ This avoids duplicate display when Compose stacks are shown on the same page.
+ This option is only available when "Show Compose in Header Menu" is set to No (non-tabbed mode). +
+
+
+ +
+
_(Show Dashboard Tile)_:
+
+ > +
+ Display a Compose Stacks tile on the Dashboard showing stack status at a glance. +
+
+
+ +
+
_(Hide Compose Containers from Docker Tile on Dashboard)_:
+
+ > +
+ Hide containers managed by Compose stacks from Unraid's Docker Containers dashboard tile.
+ This avoids duplicate entries when both the Compose Stacks tile and Docker Containers tile are visible on the Dashboard.
+ This option requires "Show Dashboard Tile" to be enabled. +
+
+
+ +
_(Update Checking)_
+ +
+
_(Auto Check for Updates)_:
+
+ > +
+ Automatically check for container image updates when the Compose page loads. +
+
+
+ +
+
_(Auto Check Interval (days))_:
+
+ > +
+ How often to check for updates.
+ Examples: 0.04 (hourly), 1 (daily), 7 (weekly). +
+
+
+ +
+
_(Clear Update Cache)_:
+
+ +
+ Clear cached update status. Use if update checks show incorrect results. +
+
+
+ +
_(Advanced)_
+ +
+
_(Debug Logging)_:
+
+ > +
+ Log detailed compose command information to syslog. View logs in the Log tab. +
+
+
+ +
+
_(Patch Docker Page)_:
+
+ > + + Not needed on Unraid 6.12+ (compose integration is built-in) + + value="_(Patch Now)_" onclick="patchWebui()" class="slim-button" style="margin-left:15px;"> + value="_(Unpatch)_" onclick="unpatchWebui()" class="slim-button" style="margin-left:15px;"> +
> + (Unraid 6.11 and earlier only) Patch the Docker page to better display compose-managed containers.
+ Not needed on Unraid 6.12+ where compose integration is built-in. +
+
+
+ +
+
 
+
+
+
- -
-
_(Backup Settings)_
- -
-
_(Backup Destination)_:
-
- -
- Path where backup archives will be stored.
- Leave blank to use the default: /boot/config/plugins/compose.manager/backups -
-
-
- -
-
_(Backups to Keep)_:
-
- -
- Number of backup archives to retain. After a successful backup, the oldest archives
- exceeding this count will be automatically deleted. Set to 0 for unlimited retention. -
-
-
- -
-
_(Scheduled Backup)_:
-
- - onchange="toggleBackupSchedule()"> -
- - - -
-
-
- -
-
 
-
-
- - -
+ +
+
_(Backup Settings)_
+ +
+
_(Backup Destination)_:
+
+ +
+ Path where backup archives will be stored.
+ Leave blank to use the default: /boot/config/plugins/compose.manager/backups +
+
+
+ +
+
_(Backups to Keep)_:
+
+ +
+ Number of backup archives to retain. After a successful backup, the oldest archives
+ exceeding this count will be automatically deleted. Set to 0 for unlimited retention. +
+
+
+ +
+
_(Scheduled Backup)_:
+
+ + onchange="toggleBackupSchedule()"> +
+ + + +
+
+
+ +
+
 
+
+
+ + +
+
+
+
+
-
-
-
-
- -
-
_(Restore Operations)_
- -
-
_(Available Backups)_:
-
-
- - - - - - - - - - - - -
_(Filename)__(Size)__(Date)_
_(Switch to this tab to load backup list.)_
-
-
- - - - -
-
-
- -
-
_(Stacks in Archive)_:
-
-
- _(Select a backup archive above to see its contents.)_ -
-
-
- -
-
 
-
-
- -
- + +
+
_(Restore Operations)_
+ +
+
_(Available Backups)_:
+
+
+ + + + + + + + + + + + + + +
_(Filename)__(Size)__(Date)_
_(Switch to this tab to load backup list.)_
+
+
+ + + + +
+
+
+ +
+
_(Stacks in Archive)_:
+
+
+ _(Select a backup archive above to see its contents.)_ +
+
+
+ +
+
 
+
+
+ +
+ +
+
+
+
-
-
-
-
-
- - - - - - - -
-
- _(Loading logs...)_ -
-
-

This log shows compose-related entries from the system log.

-

Enable Debug Logging in Settings to capture detailed compose command information.

-

Log entries are generated by the logger command when compose operations are performed.

-
+
+ + + + + + + +
+
+ _(Loading logs...)_ +
+
+

This log shows compose-related entries from the system log.

+

Enable Debug Logging in Settings to capture detailed compose command information.

+

Log entries are generated by the logger command when compose operations are performed.

+
diff --git a/source/compose.manager/event/started b/source/compose.manager/event/started index c656715..e9c4f2e 100755 --- a/source/compose.manager/event/started +++ b/source/compose.manager/event/started @@ -81,53 +81,54 @@ start_stack() { log "Starting stack: $stack_name" - # Build command arguments - local override="" + # Build command as an array to avoid shell injection via string interpolation + local -a cmd_args=("$COMPOSE_WRAPPER" -c up) + + if [ -f "$dir/indirect" ]; then + local indirect + indirect=$(< "$dir/indirect") + cmd_args+=(-d "$indirect") + else + cmd_args+=(-f "$dir/docker-compose.yml") + fi + + cmd_args+=(-p "$sanitized_name" -s "$dir") + if [ -f "$dir/docker-compose.override.yml" ]; then - override="-f ${dir}/docker-compose.override.yml" + cmd_args+=(-f "$dir/docker-compose.override.yml") fi - - local envpath="" + if [ -f "$dir/envpath" ]; then - envpath="-e $(< "$dir/envpath")" + local env_file + env_file=$(< "$dir/envpath") + cmd_args+=(-e "$env_file") fi - + # Read default profile(s) for autostart - local profiles="" if [ -f "$dir/default_profile" ]; then - local default_profiles=$(< "$dir/default_profile") + local default_profiles + default_profiles=$(< "$dir/default_profile") IFS=',' read -ra PROFILE_ARRAY <<< "$default_profiles" for p in "${PROFILE_ARRAY[@]}"; do p=$(echo "$p" | xargs) # trim whitespace if [ -n "$p" ]; then - profiles="$profiles -g $p" + cmd_args+=(-g "$p") fi done fi - - local recreate="" + if [ "$AUTOSTART_FORCE_RECREATE" = "true" ]; then - recreate="--recreate" + cmd_args+=(--recreate) fi - - local debug="" + if [ "$DEBUG_TO_LOG" = "true" ]; then - debug="--debug" + cmd_args+=(--debug) fi - - # Build the command - local cmd="" - if [ -f "$dir/indirect" ]; then - local indirect=$(< "$dir/indirect") - cmd="$COMPOSE_WRAPPER -c up -d '$indirect' -p '$sanitized_name' -s '$dir' $recreate $debug $override $envpath $profiles" - else - cmd="$COMPOSE_WRAPPER -c up -f '$dir/docker-compose.yml' -p '$sanitized_name' -s '$dir' $recreate $debug $override $envpath $profiles" - fi - + # Execute with timeout, capture output for logging local output local exit_code - output=$(timeout $STARTUP_TIMEOUT bash -c "$cmd" 2>&1) + output=$(timeout "$STARTUP_TIMEOUT" "${cmd_args[@]}" 2>&1) exit_code=$? local duration=$(($(date +%s) - start_time)) @@ -160,7 +161,7 @@ fi declare -a stacks_to_start declare -A stack_priorities -for dir in $COMPOSE_ROOT/*; do +for dir in "$COMPOSE_ROOT"/*; do if [ -d "$dir" ]; then if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/indirect" ]; then if [ -f "$dir/autostart" ] && [ "true" == "$(< "$dir/autostart")" ]; then @@ -237,17 +238,4 @@ log "Results: $succeeded succeeded, $failed failed, $timedout timed out (of $tot if [ $failed -gt 0 ] || [ $timedout -gt 0 ]; then exit 1 fi -exit 0 - esac - rm -f "$dir/autostart_result" - fi -done - -log "=== Compose Manager Autostart Complete ===" -log "Results: $succeeded succeeded, $failed failed, $timedout timed out (of $total_stacks total)" - -# Exit with error if any failed -if [ $failed -gt 0 ] || [ $timedout -gt 0 ]; then - exit 1 -fi exit 0 \ No newline at end of file diff --git a/source/compose.manager/event/stopping_docker b/source/compose.manager/event/stopping_docker index 6b60cf1..a810f63 100755 --- a/source/compose.manager/event/stopping_docker +++ b/source/compose.manager/event/stopping_docker @@ -37,29 +37,34 @@ stop_stack() { log "Stopping stack: $stack_name" # Build command arguments - local files="" + local -a file_args=() if [ -f "$dir/indirect" ]; then - local indirect=$(< "$dir/indirect") - for file in $(find "$indirect" -maxdepth 1 -type f -name '*compose*.yml' 2>/dev/null); do - files="$files -f $file" - done + local indirect + indirect=$(< "$dir/indirect") + while IFS= read -r -d '' file; do + file_args+=("-f" "$file") + done < <(find "$indirect" -maxdepth 1 -type f -name '*compose*.yml' -print0 2>/dev/null) else - files="-f $dir/docker-compose.yml" + file_args=("-f" "$dir/docker-compose.yml") fi - local override="" + local -a override_args=() if [ -f "$dir/docker-compose.override.yml" ]; then - override="-f $dir/docker-compose.override.yml" + override_args=("-f" "$dir/docker-compose.override.yml") fi - local envpath="" + local -a env_args=() if [ -f "$dir/envpath" ]; then - envpath="--env-file $(< "$dir/envpath")" + local envpath + envpath=$(< "$dir/envpath") + if [ -f "$envpath" ]; then + env_args=("--env-file" "$envpath") + fi fi # Try graceful stop first with timeout local exit_code - timeout $SHUTDOWN_TIMEOUT docker compose $files $override $envpath -p "$sanitized_name" stop 2>&1 + timeout $SHUTDOWN_TIMEOUT docker compose "${file_args[@]}" "${override_args[@]}" "${env_args[@]}" -p "$sanitized_name" stop 2>&1 exit_code=$? local duration=$(($(date +%s) - start_time)) @@ -70,7 +75,7 @@ stop_stack() { elif [ $exit_code -eq 124 ]; then log "WARNING: Stack $stack_name stop timed out after ${SHUTDOWN_TIMEOUT}s, forcing..." # Force kill containers if graceful stop times out - docker compose $files $override $envpath -p "$sanitized_name" kill 2>&1 + docker compose "${file_args[@]}" "${override_args[@]}" "${env_args[@]}" -p "$sanitized_name" kill 2>&1 log "Stack $stack_name force killed" return 124 else @@ -85,7 +90,7 @@ log "=== Compose Manager Shutdown Begin ===" # Collect all stacks (not just autostart ones - we need to stop everything) declare -a stacks_to_stop -for dir in $COMPOSE_ROOT/*; do +for dir in "$COMPOSE_ROOT"/*; do if [ -d "$dir" ]; then if [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/indirect" ]; then # Get stack name diff --git a/source/compose.manager/php/backup_functions.php b/source/compose.manager/php/backup_functions.php index da5a6ca..c04bc8c 100644 --- a/source/compose.manager/php/backup_functions.php +++ b/source/compose.manager/php/backup_functions.php @@ -299,9 +299,9 @@ function updateBackupCron() if (!$enabled) { // Write back crontab without our line - $tmpFile = '/tmp/compose-manager-crontab.' . getmypid(); + $tmpFile = tempnam('/tmp', 'compose-cron-'); file_put_contents($tmpFile, $existing); - exec("crontab {$tmpFile}"); + exec("crontab " . escapeshellarg($tmpFile)); @unlink($tmpFile); return; } @@ -323,9 +323,9 @@ function updateBackupCron() } // Write updated crontab - $tmpFile = '/tmp/compose-manager-crontab.' . getmypid(); + $tmpFile = tempnam('/tmp', 'compose-cron-'); file_put_contents($tmpFile, $existing . $cronLine . "\n"); - exec("crontab {$tmpFile}"); + exec("crontab " . escapeshellarg($tmpFile)); @unlink($tmpFile); } @@ -361,14 +361,13 @@ function logger($message) */ function resolveArchivePath($filenameOrPath, $directory = null) { - // If it's an absolute path and exists, use it directly - if (strpos($filenameOrPath, '/') !== false && file_exists($filenameOrPath)) { - return $filenameOrPath; - } + // Always use basename to prevent path traversal — the archive is resolved + // relative to the backup destination, never from an arbitrary absolute path. + $filename = basename($filenameOrPath); // Try the explicitly provided directory first if ($directory !== null && $directory !== '') { - $candidate = rtrim($directory, '/') . '/' . basename($filenameOrPath); + $candidate = rtrim($directory, '/') . '/' . $filename; if (file_exists($candidate)) { return $candidate; } @@ -376,7 +375,7 @@ function resolveArchivePath($filenameOrPath, $directory = null) // Otherwise look in the configured backup destination $dest = getBackupDestination(); - $candidate = $dest . '/' . basename($filenameOrPath); + $candidate = $dest . '/' . $filename; if (file_exists($candidate)) { return $candidate; } diff --git a/source/compose.manager/php/compose_list.php b/source/compose.manager/php/compose_list.php index a5a3f47..beb8da6 100644 --- a/source/compose.manager/php/compose_list.php +++ b/source/compose.manager/php/compose_list.php @@ -136,6 +136,8 @@ if (is_file("$compose_root/$project/description")) { $description = @file_get_contents("$compose_root/$project/description"); $description = str_replace("\r", "", $description); + // Escape HTML first to prevent XSS, then convert newlines to
+ $description = htmlspecialchars($description, ENT_QUOTES, 'UTF-8'); $description = str_replace("\n", "
", $description); } else { $description = ""; @@ -172,7 +174,7 @@ $profilestext = @file_get_contents("$compose_root/$project/profiles"); $profiles = json_decode($profilestext, false); } - $profilesJson = json_encode($profiles ? $profiles : []); + $profilesJson = htmlspecialchars(json_encode($profiles ? $profiles : []), ENT_QUOTES, 'UTF-8'); // Determine status text and class for badge $statusText = "Stopped"; diff --git a/source/compose.manager/php/compose_manager_main.php b/source/compose.manager/php/compose_manager_main.php index ef49e31..dffce18 100644 --- a/source/compose.manager/php/compose_manager_main.php +++ b/source/compose.manager/php/compose_manager_main.php @@ -1239,7 +1239,7 @@ functionBefore: function(instance, helper) { var disabled = $("#" + myID).attr('data-isup') == "1" ? "disabled" : ""; var notdisabled = $("#" + myID).attr('data-isup') == "1" ? "" : "disabled"; var stackName = $("#" + myID).attr("data-scriptname"); - instance.content(stackName + "
\ + instance.content(escapeHtml(stackName) + "
\
\ \ \ @@ -1571,7 +1571,7 @@ function(data) { function deleteStack(myID) { var stackName = $("#" + myID).attr("data-scriptname"); var project = $("#" + myID).attr("data-namename"); - var msgHtml = "Are you sure you want to delete " + project + " (" + compose_root + "/" + stackName + ")?"; + var msgHtml = "Are you sure you want to delete " + escapeHtml(project) + " (" + escapeHtml(compose_root) + "/" + escapeHtml(stackName) + ")?"; swal({ title: "Delete Stack?", text: msgHtml, @@ -1612,28 +1612,37 @@ function stripTags(string) { } function editName(myID) { - // console.log(myID); var currentName = $("#" + myID).attr("data-namename"); $("#" + myID).attr("data-originalName", currentName); - $("#" + myID).html("
  "); - $("#" + myID).tooltipster("close"); - $("#" + myID).tooltipster("disable"); + var $el = $("#" + myID); + $el.empty(); + var $input = $("").attr('id', 'newName' + myID).val(currentName); + var $cancel = $("").on('click', function() { cancelName(myID); }); + var $apply = $("").on('click', function() { applyName(myID); }); + $el.append($input).append($("
")).append($cancel).append("  ").append($apply); + $el.tooltipster("close"); + $el.tooltipster("disable"); } function editDesc(myID) { var origID = myID; $("#" + myID).tooltipster("close"); myID = myID.replace("name", "desc"); - var currentDesc = $("#" + myID).html(); + var currentDesc = $("#" + myID).text(); $("#" + myID).attr("data-originaldescription", currentDesc); - $("#" + myID).html("
  "); + var $el = $("#" + myID); + $el.empty(); + var $textarea = $("").attr('id', 'newDesc' + myID).val(currentDesc); + var $cancel = $("").on('click', function() { cancelDesc(myID); }); + var $apply = $("").on('click', function() { applyDesc(myID); }); + $el.append($textarea).append($("
")).append($cancel).append("  ").append($apply); $("#" + origID).tooltipster("enable"); } function applyName(myID) { var newName = $("#newName" + myID).val(); var project = $("#" + myID).attr("data-scriptname"); - $("#" + myID).html(newName); + $("#" + myID).text(newName); $("#" + myID).tooltipster("enable"); $("#" + myID).tooltipster("close"); $.post(caURL, { @@ -1647,7 +1656,7 @@ function applyName(myID) { function cancelName(myID) { var oldName = $("#" + myID).attr("data-originalName"); - $("#" + myID).html(oldName); + $("#" + myID).text(oldName); $("#" + myID).tooltipster("enable"); $("#" + myID).tooltipster("close"); window.location.reload(); @@ -1655,16 +1664,16 @@ function cancelName(myID) { function cancelDesc(myID) { var oldName = $("#" + myID).attr("data-originaldescription"); - $("#" + myID).html(oldName); + $("#" + myID).text(oldName); $("#" + myID).tooltipster("enable"); $("#" + myID).tooltipster("close"); } function applyDesc(myID) { var newDesc = $("#newDesc" + myID).val(); - newDesc = newDesc.replace(/\n/g, "
"); var project = $("#" + myID).attr("data-scriptname"); - $("#" + myID).html(newDesc); + // Use .text() with CSS white-space to avoid .html() XSS risk + $("#" + myID).text(newDesc).css('white-space', 'pre-line'); $.post(caURL, { action: 'changeDesc', script: project, @@ -1748,7 +1757,7 @@ function editComposeFile(myID) { $('#editorFileName').data("stackname", project); $('#editorFileName').data("stackfilename", "docker-compose.yml") - $('#editorFileName').html(response.fileName) + $('#editorFileName').text(response.fileName) $(".editing").show(); window.scrollTo(0, 0); } @@ -1771,7 +1780,7 @@ function editEnv(myID) { $('#editorFileName').data("stackname", project); $('#editorFileName').data("stackfilename", ".env") - $('#editorFileName').html(response.fileName) + $('#editorFileName').text(response.fileName) $(".editing").show(); window.scrollTo(0, 0); } @@ -3575,7 +3584,7 @@ function editComposeFileByProject(project) { var filename = response.fileName; $(".tipsterallowed").hide(); $(".editing").show(); - $("#editorFileName").html(filename); + $("#editorFileName").text(filename); $("#editorFileName").attr("data-stackname", project); $("#editorFileName").attr("data-stackfilename", "docker-compose.yml"); var editor = ace.edit("itemEditor"); @@ -3595,7 +3604,7 @@ function editEnvByProject(project) { var filename = response.fileName; $(".tipsterallowed").hide(); $(".editing").show(); - $("#editorFileName").html(filename); + $("#editorFileName").text(filename); $("#editorFileName").attr("data-stackname", project); $("#editorFileName").attr("data-stackfilename", ".env"); var editor = ace.edit("itemEditor"); @@ -3615,7 +3624,7 @@ function editOverrideByProject(project) { var filename = response.fileName; $(".tipsterallowed").hide(); $(".editing").show(); - $("#editorFileName").html(filename); + $("#editorFileName").text(filename); $("#editorFileName").attr("data-stackname", project); $("#editorFileName").attr("data-stackfilename", "docker-compose.override.yml"); var editor = ace.edit("itemEditor"); @@ -3677,13 +3686,17 @@ function toggleStackDetails(stackId) { $expandIcon.removeClass('expanded'); expandedStacks[stackId] = false; } else { - // Expand - $detailsRow.slideDown(200); $expandIcon.addClass('expanded'); expandedStacks[stackId] = true; - // Load container details if not cached or cache is stale - loadStackContainerDetails(stackId, project); + if (stackContainersCache[stackId]) { + // Cached — content is already rendered in the DOM from last load. + // Just slide down without re-fetching to avoid flash/layout shift. + $detailsRow.slideDown(200); + } else { + // First load: fetch data, row stays hidden until render completes + loadStackContainerDetails(stackId, project); + } } } @@ -3755,10 +3768,13 @@ function loadStackContainerDetails(stackId, project) { }); } catch (e) {} renderContainerDetails(stackId, containers, project); + // Slide down details row now that content is rendered + $('#details-row-' + stackId).slideDown(200); } else { // Escape error message to prevent XSS var errorMsg = escapeHtml(response.message || 'Failed to load container details'); $container.html('
' + errorMsg + '
'); + $('#details-row-' + stackId).slideDown(200); stackDetailsLoading[stackId] = false; try { composeClientDebug('loadStackContainerDetails:error', { @@ -3770,6 +3786,7 @@ function loadStackContainerDetails(stackId, project) { } } else { $container.html('
Failed to load container details
'); + $('#details-row-' + stackId).slideDown(200); stackDetailsLoading[stackId] = false; try { composeClientDebug('loadStackContainerDetails:empty-response', { @@ -3780,6 +3797,7 @@ function loadStackContainerDetails(stackId, project) { } }).fail(function() { $container.html('
Failed to load container details
'); + $('#details-row-' + stackId).slideDown(200); stackDetailsLoading[stackId] = false; try { composeClientDebug('loadStackContainerDetails:failed', { diff --git a/source/compose.manager/php/compose_util.php b/source/compose.manager/php/compose_util.php index 3fc6ce8..737aec9 100644 --- a/source/compose.manager/php/compose_util.php +++ b/source/compose.manager/php/compose_util.php @@ -1,7 +1,8 @@ 'ok')); } else { echo json_encode(array('status' => 'missing_msg')); @@ -85,8 +86,8 @@ // ttyd-exec sources /etc/default/ttyd for TTYD_OPTS and adds -d0. // No -R flag = writable interactive terminal. $cmd = "ttyd-exec -s9 -om1 -i " . escapeshellarg("/var/tmp/$socketName.sock") - . " docker exec -it " . escapeshellarg($containerName) - . " " . escapeshellarg($shell); + . " docker exec -it " . escapeshellarg($containerName) + . " " . escapeshellarg($shell); exec($cmd); // Wait for ttyd to create the socket (up to 2s) to avoid 502 @@ -114,7 +115,7 @@ // Start ttyd with docker logs -f (read-only) $cmd = "ttyd -R -o -i " . escapeshellarg("/var/tmp/$socketName.sock") - . " docker logs -f " . escapeshellarg($containerName) . " > /dev/null 2>&1 &"; + . " docker logs -f " . escapeshellarg($containerName) . " > /dev/null 2>&1 &"; exec($cmd); // Wait for ttyd to create the socket (up to 2s) to avoid 502 @@ -127,4 +128,3 @@ } break; } -?> \ No newline at end of file diff --git a/source/compose.manager/php/compose_util_functions.php b/source/compose.manager/php/compose_util_functions.php index 7bb140c..a23624f 100644 --- a/source/compose.manager/php/compose_util_functions.php +++ b/source/compose.manager/php/compose_util_functions.php @@ -33,13 +33,12 @@ function logger($string) function execComposeCommandInTTY($cmd, $debug) { global $socket_name; - $pid = exec("pgrep -a ttyd|awk '/\\/$socket_name\\.sock/{print \$1}'"); - if ($debug) { - logger($pid); - } - if ($pid) exec("kill $pid"); + // Use pkill -f for more robust process matching instead of pgrep|awk pipeline + exec("pkill -f " . escapeshellarg("$socket_name.sock") . " 2>/dev/null"); + usleep(300000); // 300ms for process to exit @unlink("/var/tmp/$socket_name.sock"); - $command = "ttyd -R -o -i '/var/tmp/$socket_name.sock' $cmd" . " > /dev/null &"; + $socketPath = escapeshellarg("/var/tmp/$socket_name.sock"); + $command = "ttyd -R -o -i $socketPath $cmd > /dev/null &"; exec($command); if ($debug) { logger($command); @@ -58,8 +57,8 @@ function echoComposeCommand($action, $recreate = false) global $sName; $cfg = parse_plugin_cfg($sName); $debug = $cfg['DEBUG_TO_LOG'] == "true"; - $path = isset($_POST['path']) ? urldecode($_POST['path']) : ""; - $profile = isset($_POST['profile']) ? urldecode($_POST['profile']) : ""; + $path = isset($_POST['path']) ? trim($_POST['path']) : ""; + $profile = isset($_POST['profile']) ? trim($_POST['profile']) : ""; $unRaidVars = parse_ini_file("/var/local/emhttp/var.ini"); $originalAction = $action; if ($unRaidVars['mdState'] != "STARTED") { diff --git a/source/compose.manager/php/dashboard_stacks.php b/source/compose.manager/php/dashboard_stacks.php index 6eee440..678510a 100644 --- a/source/compose.manager/php/dashboard_stacks.php +++ b/source/compose.manager/php/dashboard_stacks.php @@ -5,7 +5,6 @@ */ $plugin_root = "/usr/local/emhttp/plugins/compose.manager"; -$compose_root = "/boot/config/plugins/compose.manager/projects"; require_once("$plugin_root/php/defines.php"); require_once("$plugin_root/php/util.php"); @@ -26,7 +25,7 @@ } // Load saved update status from central JSON file -$composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; +$composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; $savedUpdateStatus = []; if (is_file($composeUpdateStatusFile)) { $savedUpdateStatus = json_decode(file_get_contents($composeUpdateStatusFile), true) ?: []; diff --git a/source/compose.manager/php/defines.php b/source/compose.manager/php/defines.php index 353e22e..076a88f 100644 --- a/source/compose.manager/php/defines.php +++ b/source/compose.manager/php/defines.php @@ -15,4 +15,9 @@ function locate_compose_root($name) { $docker_label_shell = "net.unraid.docker.shell"; $docker_label_managed_name = "composeman"; $compose_root = locate_compose_root($sName); + +// Centralised file-path constants — avoid scattering identical literals +define('COMPOSE_UPDATE_STATUS_FILE', '/boot/config/plugins/compose.manager/update-status.json'); +define('UNRAID_UPDATE_STATUS_FILE', '/var/lib/docker/unraid-update-status.json'); +define('PENDING_RECHECK_FILE', '/boot/config/plugins/compose.manager/pending-recheck.json'); ?> \ No newline at end of file diff --git a/source/compose.manager/php/exec.php b/source/compose.manager/php/exec.php index 5d880ce..8562b46 100644 --- a/source/compose.manager/php/exec.php +++ b/source/compose.manager/php/exec.php @@ -4,11 +4,31 @@ require_once("/usr/local/emhttp/plugins/compose.manager/php/util.php"); require_once("/usr/local/emhttp/plugins/compose.manager/php/exec_functions.php"); +/** + * Safely retrieve the 'script' POST parameter (stack directory name). + * Applies basename() to prevent path traversal attacks. + * Does NOT apply urldecode() because PHP already decodes POST data. + * + * @return string The sanitized script/stack directory name + */ +if (!function_exists('getPostScript')) { + function getPostScript(): string { + $script = $_POST['script'] ?? ''; + return basename(trim($script)); + } +} + switch ($_POST['action']) { case 'addStack': #Create indirect - $indirect = isset($_POST['stackPath']) ? urldecode(($_POST['stackPath'])) : ""; + $indirect = isset($_POST['stackPath']) ? trim($_POST['stackPath']) : ""; if (!empty($indirect)) { + // Validate stackPath is under an allowed root (/mnt/ or /boot/config/) + $realIndirect = realpath(dirname($indirect)) ?: $indirect; + if (strpos($realIndirect, '/mnt/') !== 0 && strpos($realIndirect, '/boot/config/') !== 0) { + echo json_encode(['result' => 'error', 'message' => 'Stack path must be under /mnt/ or /boot/config/.']); + break; + } if (!is_dir($indirect)) { exec("mkdir -p " . escapeshellarg($indirect)); if (!is_dir($indirect)) { @@ -21,14 +41,8 @@ #Pull stack files #Create stack folder - $stackName = isset($_POST['stackName']) ? urldecode(($_POST['stackName'])) : ""; - $folderName = str_replace('"', "", $stackName); - $folderName = str_replace("'", "", $folderName); - $folderName = str_replace("&", "", $folderName); - $folderName = str_replace("(", "", $folderName); - $folderName = str_replace(")", "", $folderName); - $folderName = preg_replace("/ {2,}/", " ", $folderName); - $folderName = preg_replace("/\s/", "_", $folderName); + $stackName = isset($_POST['stackName']) ? trim($_POST['stackName']) : ""; + $folderName = sanitizeFolderName($stackName); $folder = "$compose_root/$folderName"; while (true) { if (is_dir($folder)) { @@ -65,7 +79,7 @@ file_put_contents("$folder/name", $stackName); // Save description if provided - $stackDesc = isset($_POST['stackDesc']) ? urldecode(($_POST['stackDesc'])) : ""; + $stackDesc = isset($_POST['stackDesc']) ? trim($_POST['stackDesc']) : ""; if (!empty($stackDesc)) { file_put_contents("$folder/description", trim($stackDesc)); } @@ -75,7 +89,7 @@ echo json_encode(['result' => 'success', 'message' => '', 'project' => $projectDir, 'projectName' => $stackName]); break; case 'deleteStack': - $stackName = isset($_POST['stackName']) ? urldecode(($_POST['stackName'])) : ""; + $stackName = isset($_POST['stackName']) ? basename(trim($_POST['stackName'])) : ""; if (! $stackName) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -90,19 +104,22 @@ } break; case 'changeName': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - $newName = isset($_POST['newName']) ? urldecode(($_POST['newName'])) : ""; - file_put_contents("$compose_root/$script/name", trim($newName)); + $script = getPostScript(); + $newName = isset($_POST['newName']) ? trim($_POST['newName']) : ""; + // Strip characters that could cause shell injection when name is + // used in bash scripts (e.g. event/started autostart) + $newName = preg_replace('/[^a-zA-Z0-9 _.\-()\[\]]/', '', $newName); + file_put_contents("$compose_root/$script/name", $newName); echo json_encode(['result' => 'success', 'message' => '']); break; case 'changeDesc': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; - $newDesc = isset($_POST['newDesc']) ? urldecode(($_POST['newDesc'])) : ""; + $script = getPostScript(); + $newDesc = isset($_POST['newDesc']) ? trim($_POST['newDesc']) : ""; file_put_contents("$compose_root/$script/description", trim($newDesc)); echo json_encode(['result' => 'success', 'message' => '']); break; case 'getDescription': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -113,7 +130,7 @@ echo json_encode(['result' => 'success', 'content' => $fileContents]); break; case 'getYml': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $basePath = getPath("$compose_root/$script"); $fileName = "docker-compose.yml"; @@ -125,7 +142,7 @@ echo json_encode(['result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents]); break; case 'getEnv': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $basePath = getPath("$compose_root/$script"); $fileName = "$basePath/.env"; if (is_file("$basePath/envpath")) { @@ -141,7 +158,7 @@ echo json_encode(['result' => 'success', 'fileName' => "$fileName", 'content' => $scriptContents]); break; case 'getOverride': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $basePath = "$compose_root/$script"; $fileName = "docker-compose.override.yml"; @@ -153,7 +170,7 @@ echo json_encode(['result' => 'success', 'fileName' => "$basePath/$fileName", 'content' => $scriptContents]); break; case 'saveYml': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = getPath("$compose_root/$script"); $fileName = "docker-compose.yml"; @@ -162,7 +179,7 @@ echo "$basePath/$fileName saved"; break; case 'saveEnv': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = getPath("$compose_root/$script"); $fileName = "$basePath/.env"; @@ -175,7 +192,7 @@ echo "$fileName saved"; break; case 'saveOverride': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = "$compose_root/$script"; $fileName = "docker-compose.override.yml"; @@ -184,15 +201,15 @@ echo "$basePath/$fileName saved"; break; case 'updateAutostart': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - $autostart = isset($_POST['autostart']) ? urldecode(($_POST['autostart'])) : "false"; + $autostart = isset($_POST['autostart']) ? trim($_POST['autostart']) : "false"; $fileName = "$compose_root/$script/autostart"; if (is_file($fileName)) { - exec("rm " . escapeshellarg($fileName)); + @unlink($fileName); } file_put_contents($fileName, $autostart); echo json_encode(['result' => 'success', 'message' => '']); @@ -227,13 +244,13 @@ case 'clearUpdateCache': // Clear the compose manager update status cache - $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; + $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; if (is_file($composeUpdateStatusFile)) { unlink($composeUpdateStatusFile); } // Also clear entries from Unraid's update status that were created by compose manager // by removing entries that don't correspond to running Docker containers - $unraidUpdateStatusFile = "/var/lib/docker/unraid-update-status.json"; + $unraidUpdateStatusFile = UNRAID_UPDATE_STATUS_FILE; if (is_file($unraidUpdateStatusFile)) { require_once("/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"); $DockerClient = new DockerClient(); @@ -256,23 +273,37 @@ echo json_encode(['result' => 'success', 'message' => 'Update cache cleared']); break; case 'setEnvPath': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - $fileContent = isset($_POST['envPath']) ? urldecode(($_POST['envPath'])) : ""; + $fileContent = isset($_POST['envPath']) ? trim($_POST['envPath']) : ""; $fileName = "$compose_root/$script/envpath"; + // Validate env path is under an allowed root + if (!empty($fileContent)) { + $realEnvDir = realpath(dirname($fileContent)); + $realComposeRoot = realpath($compose_root); + $allowed = $realEnvDir !== false && ( + strpos($realEnvDir, '/mnt/') === 0 || + strpos($realEnvDir, '/boot/config/') === 0 || + ($realComposeRoot !== false && strpos($realEnvDir, $realComposeRoot) === 0) + ); + if (!$allowed) { + echo json_encode(['result' => 'error', 'message' => 'Env file path must be under /mnt/, /boot/config/, or the compose root.']); + break; + } + } if (is_file($fileName)) { - exec("rm " . escapeshellarg($fileName)); + @unlink($fileName); } - if (isset($fileContent) && !empty($fileContent)) { + if (!empty($fileContent)) { file_put_contents($fileName, $fileContent); } echo json_encode(['result' => 'success', 'message' => '']); break; case 'getEnvPath': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -286,7 +317,7 @@ echo json_encode(['result' => 'success', 'fileName' => "$fileName", 'content' => $fileContents]); break; case 'getStackSettings': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -327,7 +358,7 @@ ]); break; case 'setStackSettings': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -382,17 +413,15 @@ echo json_encode(['result' => 'success', 'message' => 'Settings saved']); break; case 'saveProfiles': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); $scriptContents = isset($_POST['scriptContents']) ? $_POST['scriptContents'] : ""; $basePath = "$compose_root/$script"; $fileName = "$basePath/profiles"; if ($scriptContents == "[]") { if (is_file($fileName)) { - exec("rm " . escapeshellarg($fileName)); - echo json_encode(['result' => 'success', 'message' => "$fileName deleted"]); + @unlink($fileName); } - echo json_encode(['result' => 'success', 'message' => '']); break; } @@ -401,40 +430,19 @@ echo json_encode(['result' => 'success', 'message' => "$fileName saved"]); break; case 'getStackContainers': - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; } - // Get the project name (sanitized) - $projectName = $script; - if (is_file("$compose_root/$script/name")) { - $projectName = trim(file_get_contents("$compose_root/$script/name")); - } - $projectName = sanitizeStr($projectName); - - // Get containers for this compose project using docker compose ps - $basePath = getPath("$compose_root/$script"); - $composeFile = "$basePath/docker-compose.yml"; - $overrideFile = "$compose_root/$script/docker-compose.override.yml"; - - $files = "-f " . escapeshellarg($composeFile); - if (is_file($overrideFile)) { - $files .= " -f " . escapeshellarg($overrideFile); - } - - $envFile = ""; - if (is_file("$compose_root/$script/envpath")) { - $envPath = trim(file_get_contents("$compose_root/$script/envpath")); - if (is_file($envPath)) { - $envFile = "--env-file " . escapeshellarg($envPath); - } - } + // Build compose CLI arguments (project name, file flags, env-file flag) + $args = buildComposeArgs($script); + $projectName = $args['projectName']; // Get container details in JSON format // Include --all so exited/stopped containers are returned as well - $cmd = "docker compose $files $envFile -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; + $cmd = "docker compose {$args['files']} {$args['envFile']} -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); // Cache network drivers for resolving network types (bridge vs macvlan/ipvlan) @@ -457,6 +465,13 @@ } $containers = []; + // Load update status once before the loop (static data, doesn't change per-container) + $updateStatusFile = UNRAID_UPDATE_STATUS_FILE; + $updateStatus = []; + if (is_file($updateStatusFile)) { + $updateStatus = json_decode(file_get_contents($updateStatusFile), true) ?: []; + } + if ($output) { // docker compose ps --format json outputs one JSON object per line $lines = explode("\n", trim($output)); @@ -580,12 +595,7 @@ $container['WebUI'] = $resolvedURL; } - // Get update status from saved status file - $updateStatusFile = "/var/lib/docker/unraid-update-status.json"; - $updateStatus = []; - if (is_file($updateStatusFile)) { - $updateStatus = json_decode(file_get_contents($updateStatusFile), true) ?: []; - } + // Get update status from saved status file (read once before loop) $imageName = $container['Image']; // Ensure image has a tag for lookup if (strpos($imageName, ':') === false) { @@ -627,8 +637,8 @@ echo json_encode(['result' => 'success', 'containers' => $containers, 'projectName' => $projectName]); break; case 'containerAction': - $containerName = isset($_POST['container']) ? urldecode(($_POST['container'])) : ""; - $containerAction = isset($_POST['containerAction']) ? urldecode(($_POST['containerAction'])) : ""; + $containerName = isset($_POST['container']) ? trim($_POST['container']) : ""; + $containerAction = isset($_POST['containerAction']) ? trim($_POST['containerAction']) : ""; if (! $containerName || ! $containerAction) { echo json_encode(['result' => 'error', 'message' => 'Container or action not specified.']); @@ -648,7 +658,7 @@ break; case 'checkStackUpdates': // Check for updates for all containers in a compose stack - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -657,34 +667,13 @@ // Include Docker manager classes for update checking require_once("/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php"); - // Get the project name (sanitized) - $projectName = $script; - if (is_file("$compose_root/$script/name")) { - $projectName = trim(file_get_contents("$compose_root/$script/name")); - } - $projectName = sanitizeStr($projectName); - - // Get containers for this compose project - $basePath = getPath("$compose_root/$script"); - $composeFile = "$basePath/docker-compose.yml"; - $overrideFile = "$compose_root/$script/docker-compose.override.yml"; - - $files = "-f " . escapeshellarg($composeFile); - if (is_file($overrideFile)) { - $files .= " -f " . escapeshellarg($overrideFile); - } - - $envFile = ""; - if (is_file("$compose_root/$script/envpath")) { - $envPath = trim(file_get_contents("$compose_root/$script/envpath")); - if (is_file($envPath)) { - $envFile = "--env-file " . escapeshellarg($envPath); - } - } + // Build compose CLI arguments (project name, file flags, env-file flag) + $args = buildComposeArgs($script); + $projectName = $args['projectName']; // Get container images // Include --all to ensure non-running containers are considered for update checks - $cmd = "docker compose $files $envFile -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; + $cmd = "docker compose {$args['files']} {$args['envFile']} -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); $updateResults = []; @@ -692,11 +681,39 @@ // Load the update status file to get SHA values $dockerManPaths = [ - 'update-status' => "/var/lib/docker/unraid-update-status.json" + 'update-status' => UNRAID_UPDATE_STATUS_FILE ]; if ($output) { $lines = explode("\n", trim($output)); + + // Load the update status data ONCE before the loop instead of per-container + $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); + $statusDirty = false; + + // First pass: clear cached local SHAs for all images that need checking + foreach ($lines as $line) { + if (!empty($line)) { + $container = json_decode($line, true); + if ($container) { + $image = $container['Image'] ?? ''; + if ($image) { + $image = normalizeImageForUpdateCheck($image); + if (isset($updateStatusData[$image])) { + $updateStatusData[$image]['local'] = null; + $statusDirty = true; + } + } + } + } + } + + // Save once after clearing all cached SHAs + if ($statusDirty) { + DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); + } + + // Second pass: check updates and collect results foreach ($lines as $line) { if (!empty($line)) { $container = json_decode($line, true); @@ -708,21 +725,11 @@ // Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images) $image = normalizeImageForUpdateCheck($image); - // Clear cached local SHA to force re-inspection of the actual image - // This is needed because Unraid's reloadUpdateStatus uses cached values - // which can be stale after docker compose pull - $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); - if (isset($updateStatusData[$image])) { - // Clear the local SHA to force fresh inspection - $updateStatusData[$image]['local'] = null; - DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); - } - // Check update status using Unraid's DockerUpdate class $DockerUpdate->reloadUpdateStatus($image); $updateStatus = $DockerUpdate->getUpdateStatus($image); - // Get SHA values from the status file + // Re-read status data (may have been updated by reloadUpdateStatus) $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); $localSha = ''; $remoteSha = ''; @@ -760,7 +767,7 @@ echo json_encode(['result' => 'success', 'updates' => $updateResults, 'projectName' => $projectName]); // Save the update status for this stack - $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; + $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; $savedStatus = []; if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true) ?: []; @@ -784,7 +791,7 @@ // Path to update status file $dockerManPaths = [ - 'update-status' => "/var/lib/docker/unraid-update-status.json" + 'update-status' => UNRAID_UPDATE_STATUS_FILE ]; // Iterate through all stacks @@ -810,22 +817,12 @@ } } - // Get project name - $projectName = $stackName; - if (is_file("$compose_root/$stackName/name")) { - $projectName = trim(file_get_contents("$compose_root/$stackName/name")); - } - $projectName = sanitizeStr($projectName); - - // Get containers - $files = "-f " . escapeshellarg($composeFile); - $overrideFile = "$compose_root/$stackName/docker-compose.override.yml"; - if (is_file($overrideFile)) { - $files .= " -f " . escapeshellarg($overrideFile); - } + // Build compose CLI arguments (includes env-file, override, etc.) + $args = buildComposeArgs($stackName); + $projectName = $args['projectName']; // Include --all so we can detect stacks that have stopped containers - $cmd = "docker compose $files -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; + $cmd = "docker compose {$args['files']} {$args['envFile']} -p " . escapeshellarg($projectName) . " ps --all --format json 2>/dev/null"; $output = shell_exec($cmd); $stackUpdates = []; @@ -834,38 +831,56 @@ if ($output) { $lines = explode("\n", trim($output)); + + // Load once, batch-clear local SHAs, save once (avoid per-container I/O) + $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); + $statusDirty = false; + + // First pass: collect running images and clear cached local SHAs + $runningImages = []; foreach ($lines as $line) { if (!empty($line)) { $container = json_decode($line, true); if ($container) { - $containerName = $container['Name'] ?? ''; - $image = $container['Image'] ?? ''; $state = $container['State'] ?? ''; - - // Check if any container is running if ($state === 'running') { $isRunning = true; + $image = $container['Image'] ?? ''; + if ($image) { + $image = normalizeImageForUpdateCheck($image); + $runningImages[] = $image; + if (isset($updateStatusData[$image])) { + $updateStatusData[$image]['local'] = null; + $statusDirty = true; + } + } } + } + } + } + + // Save once after clearing all cached SHAs + if ($statusDirty) { + DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); + } + + // Second pass: check updates for running containers + foreach ($lines as $line) { + if (!empty($line)) { + $container = json_decode($line, true); + if ($container) { + $containerName = $container['Name'] ?? ''; + $image = $container['Image'] ?? ''; + $state = $container['State'] ?? ''; // Only check updates for running containers if ($containerName && $image && $state === 'running') { - // Normalize image name (strip docker.io/ prefix, @sha256: digest, add library/ for official images) $image = normalizeImageForUpdateCheck($image); - // Clear cached local SHA to force re-inspection of the actual image - // This is needed because Unraid's reloadUpdateStatus uses cached values - // which can be stale after docker compose pull - $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); - if (isset($updateStatusData[$image])) { - // Clear the local SHA to force fresh inspection - $updateStatusData[$image]['local'] = null; - DockerUtil::saveJSON($dockerManPaths['update-status'], $updateStatusData); - } - $DockerUpdate->reloadUpdateStatus($image); $updateStatus = $DockerUpdate->getUpdateStatus($image); - // Get SHA values from the status file + // Re-read status data (may have been updated by reloadUpdateStatus) $updateStatusData = DockerUtil::loadJSON($dockerManPaths['update-status']); $localSha = ''; $remoteSha = ''; @@ -908,7 +923,7 @@ } // Save the update status for all stacks - $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; + $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; $savedStatus = $allUpdates; foreach ($savedStatus as $stackKey => &$stackData) { $stackData['lastChecked'] = time(); @@ -919,7 +934,7 @@ break; case 'getSavedUpdateStatus': // Load saved update status from file - $composeUpdateStatusFile = "/boot/config/plugins/compose.manager/update-status.json"; + $composeUpdateStatusFile = COMPOSE_UPDATE_STATUS_FILE; if (is_file($composeUpdateStatusFile)) { $savedStatus = json_decode(file_get_contents($composeUpdateStatusFile), true); if ($savedStatus) { @@ -999,7 +1014,7 @@ case 'checkStackLock': // Check if a stack is currently locked (operation in progress) - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -1022,7 +1037,7 @@ case 'getStackResult': // Get the last operation result for a stack - $script = isset($_POST['script']) ? urldecode(($_POST['script'])) : ""; + $script = getPostScript(); if (! $script) { echo json_encode(['result' => 'error', 'message' => 'Stack not specified.']); break; @@ -1049,7 +1064,7 @@ break; } - $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; + $pendingRecheckFile = PENDING_RECHECK_FILE; $pending = []; if (is_file($pendingRecheckFile)) { $pending = json_decode(file_get_contents($pendingRecheckFile), true) ?: []; @@ -1066,7 +1081,7 @@ case 'getPendingRecheckStacks': // Get list of stacks that need recheck - $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; + $pendingRecheckFile = PENDING_RECHECK_FILE; $pending = []; if (is_file($pendingRecheckFile)) { $pending = json_decode(file_get_contents($pendingRecheckFile), true) ?: []; @@ -1085,7 +1100,7 @@ break; } - $pendingRecheckFile = "/boot/config/plugins/compose.manager/pending-recheck.json"; + $pendingRecheckFile = PENDING_RECHECK_FILE; $pending = []; if (is_file($pendingRecheckFile)) { $pending = json_decode(file_get_contents($pendingRecheckFile), true) ?: []; @@ -1114,7 +1129,7 @@ case 'listBackups': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); - $directory = isset($_POST['directory']) && $_POST['directory'] !== '' ? urldecode($_POST['directory']) : null; + $directory = isset($_POST['directory']) && $_POST['directory'] !== '' ? trim($_POST['directory']) : null; $archives = listBackupArchives($directory); echo json_encode(['result' => 'success', 'archives' => $archives]); break; @@ -1168,8 +1183,8 @@ case 'readManifest': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); - $archive = isset($_POST['archive']) ? urldecode($_POST['archive']) : ''; - $directory = isset($_POST['directory']) && $_POST['directory'] !== '' ? urldecode($_POST['directory']) : null; + $archive = isset($_POST['archive']) ? trim($_POST['archive']) : ''; + $directory = isset($_POST['directory']) && $_POST['directory'] !== '' ? trim($_POST['directory']) : null; if (empty($archive)) { echo json_encode(['result' => 'error', 'message' => 'No archive specified.']); break; @@ -1181,7 +1196,7 @@ case 'restoreBackup': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); - $archive = isset($_POST['archive']) ? urldecode($_POST['archive']) : ''; + $archive = isset($_POST['archive']) ? basename(trim($_POST['archive'])) : ''; $stacks = isset($_POST['stacks']) ? $_POST['stacks'] : ''; if (is_string($stacks)) { $stacks = json_decode($stacks, true); @@ -1194,7 +1209,7 @@ echo json_encode(['result' => 'error', 'message' => 'No stacks selected for restore.']); break; } - exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore started from " . basename($archive) . " (" . count($stacks) . " stacks)")); + exec("logger -t 'compose.manager' " . escapeshellarg("[restore] Restore started from " . $archive . " (" . count($stacks) . " stacks)")); $archivePath = resolveArchivePath($archive); $result = restoreStacks($archivePath, $stacks); if ($result['result'] === 'error') { @@ -1211,7 +1226,7 @@ case 'deleteBackup': require_once("/usr/local/emhttp/plugins/compose.manager/php/backup_functions.php"); - $archive = isset($_POST['archive']) ? urldecode($_POST['archive']) : ''; + $archive = isset($_POST['archive']) ? trim($_POST['archive']) : ''; if (empty($archive)) { echo json_encode(['result' => 'error', 'message' => 'No archive specified.']); break; @@ -1243,6 +1258,31 @@ break; } + // Whitelist allowed setting keys to prevent arbitrary config injection + $allowedKeys = [ + 'BACKUP_DESTINATION', 'BACKUP_RETENTION', + 'BACKUP_SCHEDULE_ENABLED', 'BACKUP_SCHEDULE_FREQUENCY', + 'BACKUP_SCHEDULE_TIME', 'BACKUP_SCHEDULE_DAY' + ]; + $settings = array_intersect_key($settings, array_flip($allowedKeys)); + + // Validate numeric/enum fields + if (isset($settings['BACKUP_RETENTION'])) { + $settings['BACKUP_RETENTION'] = max(0, intval($settings['BACKUP_RETENTION'])); + } + if (isset($settings['BACKUP_SCHEDULE_DAY'])) { + $settings['BACKUP_SCHEDULE_DAY'] = max(0, min(6, intval($settings['BACKUP_SCHEDULE_DAY']))); + } + if (isset($settings['BACKUP_SCHEDULE_FREQUENCY']) && !in_array($settings['BACKUP_SCHEDULE_FREQUENCY'], ['daily', 'weekly'], true)) { + $settings['BACKUP_SCHEDULE_FREQUENCY'] = 'daily'; + } + if (isset($settings['BACKUP_SCHEDULE_ENABLED']) && !in_array($settings['BACKUP_SCHEDULE_ENABLED'], ['true', 'false'], true)) { + $settings['BACKUP_SCHEDULE_ENABLED'] = 'false'; + } + if (isset($settings['BACKUP_SCHEDULE_TIME']) && !preg_match('/^\d{1,2}:\d{2}$/', $settings['BACKUP_SCHEDULE_TIME'])) { + $settings['BACKUP_SCHEDULE_TIME'] = '03:00'; + } + // Write settings to config file $cfgFile = '/boot/config/plugins/compose.manager/compose.manager.cfg'; $existingCfg = is_file($cfgFile) ? parse_ini_file($cfgFile) : []; @@ -1250,6 +1290,8 @@ $lines = []; foreach ($updatedCfg as $key => $value) { + // Sanitize value: strip newlines and quotes to prevent INI injection + $value = str_replace(['"', "\n", "\r"], '', $value); $lines[] = "$key=\"$value\""; } file_put_contents($cfgFile, implode("\n", $lines) . "\n"); diff --git a/source/compose.manager/php/exec_functions.php b/source/compose.manager/php/exec_functions.php index ab007e4..d3a425c 100644 --- a/source/compose.manager/php/exec_functions.php +++ b/source/compose.manager/php/exec_functions.php @@ -58,3 +58,42 @@ function sanitizeFolderName($stackName) { $folderName = preg_replace("/\s/", "_", $folderName); return $folderName; } + +/** + * Build the common compose CLI arguments for a stack. + * + * Resolves the project name, compose/override files, and env-file flag + * from the stack directory. Used by getStackContainers, checkStackUpdates, + * and checkAllStacksUpdates to avoid duplicating this logic. + * + * @param string $stack Stack directory name (basename under $compose_root) + * @return array{projectName: string, files: string, envFile: string} + */ +function buildComposeArgs(string $stack): array { + global $compose_root; + + $projectName = $stack; + if (is_file("$compose_root/$stack/name")) { + $projectName = trim(file_get_contents("$compose_root/$stack/name")); + } + $projectName = sanitizeStr($projectName); + + $basePath = getPath("$compose_root/$stack"); + $composeFile = "$basePath/docker-compose.yml"; + $overrideFile = "$compose_root/$stack/docker-compose.override.yml"; + + $files = "-f " . escapeshellarg($composeFile); + if (is_file($overrideFile)) { + $files .= " -f " . escapeshellarg($overrideFile); + } + + $envFile = ""; + if (is_file("$compose_root/$stack/envpath")) { + $envPath = trim(file_get_contents("$compose_root/$stack/envpath")); + if (is_file($envPath)) { + $envFile = "--env-file " . escapeshellarg($envPath); + } + } + + return ['projectName' => $projectName, 'files' => $files, 'envFile' => $envFile]; +} diff --git a/source/compose.manager/php/icon.php b/source/compose.manager/php/icon.php index 11aef29..c3820e7 100644 --- a/source/compose.manager/php/icon.php +++ b/source/compose.manager/php/icon.php @@ -15,7 +15,7 @@ // Sanitize project name $project = basename($project); -$compose_root = getComposeRoot(); +$compose_root = locate_compose_root('compose.manager'); $projectPath = "$compose_root/$project"; if (!is_dir($projectPath)) { diff --git a/source/compose.manager/scripts/backup_cron.sh b/source/compose.manager/scripts/backup_cron.sh index 432f7e5..f60d79c 100644 --- a/source/compose.manager/scripts/backup_cron.sh +++ b/source/compose.manager/scripts/backup_cron.sh @@ -16,12 +16,12 @@ result=$(php -r " echo json_encode(\$r); ") -# Parse result -status=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['result'] ?? 'error';") -message=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['message'] ?? 'Unknown error';") -archive=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['archive'] ?? '';") -size=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['size'] ?? '';") -stacks=$(echo "$result" | php -r "echo json_decode(file_get_contents('php://stdin'), true)['stacks'] ?? 0;") +# Parse result fields individually to avoid eval +status=$(echo "$result" | php -r '$j = json_decode(file_get_contents("php://stdin"), true) ?: []; echo $j["result"] ?? "error";') +message=$(echo "$result" | php -r '$j = json_decode(file_get_contents("php://stdin"), true) ?: []; echo $j["message"] ?? "Unknown error";') +archive=$(echo "$result" | php -r '$j = json_decode(file_get_contents("php://stdin"), true) ?: []; echo $j["archive"] ?? "";') +size=$(echo "$result" | php -r '$j = json_decode(file_get_contents("php://stdin"), true) ?: []; echo $j["size"] ?? "";') +stacks=$(echo "$result" | php -r '$j = json_decode(file_get_contents("php://stdin"), true) ?: []; echo $j["stacks"] ?? "0";') if [ "$status" = "success" ]; then logger -t "$LOG_TAG" "[backup] Scheduled backup completed: $archive ($size, $stacks stacks)" diff --git a/source/compose.manager/scripts/compose.sh b/source/compose.manager/scripts/compose.sh index ac4425b..f11c09a 100755 --- a/source/compose.manager/scripts/compose.sh +++ b/source/compose.manager/scripts/compose.sh @@ -18,11 +18,11 @@ OPTS=$(getopt -a -n compose --options $SHORT --longoptions $LONG -- "$@") eval set -- "$OPTS" envFile="" -files="" -project_dir="" +env_args=() +file_args=() +profile_args=() +cmd_args=() stack_path="" -options="" -command_options="" debug=false lock_fd="" @@ -95,24 +95,27 @@ save_result() { } # Run command with retry logic for transient failures -# Usage: run_with_retry "command" "description" [retry_on_pattern] +# Usage: run_with_retry "description" command [args...] run_with_retry() { - local cmd="$1" - local desc="$2" - local retry_pattern="${3:-error|timeout|connection refused|no such host|temporary failure}" + local desc="$1" + shift + local retry_pattern="error|timeout|connection refused|no such host|temporary failure" local attempt=1 local exit_code=0 local output="" local temp_file=$(mktemp) + # Build a properly quoted command string for script -c + local cmd_string + cmd_string=$(printf '%q ' "$@") + while [ $attempt -le $MAX_RETRIES ]; do if [ "$debug" = true ]; then log_msg "DEBUG" "Attempt $attempt/$MAX_RETRIES: $desc" fi - # Run command directly (preserves terminal escape sequences for progress bars) - # Use script to capture output while preserving TTY behavior - script -q -c "eval $cmd" "$temp_file" 2>&1 + # Run command via script to preserve TTY escape sequences for progress bars + script -q -c "$cmd_string" "$temp_file" 2>&1 exit_code=$? output=$(cat "$temp_file" | tr -d '\r') @@ -152,21 +155,21 @@ do envFile="$2" shift 2 - if [ -f $envFile ]; then + if [ -f "$envFile" ]; then echo "using .env: $envFile" else echo ".env doesn't exist: $envFile" exit fi - envFile="--env-file ${envFile@Q}" + env_args=("--env-file" "$envFile") ;; -c | --command ) command="$2" shift 2 ;; -f | --file ) - files="${files} -f ${2@Q}" + file_args+=("-f" "$2") shift 2 ;; -p | --project_name ) @@ -175,18 +178,18 @@ do ;; -d | --project_dir ) if [ -d "$2" ]; then - for file in $( find $2 -maxdepth 1 -type f -name '*compose*.yml' ); do - files="$files -f ${file@Q}" + for file in $( find "$2" -maxdepth 1 -type f -name '*compose*.yml' ); do + file_args+=("-f" "$file") done fi shift 2 ;; -g | --profile ) - options="${options} --profile $2" + profile_args+=("--profile" "$2") shift 2 ;; --recreate ) - command_options="${command_options} --force-recreate" + cmd_args+=("--force-recreate") shift; ;; -s | --stack-path ) @@ -203,10 +206,14 @@ do ;; *) echo "Unexpected option: $1" + shift ;; esac done +# Build the compose base command as an array (no eval needed) +compose_base=(docker compose "${env_args[@]}" "${file_args[@]}" "${profile_args[@]}") + # Acquire lock for operations that modify state (not for read-only commands) case $command in up|down|pull|update|stop) @@ -222,10 +229,10 @@ case $command in up) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options -p $name up $command_options -d" + log_msg "DEBUG" "${compose_base[*]} -p $name up ${cmd_args[*]} -d" fi - run_with_retry "docker compose $envFile $files $options -p \"$name\" up $command_options -d" "start stack $name" + run_with_retry "start stack $name" "${compose_base[@]}" -p "$name" up "${cmd_args[@]}" -d exit_code=$? if [ $exit_code -eq 0 ]; then @@ -246,10 +253,10 @@ case $command in down) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options -p $name down" + log_msg "DEBUG" "${compose_base[*]} -p $name down" fi - eval docker compose $envFile $files $options -p "$name" down 2>&1 + "${compose_base[@]}" -p "$name" down 2>&1 exit_code=$? if [ $exit_code -eq 0 ]; then @@ -266,10 +273,10 @@ case $command in pull) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options -p $name pull" + log_msg "DEBUG" "${compose_base[*]} -p $name pull" fi - run_with_retry "docker compose $envFile $files $options -p \"$name\" pull" "pull images for $name" + run_with_retry "pull images for $name" "${compose_base[@]}" -p "$name" pull exit_code=$? if [ $exit_code -eq 0 ]; then @@ -286,24 +293,27 @@ case $command in update) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options -p $name images -q" - log_msg "DEBUG" "docker compose $envFile $files $options -p $name pull" - log_msg "DEBUG" "docker compose $envFile $files $options -p $name up -d --build" + log_msg "DEBUG" "${compose_base[*]} -p $name images -q" + log_msg "DEBUG" "${compose_base[*]} -p $name pull" + log_msg "DEBUG" "${compose_base[*]} -p $name up -d --build" fi # Capture current images for cleanup later images=() - images+=( $(docker compose $envFile $files $options -p "$name" images -q 2>/dev/null) ) - - if [ ${#images[@]} -eq 0 ]; then - delete="-f" - files_arr=( $files ) - files_arr=( ${files_arr[@]/$delete} ) - if (( ${#files_arr[@]} )); then - services=( $(cat ${files_arr[*]//\'/} | sed -n 's/image:\(.*\)/\1/p') ) + images+=( $("${compose_base[@]}" -p "$name" images -q 2>/dev/null) ) + if [ ${#images[@]} -eq 0 ]; then + # Fallback: extract image names from compose files directly + local_files=() + for (( i=0; i<${#file_args[@]}; i++ )); do + if [ "${file_args[$i]}" = "-f" ] && [ -f "${file_args[$((i+1))]}" ]; then + local_files+=("${file_args[$((i+1))]}") + fi + done + if (( ${#local_files[@]} )); then + services=( $(cat "${local_files[@]}" | sed -n 's/image:\(.*\)/\1/p') ) for image in "${services[@]}"; do - images+=( $(docker images -q --no-trunc ${image} 2>/dev/null) ) + images+=( $(docker images -q --no-trunc "${image}" 2>/dev/null) ) done fi @@ -312,7 +322,7 @@ case $command in # Pull with retry logic (most likely to have transient network failures) echo "Pulling latest images..." - run_with_retry "docker compose $envFile $files $options -p \"$name\" pull" "pull images for $name" + run_with_retry "pull images for $name" "${compose_base[@]}" -p "$name" pull pull_exit=$? if [ $pull_exit -ne 0 ]; then @@ -326,12 +336,12 @@ case $command in # Recreate containers with new images echo "" echo "Recreating containers..." - run_with_retry "docker compose $envFile $files $options -p \"$name\" up -d --build" "recreate containers for $name" + run_with_retry "recreate containers for $name" "${compose_base[@]}" -p "$name" up -d --build up_exit=$? if [ $up_exit -eq 0 ]; then # Clean up old images - new_images=( $(docker compose $envFile $files $options -p "$name" images -q 2>/dev/null) ) + new_images=( $("${compose_base[@]}" -p "$name" images -q 2>/dev/null) ) for target in "${new_images[@]}"; do for i in "${!images[@]}"; do if [[ ${images[i]} = $target ]]; then @@ -346,7 +356,7 @@ case $command in fi echo "" echo "Cleaning up old images..." - eval docker rmi ${images[*]} 2>/dev/null || true + docker rmi "${images[@]}" 2>/dev/null || true fi # Save stack started timestamp after update @@ -366,10 +376,10 @@ case $command in stop) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options -p $name stop" + log_msg "DEBUG" "${compose_base[*]} -p $name stop" fi - eval docker compose $envFile $files $options -p "$name" stop 2>&1 + "${compose_base[@]}" -p "$name" stop 2>&1 exit_code=$? if [ $exit_code -eq 0 ]; then @@ -388,7 +398,7 @@ case $command in if [ "$debug" = true ]; then log_msg "DEBUG" "docker compose ls -a --format json" fi - eval docker compose ls -a --format json 2>&1 + docker compose ls -a --format json 2>&1 ;; ps) @@ -396,19 +406,23 @@ case $command in if [ "$debug" = true ]; then log_msg "DEBUG" "docker ps -a --filter label=com.docker.compose.project --format json" fi - eval docker ps -a --filter 'label=com.docker.compose.project' --format json 2>&1 + docker ps -a --filter 'label=com.docker.compose.project' --format json 2>&1 ;; logs) if [ "$debug" = true ]; then - log_msg "DEBUG" "docker compose $envFile $files $options logs -f" + log_msg "DEBUG" "${compose_base[*]} logs -f" + fi + "${compose_base[@]}" logs -f 2>&1 + exit_code=$? + if [ $exit_code -ne 0 ]; then + log_msg "ERROR" "Failed to stream logs (exit code: $exit_code)" fi - eval docker compose $envFile $files $options logs -f 2>&1 ;; *) echo "Unknown command: $command" - log_msg "ERROR" "Unknown command: $command (name: $name, files: $files)" + log_msg "ERROR" "Unknown command: $command (name: $name, files: ${file_args[*]})" exit 1 ;; esac \ No newline at end of file diff --git a/source/compose.manager/scripts/patch.sh b/source/compose.manager/scripts/patch.sh index af8f65b..8ac6617 100644 --- a/source/compose.manager/scripts/patch.sh +++ b/source/compose.manager/scripts/patch.sh @@ -144,7 +144,7 @@ fi candidates=() host_ver_int=$(ver_to_int "${UNRAID_VER:-0.0.0}") if [ -d "$PATCH_ROOT" ]; then - tmpfile=$(mktemp /tmp/patchdirs.XXXX) + tmpfile=$(mktemp /tmp/patchdirs.XXXXXXXX) for d in "$PATCH_ROOT"/*; do [ -d "$d" ] || continue read minI maxI <<< $(parse_folder_range "$d") diff --git a/source/compose.manager/styles/comboButton.css b/source/compose.manager/styles/comboButton.css index 11441bc..44f70ec 100644 --- a/source/compose.manager/styles/comboButton.css +++ b/source/compose.manager/styles/comboButton.css @@ -400,6 +400,9 @@ .compose-ct-table td:nth-child(3) { white-space: normal; word-break: break-all; - color: #606060; text-align: left !important; } +/* Source data cells only — muted color (header inherits theme default) */ +.compose-ct-table td:nth-child(3) { + color: #606060; +} diff --git a/tests/unit/ComposeUtilTest.php b/tests/unit/ComposeUtilTest.php index 82db1a6..be75112 100644 --- a/tests/unit/ComposeUtilTest.php +++ b/tests/unit/ComposeUtilTest.php @@ -70,7 +70,7 @@ public function testEchoComposeCommandArrayNotStarted(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($tempDir . '/test-stack'); + $_POST['path'] = $tempDir . '/test-stack'; // Capture output ob_start(); @@ -108,7 +108,7 @@ public function testEchoComposeCommandNchanFormat(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($stackDir); + $_POST['path'] = $stackDir; $_POST['profile'] = ''; // Capture output @@ -148,8 +148,8 @@ public function testEchoComposeCommandWithProfile(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data with profile - $_POST['path'] = urlencode($stackDir); - $_POST['profile'] = urlencode('dev'); + $_POST['path'] = $stackDir; + $_POST['profile'] = 'dev'; // Capture output ob_start(); @@ -187,8 +187,8 @@ public function testEchoComposeCommandWithMultipleProfiles(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data with multiple profiles - $_POST['path'] = urlencode($stackDir); - $_POST['profile'] = urlencode('dev,prod'); + $_POST['path'] = $stackDir; + $_POST['profile'] = 'dev,prod'; // Capture output ob_start(); @@ -231,7 +231,7 @@ public function testEchoComposeCommandWithIndirect(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($stackDir); + $_POST['path'] = $stackDir; $_POST['profile'] = ''; // Capture output @@ -272,7 +272,7 @@ public function testEchoComposeCommandWithOverrideFile(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($stackDir); + $_POST['path'] = $stackDir; $_POST['profile'] = ''; // Capture output @@ -312,7 +312,7 @@ public function testEchoComposeCommandWithEnvPath(): void \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($stackDir); + $_POST['path'] = $stackDir; $_POST['profile'] = ''; // Capture output @@ -352,7 +352,7 @@ public function testEchoComposeCommandActions(string $action, string $expectedAr \PluginTests\StreamWrapper\UnraidStreamWrapper::addMapping('/var/local/emhttp/var.ini', "$varIniDir/var.ini"); // Set POST data - $_POST['path'] = urlencode($stackDir); + $_POST['path'] = $stackDir; $_POST['profile'] = ''; // Capture output diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index 5b4b053..e649439 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -121,8 +121,8 @@ public function testChangeNameSavesNameFile(): void $stackPath = $this->createTestStack('test-stack'); $output = $this->executeAction('changeName', [ - 'script' => urlencode('test-stack'), - 'newName' => urlencode('My Custom Name'), + 'script' => 'test-stack', + 'newName' => 'My Custom Name', ]); $this->assertFileExists($stackPath . '/name'); @@ -140,8 +140,8 @@ public function testChangeNameTrimsWhitespace(): void $stackPath = $this->createTestStack('test-stack'); $this->executeAction('changeName', [ - 'script' => urlencode('test-stack'), - 'newName' => urlencode(' Trimmed Name '), + 'script' => 'test-stack', + 'newName' => ' Trimmed Name ', ]); $this->assertEquals('Trimmed Name', file_get_contents($stackPath . '/name')); @@ -159,8 +159,8 @@ public function testChangeDescSavesDescription(): void $stackPath = $this->createTestStack('test-stack'); $output = $this->executeAction('changeDesc', [ - 'script' => urlencode('test-stack'), - 'newDesc' => urlencode('This is a test description'), + 'script' => 'test-stack', + 'newDesc' => 'This is a test description', ]); $this->assertFileExists($stackPath . '/description'); @@ -183,7 +183,7 @@ public function testGetDescriptionReturnsContent(): void file_put_contents($stackPath . '/description', 'Test description content'); $output = $this->executeAction('getDescription', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -199,7 +199,7 @@ public function testGetDescriptionEmptyWhenNoFile(): void $this->createTestStack('test-stack'); $output = $this->executeAction('getDescription', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -233,8 +233,8 @@ public function testUpdateAutostartCreatesFile(): void $stackPath = $this->createTestStack('test-stack'); $output = $this->executeAction('updateAutostart', [ - 'script' => urlencode('test-stack'), - 'autostart' => urlencode('true'), + 'script' => 'test-stack', + 'autostart' => 'true', ]); $this->assertFileExists($stackPath . '/autostart'); @@ -253,8 +253,8 @@ public function testUpdateAutostartReplacesExisting(): void file_put_contents($stackPath . '/autostart', 'true'); $this->executeAction('updateAutostart', [ - 'script' => urlencode('test-stack'), - 'autostart' => urlencode('false'), + 'script' => 'test-stack', + 'autostart' => 'false', ]); $this->assertEquals('false', file_get_contents($stackPath . '/autostart')); @@ -275,7 +275,7 @@ public function testGetYmlReturnsContent(): void ]); $output = $this->executeAction('getYml', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -296,7 +296,7 @@ public function testSaveYmlWritesFile(): void $newContent = "services:\n app:\n image: alpine"; $output = $this->executeAction('saveYml', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', 'scriptContents' => $newContent, ]); @@ -318,7 +318,7 @@ public function testGetEnvReturnsContent(): void file_put_contents($stackPath . '/.env', $envContent); $output = $this->executeAction('getEnv', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -337,7 +337,7 @@ public function testGetEnvUsesCustomEnvPath(): void file_put_contents($stackPath . '/envpath', $customEnvPath); $output = $this->executeAction('getEnv', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -358,7 +358,7 @@ public function testSaveEnvWritesFile(): void $envContent = "NEW_VAR=new_value"; $output = $this->executeAction('saveEnv', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', 'scriptContents' => $envContent, ]); @@ -380,7 +380,7 @@ public function testGetOverrideReturnsContent(): void file_put_contents($stackPath . '/docker-compose.override.yml', $overrideContent); $output = $this->executeAction('getOverride', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -396,7 +396,7 @@ public function testGetOverrideEmptyWhenNoFile(): void $this->createTestStack('test-stack'); $output = $this->executeAction('getOverride', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -417,7 +417,7 @@ public function testSaveOverrideWritesFile(): void $overrideContent = "services:\n web:\n volumes:\n - ./data:/data"; $this->executeAction('saveOverride', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', 'scriptContents' => $overrideContent, ]); @@ -430,15 +430,20 @@ public function testSaveOverrideWritesFile(): void /** * Test setEnvPath creates envpath file + * Uses a path under compose_root which is always writable and allowed. */ public function testSetEnvPathCreatesFile(): void { $stackPath = $this->createTestStack('test-stack'); - - $customPath = '/mnt/user/appdata/custom.env'; + + // Use a path under compose_root — always writable and passes validation + $envDir = $this->testComposeRoot . '/envfiles'; + mkdir($envDir, 0755, true); + $customPath = $envDir . '/custom.env'; + $output = $this->executeAction('setEnvPath', [ - 'script' => urlencode('test-stack'), - 'envPath' => urlencode($customPath), + 'script' => 'test-stack', + 'envPath' => $customPath, ]); $this->assertFileExists($stackPath . '/envpath'); @@ -458,7 +463,7 @@ public function testSetEnvPathEmptyReturnsSuccess(): void file_put_contents($stackPath . '/envpath', '/some/path.env'); $output = $this->executeAction('setEnvPath', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', 'envPath' => '', ]); @@ -480,7 +485,7 @@ public function testGetEnvPathReturnsContent(): void file_put_contents($stackPath . '/envpath', $customPath); $output = $this->executeAction('getEnvPath', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -501,7 +506,7 @@ public function testDeleteStackReturnsSuccess(): void $this->createTestStack('test-stack'); $output = $this->executeAction('deleteStack', [ - 'stackName' => urlencode('test-stack'), + 'stackName' => 'test-stack', ]); $result = json_decode($output, true); @@ -519,7 +524,7 @@ public function testDeleteStackWarningForIndirect(): void ]); $output = $this->executeAction('deleteStack', [ - 'stackName' => urlencode('test-stack'), + 'stackName' => 'test-stack', ]); $result = json_decode($output, true); @@ -555,7 +560,7 @@ public function testGetStackSettingsReturnsData(): void file_put_contents($stackPath . '/default_profile', 'production'); $output = $this->executeAction('getStackSettings', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true); @@ -591,9 +596,9 @@ public function testSetStackSettingsSavesSettings(): void $stackPath = $this->createTestStack('test-stack'); $output = $this->executeAction('setStackSettings', [ - 'script' => urlencode('test-stack'), - 'envPath' => urlencode('/new/env/path.env'), - 'defaultProfile' => urlencode('staging'), + 'script' => 'test-stack', + 'envPath' => '/new/env/path.env', + 'defaultProfile' => 'staging', ]); $result = json_decode($output, true); @@ -612,7 +617,7 @@ public function testCheckStackLockReturnsFalseWhenNoLock(): void $this->createTestStack('test-stack'); $output = $this->executeAction('checkStackLock', [ - 'script' => urlencode('test-stack'), + 'script' => 'test-stack', ]); $result = json_decode($output, true);