diff --git a/source/compose.manager/include/CronManager.php b/source/compose.manager/include/CronManager.php index a0970461..92b13885 100644 --- a/source/compose.manager/include/CronManager.php +++ b/source/compose.manager/include/CronManager.php @@ -9,6 +9,7 @@ require_once("/usr/local/emhttp/plugins/compose.manager/include/Defines.php"); require_once("/usr/local/emhttp/plugins/compose.manager/include/Util.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/GitStackSettings.php"); /** * Factory function to create a CronManager instance. @@ -240,7 +241,7 @@ private function addGitCheckLineIfWanted(array &$lines): void } foreach (glob(rtrim($this->composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) { // Read as plain JSON: the scheduled check itself validates each stack's settings. - $settings = json_decode((string) @file_get_contents($gitSettingsFile), true); + $settings = GitStackSettings::readAsPlainJson($gitSettingsFile); if (is_array($settings) && is_string($settings['autoDeploy'] ?? null) && $settings['autoDeploy'] !== 'off') { $command = $this->pluginRoot . 'scripts/compose-git'; // Every 15 minutes, 7 minutes off the image auto-update's quarter hours, so a git diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index b5186107..6eb6ca5a 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -201,7 +201,7 @@ function composeBuildCredentialStackMap(string $composeRoot): array } foreach (glob(rtrim($composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) { // Read as plain JSON: a git.json that fails validation still keeps its credential in use. - $gitSettings = json_decode((string) @file_get_contents($gitSettingsFile), true); + $gitSettings = GitStackSettings::readAsPlainJson($gitSettingsFile); $credentialId = is_array($gitSettings) ? ($gitSettings['credentialId'] ?? null) : null; if (!is_string($credentialId) || $credentialId === '') { continue; @@ -215,6 +215,32 @@ function composeBuildCredentialStackMap(string $composeRoot): array } } +if (!function_exists('composeListUnreadableGitStacksThatMayUse')) { + /** + * The git stacks whose git.json cannot be read as JSON (a typo from a hand edit, say) but may + * still name this credential. composeBuildCredentialStackMap() cannot see them. + * + * Such a file may name it when its text contains the credential id, when the file cannot be + * read at all, or when it holds NUL bytes (saved as UTF-16, so the id cannot be searched for). + * + * @return string[] Stack folder names + */ + function composeListUnreadableGitStacksThatMayUse(string $composeRoot, string $credentialId): array + { + $stacks = []; + foreach (glob(rtrim($composeRoot, '/') . '/*/git.json') ?: [] as $gitSettingsFile) { + if (GitStackSettings::readAsPlainJson($gitSettingsFile) !== null) { + continue; + } + $content = @file_get_contents($gitSettingsFile); + if ($content === false || str_contains($content, "\0") || str_contains($content, $credentialId)) { + $stacks[] = basename(dirname($gitSettingsFile)); + } + } + return $stacks; + } +} + if (!function_exists('composeResolveContainerIcon')) { /** * Resolve container icon using stack cache first, then docker inspect fallback. @@ -1074,13 +1100,47 @@ function composeResolveContainerIcon(string $containerName, string $service, arr case 'deleteCredential': $credentialId = trim((string) ($_POST['id'] ?? '')); try { - $deleted = CredentialVault::withCredentialAssignmentLock(function () use ($compose_root, $credentialId): bool { + $refusal = null; + $deleted = CredentialVault::withCredentialAssignmentLock(function () use ($compose_root, $credentialId, &$refusal): bool { + // A projects folder on the array is gone while the array is stopped: no stack would be + // found, and every credential would look unused. + if (!is_dir($compose_root)) { + $refusal = "The projects folder ($compose_root) is not there, so the stacks that use this credential " + . 'cannot be checked. If it is on the array, start the array first.'; + return false; + } $stacks = composeBuildCredentialStackMap($compose_root)[$credentialId] ?? []; if (!empty($stacks)) { throw new RuntimeException('Credential is assigned to: ' . implode(', ', $stacks)); } + // A git stack whose git.json cannot be read may use this credential: do not delete + // it from under that stack. Registry credentials are never in a git.json. + // Looked up in the list, not with getCredentialSummary(), so a missing id is still "not found". + $provider = ''; + foreach ((new CredentialVault())->listCredentials() as $credential) { + if (($credential['id'] ?? '') === $credentialId) { + $provider = $credential['provider'] ?? ''; + } + } + $unreadable = in_array($provider, ['git', 'git-ssh'], true) + ? composeListUnreadableGitStacksThatMayUse($compose_root, $credentialId) + : []; + if (count($unreadable) === 1) { + $refusal = "The git settings (git.json) of {$unreadable[0]} could not be read, so it may use this credential. " + . "Fix the file first: the stack's Sources tab, or compose-git status, shows the error."; + return false; + } + if (count($unreadable) > 1) { + $refusal = 'The git settings (git.json) of ' . implode(', ', $unreadable) . ' could not be read, so they may use ' + . "this credential. Fix the files first: each stack's Sources tab, or compose-git status, shows the error."; + return false; + } return (new CredentialVault())->deleteCredential($credentialId); }); + if ($refusal !== null) { + echo json_encode(['result' => 'error', 'message' => $refusal]); + break; + } if ($deleted) { composeLogger('Deleted credential', ['id' => $credentialId], 'user', 'info', 'credential'); } diff --git a/source/compose.manager/include/GitStackManager.php b/source/compose.manager/include/GitStackManager.php index 2bf8f3fc..1ee49b43 100644 --- a/source/compose.manager/include/GitStackManager.php +++ b/source/compose.manager/include/GitStackManager.php @@ -927,8 +927,7 @@ private function gitStackUrl(string $folder): ?string */ private function readGitJson(string $folder): ?array { - $data = json_decode((string) @file_get_contents($this->composeRoot . '/' . $folder . '/' . GitStackSettings::FILE_NAME), true); - return is_array($data) ? $data : null; + return GitStackSettings::readAsPlainJson($this->composeRoot . '/' . $folder . '/' . GitStackSettings::FILE_NAME); } /** diff --git a/source/compose.manager/include/GitStackSettings.php b/source/compose.manager/include/GitStackSettings.php index cf93fb22..83d3afd3 100644 --- a/source/compose.manager/include/GitStackSettings.php +++ b/source/compose.manager/include/GitStackSettings.php @@ -181,6 +181,29 @@ public function isSsh(): bool return self::isSshUrl($this->url); } + /** + * Read a git.json as plain JSON, without checking its settings. + * + * For code that only needs one value and must not depend on the file being + * valid, such as finding which credentials are in use. Like load(), it + * accepts a file that starts with a byte order mark. + * + * @return array|null null when the file cannot be read or is not a JSON object or list + */ + public static function readAsPlainJson(string $file): ?array + { + $content = @file_get_contents($file); + if ($content === false) { + return null; + } + // A file saved by a Windows editor may start with a byte order mark. + if (str_starts_with($content, "\xEF\xBB\xBF")) { + $content = substr($content, 3); + } + $data = json_decode($content, true); + return is_array($data) ? $data : null; + } + /** * Load a stack's git settings. * diff --git a/source/compose.manager/javascript/credentialManager.js b/source/compose.manager/javascript/credentialManager.js index 4c30d13e..3e51a617 100644 --- a/source/compose.manager/javascript/credentialManager.js +++ b/source/compose.manager/javascript/credentialManager.js @@ -548,12 +548,27 @@ } swal({ title: 'Delete credential?', text: credential.name, type: 'warning', showCancelButton: true }, function(confirmed) { if (!confirmed) return; - $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'deleteCredential', id: credential.id }).done(function() { + $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'deleteCredential', id: credential.id }).done(function(data) { + var response; + try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; } + if (response.result !== 'success') { + showDeleteError(response.message || 'Unknown error.'); + } + loadCredentials(); + }).fail(function() { + showDeleteError('Unable to reach credential service.'); loadCredentials(); }); }); } + function showDeleteError(message) { + // Wait for the confirm dialog to finish closing, or SweetAlert can hide this one with it. + setTimeout(function() { + swal({ title: 'Unable to delete credential', text: message, type: 'error' }); + }, 300); + } + function populateSelect($select, selectedId) { $select.empty().append($('