From 3902eb6da47f3eaa3c3424aa23dd323ac5fa2256 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 13 Sep 2026 14:22:00 -0400 Subject: [PATCH 01/18] feat: add registry credential backend and runtime --- source/compose.manager/default.cfg | 1 + source/compose.manager/event/docker_started | 3 + .../include/AutoUpdateRunner.php | 5 + .../include/ComposeCommandBuilder.php | 4 + .../include/CredentialVault.php | 220 ++++++++++++++++++ source/compose.manager/include/Defines.php | 4 + source/compose.manager/include/Exec.php | 100 ++++++++ .../include/GitHubDeviceAuth.php | 168 +++++++++++++ source/compose.manager/include/Helpers.php | 16 ++ source/compose.manager/include/Util.php | 6 + source/compose.manager/scripts/common.sh | 6 + source/compose.manager/scripts/compose.sh | 25 +- .../compose.manager/scripts/compose_args.php | 1 + .../scripts/compose_autoupdate.sh | 18 ++ .../scripts/credential_config.php | 21 ++ tests/bootstrap.php | 4 + tests/unit/ComposeCommandBuilderTest.php | 15 ++ tests/unit/CredentialVaultTest.php | 78 +++++++ tests/unit/ExecActionsTest.php | 29 +++ tests/unit/GitHubDeviceAuthTest.php | 58 +++++ tests/unit/compose.bats | 2 +- 21 files changed, 781 insertions(+), 3 deletions(-) create mode 100644 source/compose.manager/include/CredentialVault.php create mode 100644 source/compose.manager/include/GitHubDeviceAuth.php create mode 100644 source/compose.manager/scripts/credential_config.php create mode 100644 tests/unit/CredentialVaultTest.php create mode 100644 tests/unit/GitHubDeviceAuthTest.php diff --git a/source/compose.manager/default.cfg b/source/compose.manager/default.cfg index 55725a3c..98096205 100755 --- a/source/compose.manager/default.cfg +++ b/source/compose.manager/default.cfg @@ -32,3 +32,4 @@ ONLY_EXPAND_RUNNING_STACKS="false" COMPOSE_STATS_RATE_MODE="live" COMPOSE_STATS_CUSTOM_INTERVAL_MS="1000" DONT_CLOSE_EDITOR_MODAL_ON_OUTSIDE_CLICK="false" +GITHUB_OAUTH_CLIENT_ID="Ov23lip7HwI4IX2ueC97" diff --git a/source/compose.manager/event/docker_started b/source/compose.manager/event/docker_started index bfdbdacd..42afb807 100755 --- a/source/compose.manager/event/docker_started +++ b/source/compose.manager/event/docker_started @@ -205,6 +205,9 @@ start_stack() { if [ -n "$COMPOSE_SPEC_ENV_FILE_PATH" ]; then cmd_args+=(-e "$COMPOSE_SPEC_ENV_FILE_PATH") fi + if [ -n "$COMPOSE_SPEC_CREDENTIAL_ID" ]; then + cmd_args+=(--credential-id "$COMPOSE_SPEC_CREDENTIAL_ID") + fi for profile in "${COMPOSE_SPEC_PROFILES[@]}"; do cmd_args+=(-g "$profile") done diff --git a/source/compose.manager/include/AutoUpdateRunner.php b/source/compose.manager/include/AutoUpdateRunner.php index f817f63d..2d426b39 100644 --- a/source/compose.manager/include/AutoUpdateRunner.php +++ b/source/compose.manager/include/AutoUpdateRunner.php @@ -128,10 +128,12 @@ $composeFileList = $stackInfo->buildComposeFileList(); $envFilePath = $args['envFilePath'] ?? null; $projectDirectory = $args['projectDirectory']; + $credentialId = $stackInfo->getCredentialId(); } else { $composeFileList = findComposeFile($path); $envFilePath = null; $projectDirectory = $path; + $credentialId = null; } // Allow overriding the shell command via environment for tests; default to sh @@ -147,6 +149,9 @@ if ($composeFileList === '' && $projectDirectory !== '') { $envPrefix .= 'COMPOSE_PROJECT_DIR=' . escapeshellarg($projectDirectory) . ' '; } + if ($credentialId !== null && $credentialId !== '') { + $envPrefix .= 'COMPOSE_CREDENTIAL_ID=' . escapeshellarg($credentialId) . ' '; + } $cmd = $envPrefix . $shCmd . ' ' . escapeshellarg($script) . " " . escapeshellarg($projectName) . " >/dev/null 2>&1 &"; exec($cmd); diff --git a/source/compose.manager/include/ComposeCommandBuilder.php b/source/compose.manager/include/ComposeCommandBuilder.php index 2177be3e..642f8d3f 100644 --- a/source/compose.manager/include/ComposeCommandBuilder.php +++ b/source/compose.manager/include/ComposeCommandBuilder.php @@ -27,6 +27,9 @@ public static function buildForAction(StackInfo $stackInfo, string $action, ?str self::assertResolvedIdentity($stackInfo, $action); $args = $stackInfo->buildComposeArgs(); + $credentialId = in_array($action, ['up', 'update', 'pull'], true) + ? trim((string) ($stackInfo->getCredentialId() ?? '')) + : ''; return [ 'action' => $action, @@ -37,6 +40,7 @@ public static function buildForAction(StackInfo $stackInfo, string $action, ?str 'useDefaultFileDiscovery' => $args['useDefaultFileDiscovery'], 'profiles' => self::resolveProfilesForAction($stackInfo, $action), 'stackPath' => $stackPath ?? $stackInfo->path, + 'credentialId' => $credentialId, ]; } diff --git a/source/compose.manager/include/CredentialVault.php b/source/compose.manager/include/CredentialVault.php new file mode 100644 index 00000000..6596715c --- /dev/null +++ b/source/compose.manager/include/CredentialVault.php @@ -0,0 +1,220 @@ +> */ + public function listCredentials(): array + { + return array_map(static function (array $credential): array { + unset($credential['secret']); + return $credential; + }, $this->readVault()); + } + + /** @param array $input */ + public function saveCredential(array $input): array + { + $credentials = $this->readVault(); + $id = trim($input['id'] ?? ''); + $existingIndex = null; + foreach ($credentials as $index => $credential) { + if (($credential['id'] ?? '') === $id && $id !== '') { + $existingIndex = $index; + break; + } + } + + $existing = $existingIndex !== null ? $credentials[$existingIndex] : []; + $secret = trim($input['secret'] ?? ''); + if ($secret === '') { + $secret = (string) ($existing['secret'] ?? ''); + } + + $name = trim($input['name'] ?? (string) ($existing['name'] ?? '')); + $registry = self::normalizeRegistry($input['registry'] ?? (string) ($existing['registry'] ?? '')); + $username = trim($input['username'] ?? (string) ($existing['username'] ?? '')); + $provider = strtolower(trim($input['provider'] ?? (string) ($existing['provider'] ?? 'generic'))); + if ($name === '' || $registry === '' || $username === '' || $secret === '') { + throw new InvalidArgumentException('Name, registry, username, and token are required.'); + } + if (!in_array($provider, ['github', 'docker', 'generic'], true)) { + throw new InvalidArgumentException('Unsupported credential provider.'); + } + + $now = gmdate('c'); + $credential = [ + 'id' => $id !== '' ? $id : bin2hex(random_bytes(16)), + 'name' => $name, + 'provider' => $provider, + 'registry' => $registry, + 'username' => $username, + 'secret' => $secret, + 'createdAt' => (string) ($existing['createdAt'] ?? $now), + 'updatedAt' => $now, + ]; + + if ($existingIndex === null) { + $credentials[] = $credential; + } else { + $credentials[$existingIndex] = $credential; + } + $this->writeVault($credentials); + + unset($credential['secret']); + return $credential; + } + + public function deleteCredential(string $id): bool + { + $credentials = $this->readVault(); + $filtered = array_values(array_filter($credentials, static fn(array $credential): bool => ($credential['id'] ?? '') !== $id)); + if (count($filtered) === count($credentials)) { + return false; + } + $this->writeVault($filtered); + return true; + } + + public function hasCredential(string $id): bool + { + if ($id === '') { + return false; + } + foreach ($this->readVault() as $credential) { + if (($credential['id'] ?? '') === $id) { + return true; + } + } + return false; + } + + public function materializeDockerConfig(string $id): string + { + $credential = $this->findCredential($id); + $baseDir = rtrim(COMPOSE_DOCKER_CONFIG_DIR, '/'); + $directory = $baseDir . '/' . bin2hex(random_bytes(16)); + if (!is_dir($baseDir) && !mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { + throw new RuntimeException('Unable to create Docker credential directory.'); + } + chmod($baseDir, 0700); + if (!mkdir($directory, 0700)) { + throw new RuntimeException('Unable to create temporary Docker config.'); + } + + $auth = base64_encode($credential['username'] . ':' . $credential['secret']); + $json = json_encode(['auths' => [$credential['registry'] => ['auth' => $auth]]], JSON_UNESCAPED_SLASHES); + if ($json === false || file_put_contents($directory . '/config.json', $json, LOCK_EX) === false) { + @rmdir($directory); + throw new RuntimeException('Unable to write temporary Docker config.'); + } + chmod($directory . '/config.json', 0600); + return $directory; + } + + public static function removeDockerConfig(string $directory): void + { + $baseDir = realpath(COMPOSE_DOCKER_CONFIG_DIR); + $target = realpath($directory); + if ($baseDir === false || $target === false || dirname($target) !== $baseDir) { + return; + } + @unlink($target . '/config.json'); + @rmdir($target); + } + + private static function normalizeRegistry(string $registry): string + { + $registry = strtolower(trim($registry)); + $registry = preg_replace('#^https?://#', '', $registry) ?? ''; + $registry = rtrim($registry, '/'); + if ($registry === 'docker.io' || $registry === 'registry-1.docker.io') { + return 'https://index.docker.io/v1/'; + } + if ($registry === '' || preg_match('/[\s?#]/', $registry)) { + return ''; + } + return $registry; + } + + /** @return array */ + private function findCredential(string $id): array + { + foreach ($this->readVault() as $credential) { + if (($credential['id'] ?? '') === $id) { + return $credential; + } + } + throw new RuntimeException('Selected credential no longer exists.'); + } + + /** @return array> */ + private function readVault(): array + { + if (!is_file(COMPOSE_CREDENTIAL_VAULT_FILE)) { + return []; + } + $payload = json_decode((string) file_get_contents(COMPOSE_CREDENTIAL_VAULT_FILE), true); + if (!is_array($payload) || !isset($payload['nonce'], $payload['ciphertext'])) { + throw new RuntimeException('Credential vault is invalid.'); + } + $nonce = base64_decode((string) $payload['nonce'], true); + $ciphertext = base64_decode((string) $payload['ciphertext'], true); + if ($nonce === false || $ciphertext === false) { + throw new RuntimeException('Credential vault is invalid.'); + } + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->loadKey()); + if ($plaintext === false) { + throw new RuntimeException('Credential vault could not be decrypted.'); + } + $credentials = json_decode($plaintext, true); + return is_array($credentials) ? array_values($credentials) : []; + } + + /** @param array> $credentials */ + private function writeVault(array $credentials): void + { + $directory = dirname(COMPOSE_CREDENTIAL_VAULT_FILE); + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException('Unable to create credential storage directory.'); + } + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $plaintext = json_encode(array_values($credentials), JSON_UNESCAPED_SLASHES); + if ($plaintext === false) { + throw new RuntimeException('Unable to encode credential vault.'); + } + $payload = json_encode([ + 'version' => 1, + 'nonce' => base64_encode($nonce), + 'ciphertext' => base64_encode(sodium_crypto_secretbox($plaintext, $nonce, $this->loadKey())), + ], JSON_UNESCAPED_SLASHES); + if ($payload === false || file_put_contents(COMPOSE_CREDENTIAL_VAULT_FILE, $payload, LOCK_EX) === false) { + throw new RuntimeException('Unable to save credential vault.'); + } + chmod(COMPOSE_CREDENTIAL_VAULT_FILE, 0600); + } + + private function loadKey(): string + { + if (is_file(COMPOSE_CREDENTIAL_KEY_FILE)) { + $key = base64_decode(trim((string) file_get_contents(COMPOSE_CREDENTIAL_KEY_FILE)), true); + if ($key !== false && strlen($key) === SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + return $key; + } + throw new RuntimeException('Credential encryption key is invalid.'); + } + $directory = dirname(COMPOSE_CREDENTIAL_KEY_FILE); + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException('Unable to create credential storage directory.'); + } + $key = random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES); + if (file_put_contents(COMPOSE_CREDENTIAL_KEY_FILE, base64_encode($key), LOCK_EX) === false) { + throw new RuntimeException('Unable to save credential encryption key.'); + } + chmod(COMPOSE_CREDENTIAL_KEY_FILE, 0600); + return $key; + } +} \ No newline at end of file diff --git a/source/compose.manager/include/Defines.php b/source/compose.manager/include/Defines.php index 1498e384..9870a29c 100644 --- a/source/compose.manager/include/Defines.php +++ b/source/compose.manager/include/Defines.php @@ -32,6 +32,10 @@ function locate_compose_root($name) defined('COMPOSE_DM_ICON_PERSIST_DIR') || define('COMPOSE_DM_ICON_PERSIST_DIR', '/var/lib/docker/unraid/images'); defined('COMPOSE_DM_WEBUI_INFO_FILE') || define('COMPOSE_DM_WEBUI_INFO_FILE', '/usr/local/emhttp/state/plugins/dynamix.docker.manager/docker.json'); defined('COMPOSE_DOCKER_LABEL_ICON') || define('COMPOSE_DOCKER_LABEL_ICON', 'net.unraid.docker.icon'); +defined('COMPOSE_CREDENTIAL_VAULT_FILE') || define('COMPOSE_CREDENTIAL_VAULT_FILE', '/boot/config/plugins/compose.manager/credentials.vault'); +defined('COMPOSE_CREDENTIAL_KEY_FILE') || define('COMPOSE_CREDENTIAL_KEY_FILE', '/boot/config/plugins/compose.manager/credentials.key'); +defined('COMPOSE_DOCKER_CONFIG_DIR') || define('COMPOSE_DOCKER_CONFIG_DIR', '/var/tmp/compose-manager-docker-config'); +defined('COMPOSE_GITHUB_DEVICE_DIR') || define('COMPOSE_GITHUB_DEVICE_DIR', '/var/tmp/compose-manager-github-device'); /** * Reserved filename at the compose root level used by the plugin installer diff --git a/source/compose.manager/include/Exec.php b/source/compose.manager/include/Exec.php index afc91688..fe96ff65 100644 --- a/source/compose.manager/include/Exec.php +++ b/source/compose.manager/include/Exec.php @@ -3,6 +3,8 @@ 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/ColumnLayout.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/CredentialVault.php"); +require_once("/usr/local/emhttp/plugins/compose.manager/include/GitHubDeviceAuth.php"); require_once("/usr/local/emhttp/plugins/dynamix/include/Wrappers.php"); require_once('/usr/local/emhttp/plugins/dynamix.docker.manager/include/DockerClient.php'); @@ -980,6 +982,83 @@ function composeResolveContainerIcon(string $containerName, string $service, arr } echo json_encode(['result' => 'success', 'fileName' => "$fileName", 'content' => $fileContents]); break; + case 'listCredentials': + try { + $credentials = (new CredentialVault())->listCredentials(); + foreach ($credentials as &$credential) { + $credential['stacks'] = []; + foreach (glob(rtrim($compose_root, '/') . '/*/credential_id') ?: [] as $credentialFile) { + if (trim((string) file_get_contents($credentialFile)) === $credential['id']) { + $credential['stacks'][] = basename(dirname($credentialFile)); + } + } + } + unset($credential); + echo json_encode(['result' => 'success', 'credentials' => $credentials]); + } catch (\Throwable $error) { + composeLogger('Unable to list credentials', ['error' => $error->getMessage()], 'user', 'error', 'credentials'); + echo json_encode(['result' => 'error', 'message' => 'Unable to read credential vault.']); + } + break; + case 'startGitHubDeviceAuth': + try { + $cfg = parse_plugin_cfg($sName); + $auth = new GitHubDeviceAuth((string) ($cfg['GITHUB_OAUTH_CLIENT_ID'] ?? '')); + echo json_encode(['result' => 'success', 'device' => $auth->start()]); + } catch (\Throwable $error) { + composeLogger('Unable to start GitHub sign-in', ['error' => $error->getMessage()], 'user', 'warning', 'credentials'); + echo json_encode(['result' => 'error', 'message' => $error->getMessage()]); + } + break; + case 'pollGitHubDeviceAuth': + try { + $cfg = parse_plugin_cfg($sName); + $auth = new GitHubDeviceAuth((string) ($cfg['GITHUB_OAUTH_CLIENT_ID'] ?? '')); + echo json_encode(['result' => 'success', 'auth' => $auth->poll(trim((string) ($_POST['state'] ?? '')))]); + } catch (\Throwable $error) { + composeLogger('Unable to complete GitHub sign-in', ['error' => $error->getMessage()], 'user', 'warning', 'credentials'); + echo json_encode(['result' => 'error', 'message' => $error->getMessage()]); + } + break; + case 'saveCredential': + try { + $credential = (new CredentialVault())->saveCredential([ + 'id' => trim((string) ($_POST['id'] ?? '')), + 'name' => trim((string) ($_POST['name'] ?? '')), + 'provider' => trim((string) ($_POST['provider'] ?? 'generic')), + 'registry' => trim((string) ($_POST['registry'] ?? '')), + 'username' => trim((string) ($_POST['username'] ?? '')), + 'secret' => trim((string) ($_POST['secret'] ?? '')), + ]); + composeLogger('Saved registry credential', ['id' => $credential['id'], 'provider' => $credential['provider'], 'registry' => $credential['registry']], 'user', 'info', 'credentials'); + echo json_encode(['result' => 'success', 'credential' => $credential]); + } catch (\InvalidArgumentException $error) { + echo json_encode(['result' => 'error', 'message' => $error->getMessage()]); + } catch (\Throwable $error) { + composeLogger('Unable to save credential', ['error' => $error->getMessage()], 'user', 'error', 'credentials'); + echo json_encode(['result' => 'error', 'message' => 'Unable to save credential.']); + } + break; + case 'deleteCredential': + $credentialId = trim((string) ($_POST['id'] ?? '')); + $stacks = []; + foreach (glob(rtrim($compose_root, '/') . '/*/credential_id') ?: [] as $credentialFile) { + if (trim((string) file_get_contents($credentialFile)) === $credentialId) { + $stacks[] = basename(dirname($credentialFile)); + } + } + if (!empty($stacks)) { + echo json_encode(['result' => 'error', 'message' => 'Credential is assigned to: ' . implode(', ', $stacks), 'stacks' => $stacks]); + break; + } + try { + $deleted = (new CredentialVault())->deleteCredential($credentialId); + echo json_encode(['result' => $deleted ? 'success' : 'error', 'message' => $deleted ? '' : 'Credential not found.']); + } catch (\Throwable $error) { + composeLogger('Unable to delete credential', ['error' => $error->getMessage()], 'user', 'error', 'credentials'); + echo json_encode(['result' => 'error', 'message' => 'Unable to delete credential.']); + } + break; case 'getStackSettings': $script = getPostScript(); if (!$script) { @@ -1023,6 +1102,9 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $extraComposeFilesFile = "$compose_root/$script/extra_compose_files"; $extraComposeFiles = is_file($extraComposeFilesFile) ? trim(file_get_contents($extraComposeFilesFile)) : ""; + $credentialIdFile = "$compose_root/$script/credential_id"; + $credentialId = is_file($credentialIdFile) ? trim(file_get_contents($credentialIdFile)) : ""; + // Candidate compose files in the compose source folder for the // Additional Compose Files selector (*compose*.y(a)ml, excluding the // main compose file and override files) @@ -1110,6 +1192,7 @@ function composeResolveContainerIcon(string $containerName, string $service, arr 'waitTimeout' => $waitTimeout, 'buildOnUpdate' => ($buildOnUpdate === 'true' || $buildOnUpdate === '1'), 'extraComposeFiles' => $extraComposeFiles, + 'credentialId' => $credentialId, 'composeFileCandidates' => $composeFileCandidates, 'editableComposeFiles' => $stackInfo->getEditableComposeFiles(), 'labelsViewMode' => $labelsViewMode, @@ -1223,6 +1306,12 @@ function composeResolveContainerIcon(string $containerName, string $service, arr $waitForHealthy = isset($_POST['waitForHealthy']) ? strtolower(trim((string) $_POST['waitForHealthy'])) : "false"; $waitTimeout = isset($_POST['waitTimeout']) ? trim((string) $_POST['waitTimeout']) : ""; $buildOnUpdate = isset($_POST['buildOnUpdate']) ? strtolower(trim((string) $_POST['buildOnUpdate'])) : "false"; + $credentialIdProvided = isset($_POST['credentialId']); + $credentialId = $credentialIdProvided ? trim((string) $_POST['credentialId']) : ''; + if ($credentialId !== '' && !(new CredentialVault())->hasCredential($credentialId)) { + echo json_encode(['result' => 'error', 'message' => 'Selected credential no longer exists.']); + break; + } $useDefaultComposeFiles = isset($_POST['useDefaultComposeFiles']) && strtolower(trim((string) $_POST['useDefaultComposeFiles'])) === 'true'; @@ -1401,6 +1490,17 @@ function composeResolveContainerIcon(string $containerName, string $service, arr } } + if ($credentialIdProvided) { + $credentialIdFile = "$compose_root/$script/credential_id"; + if ($credentialId === '') { + if (is_file($credentialIdFile)) { + @unlink($credentialIdFile); + } + } else { + file_put_contents($credentialIdFile, $credentialId); + } + } + // Set compose file discovery mode $useDefaultComposeFilesFile = "$compose_root/$script/use_default_compose_files"; if ($useDefaultComposeFiles) { diff --git a/source/compose.manager/include/GitHubDeviceAuth.php b/source/compose.manager/include/GitHubDeviceAuth.php new file mode 100644 index 00000000..048ef9e9 --- /dev/null +++ b/source/compose.manager/include/GitHubDeviceAuth.php @@ -0,0 +1,168 @@ +clientId = trim($clientId); + $this->vault = $vault ?? new CredentialVault(); + $this->request = $request ?? [$this, 'curlRequest']; + } + + /** @return array */ + public function start(): array + { + if ($this->clientId === '') { + throw new RuntimeException('GitHub sign-in is not configured.'); + } + $response = ($this->request)('POST', 'https://github.com/login/device/code', [ + 'client_id' => $this->clientId, + 'scope' => 'read:packages', + ], []); + foreach (['device_code', 'user_code', 'verification_uri', 'expires_in'] as $field) { + if (empty($response[$field])) { + throw new RuntimeException('GitHub returned an invalid device authorization response.'); + } + } + $state = bin2hex(random_bytes(24)); + $interval = max(5, (int) ($response['interval'] ?? 5)); + $this->writeState($state, [ + 'deviceCode' => (string) $response['device_code'], + 'expiresAt' => time() + (int) $response['expires_in'], + 'interval' => $interval, + 'nextPollAt' => 0, + ]); + return [ + 'state' => $state, + 'userCode' => (string) $response['user_code'], + 'verificationUri' => (string) $response['verification_uri'], + 'expiresIn' => (int) $response['expires_in'], + 'interval' => $interval, + ]; + } + + /** @return array */ + public function poll(string $state): array + { + $session = $this->readState($state); + if ((int) $session['expiresAt'] <= time()) { + $this->deleteState($state); + return ['status' => 'expired']; + } + if ((int) $session['nextPollAt'] > time()) { + return ['status' => 'pending', 'interval' => (int) $session['interval']]; + } + $session['nextPollAt'] = time() + (int) $session['interval']; + $this->writeState($state, $session); + + $response = ($this->request)('POST', 'https://github.com/login/oauth/access_token', [ + 'client_id' => $this->clientId, + 'device_code' => (string) $session['deviceCode'], + 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', + ], []); + $error = (string) ($response['error'] ?? ''); + if ($error === 'authorization_pending') { + return ['status' => 'pending', 'interval' => (int) $session['interval']]; + } + if ($error === 'slow_down') { + $session['interval'] = (int) $session['interval'] + 5; + $this->writeState($state, $session); + return ['status' => 'pending', 'interval' => (int) $session['interval']]; + } + if ($error !== '') { + $this->deleteState($state); + return ['status' => $error === 'access_denied' ? 'denied' : 'expired']; + } + $token = trim((string) ($response['access_token'] ?? '')); + if ($token === '') { + throw new RuntimeException('GitHub did not return an access token.'); + } + $user = ($this->request)('GET', 'https://api.github.com/user', [], [ + 'Authorization: Bearer ' . $token, + 'X-GitHub-Api-Version: 2022-11-28', + ]); + $username = trim((string) ($user['login'] ?? '')); + if ($username === '') { + throw new RuntimeException('Unable to read the authorized GitHub account.'); + } + $credential = $this->vault->saveCredential([ + 'name' => 'GitHub - ' . $username, + 'provider' => 'github', + 'registry' => 'ghcr.io', + 'username' => $username, + 'secret' => $token, + ]); + $this->deleteState($state); + return ['status' => 'success', 'credential' => $credential]; + } + + /** @param array $data @param string[] $headers @return array */ + private function curlRequest(string $method, string $url, array $data, array $headers): array + { + $handle = curl_init($url); + $requestHeaders = array_merge(['Accept: application/json', 'User-Agent: Compose-Manager'], $headers); + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CONNECTTIMEOUT => 10, + CURLOPT_TIMEOUT => 20, + CURLOPT_HTTPHEADER => $requestHeaders, + ]); + if ($method === 'POST') { + curl_setopt($handle, CURLOPT_POST, true); + curl_setopt($handle, CURLOPT_POSTFIELDS, http_build_query($data)); + } + $body = curl_exec($handle); + $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE); + $curlError = curl_error($handle); + curl_close($handle); + if ($body === false || $curlError !== '' || $status < 200 || $status >= 300) { + throw new RuntimeException('GitHub authentication request failed.'); + } + $decoded = json_decode((string) $body, true); + if (!is_array($decoded)) { + throw new RuntimeException('GitHub returned an invalid response.'); + } + return $decoded; + } + + /** @param array $session */ + private function writeState(string $state, array $session): void + { + if (!is_dir(COMPOSE_GITHUB_DEVICE_DIR) && !mkdir(COMPOSE_GITHUB_DEVICE_DIR, 0700, true) && !is_dir(COMPOSE_GITHUB_DEVICE_DIR)) { + throw new RuntimeException('Unable to create GitHub sign-in session.'); + } + chmod(COMPOSE_GITHUB_DEVICE_DIR, 0700); + file_put_contents(COMPOSE_GITHUB_DEVICE_DIR . '/' . $state . '.json', json_encode($session), LOCK_EX); + chmod(COMPOSE_GITHUB_DEVICE_DIR . '/' . $state . '.json', 0600); + } + + /** @return array */ + private function readState(string $state): array + { + if (preg_match('/^[a-f0-9]{48}$/', $state) !== 1) { + throw new InvalidArgumentException('Invalid GitHub sign-in session.'); + } + $path = COMPOSE_GITHUB_DEVICE_DIR . '/' . $state . '.json'; + $session = is_file($path) ? json_decode((string) file_get_contents($path), true) : null; + if (!is_array($session)) { + throw new RuntimeException('GitHub sign-in session expired.'); + } + return $session; + } + + private function deleteState(string $state): void + { + if (preg_match('/^[a-f0-9]{48}$/', $state) === 1) { + @unlink(COMPOSE_GITHUB_DEVICE_DIR . '/' . $state . '.json'); + } + } +} \ No newline at end of file diff --git a/source/compose.manager/include/Helpers.php b/source/compose.manager/include/Helpers.php index 94ad947a..7b55ff1d 100644 --- a/source/compose.manager/include/Helpers.php +++ b/source/compose.manager/include/Helpers.php @@ -324,6 +324,14 @@ function echoComposeCommand($action, array $options = []) appendComposeEnvFileArg($composeCommand, $args); + if (in_array($action, ['up', 'update', 'pull'], true)) { + $credentialId = trim((string) ($stackInfo->getCredentialId() ?? '')); + if ($credentialId !== '') { + $composeCommand[] = '--credential-id'; + $composeCommand[] = $credentialId; + } + } + // Support multiple profiles (comma-separated) if ($profile) { $profileList = array_map('trim', explode(',', $profile)); @@ -480,6 +488,14 @@ function echoComposeCommandMultiple($action, array $options = []) appendComposeEnvFileArg($composeCommand, $args); + if (in_array($action, ['up', 'update', 'pull'], true)) { + $credentialId = trim((string) ($stackInfo->getCredentialId() ?? '')); + if ($credentialId !== '') { + $composeCommand[] = '--credential-id'; + $composeCommand[] = $credentialId; + } + } + // Profile selection per action: // - up: use user-configured default profiles (running_profiles // is stale/absent when the stack isn't running). diff --git a/source/compose.manager/include/Util.php b/source/compose.manager/include/Util.php index cd2fd5b7..77cdf42a 100644 --- a/source/compose.manager/include/Util.php +++ b/source/compose.manager/include/Util.php @@ -2587,6 +2587,12 @@ public function getEnvFilePath(): ?string return ($val !== null && $val !== '') ? $val : null; } + public function getCredentialId(): ?string + { + $value = $this->readMetadata('credential_id'); + return ($value !== null && $value !== '') ? $value : null; + } + /** * Resolve the effective env file path for this stack. * diff --git a/source/compose.manager/scripts/common.sh b/source/compose.manager/scripts/common.sh index d4c9d0b7..957003ce 100644 --- a/source/compose.manager/scripts/common.sh +++ b/source/compose.manager/scripts/common.sh @@ -159,6 +159,7 @@ resolve_stack_env_file() { # COMPOSE_SPEC_PROJECT_DIR # COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY # COMPOSE_SPEC_ENV_FILE_PATH +# COMPOSE_SPEC_CREDENTIAL_ID # COMPOSE_SPEC_COMPOSE_FILES (array) # COMPOSE_SPEC_PROFILES (array) # @@ -225,6 +226,7 @@ load_compose_action_spec() { COMPOSE_SPEC_USE_DEFAULT_FILE_DISCOVERY="false" # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. COMPOSE_SPEC_ENV_FILE_PATH="" + COMPOSE_SPEC_CREDENTIAL_ID="" COMPOSE_SPEC_ERROR_MESSAGE="" COMPOSE_SPEC_COMPOSE_FILES=() COMPOSE_SPEC_PROFILES=() @@ -255,6 +257,10 @@ load_compose_action_spec() { # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. COMPOSE_SPEC_ENV_FILE_PATH="$value" ;; + credentialId) + # shellcheck disable=SC2034 # Populated here, consumed by scripts that source common.sh. + COMPOSE_SPEC_CREDENTIAL_ID="$value" + ;; composeFile) COMPOSE_SPEC_COMPOSE_FILES+=("$value") ;; diff --git a/source/compose.manager/scripts/compose.sh b/source/compose.manager/scripts/compose.sh index c4e40a5e..bace9798 100755 --- a/source/compose.manager/scripts/compose.sh +++ b/source/compose.manager/scripts/compose.sh @@ -12,7 +12,7 @@ LOCK_TIMEOUT=${COMPOSE_LOCK_TIMEOUT:-30} LOCK_DIR="/var/run/compose.manager" SHORT=e:,c:,f:,p:,d:,o:,g:,s:,w: -LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,remove-orphans,stack-path:,workdir:,follow-logs,wait,wait-timeout:,build +LONG=env,command:,file:,project_name:,project_dir:,override:,profile:,debug,recreate,remove-orphans,stack-path:,workdir:,follow-logs,wait,wait-timeout:,build,credential-id: OPTS=$(getopt -a -n compose --options $SHORT --longoptions $LONG -- "$@") eval set -- "$OPTS" @@ -32,6 +32,8 @@ wait_timeout="" build_on_update=false lock_fd="" operation_exit_code=0 +credential_id="" +docker_config_dir="" # Logging helper — delegates to shared composeLogger, adds console echo in debug mode @@ -89,7 +91,14 @@ release_lock() { } # Ensure lock is released and follow state is cleared on exit. -trap 'release_lock; clear_follow_pid' EXIT +cleanup_docker_config() { + if [ -n "$docker_config_dir" ]; then + php "$(dirname "$0")/credential_config.php" --remove "$docker_config_dir" >/dev/null 2>&1 || true + docker_config_dir="" + fi +} + +trap 'release_lock; clear_follow_pid; cleanup_docker_config' EXIT # Save operation result to stack directory save_result() { @@ -209,6 +218,10 @@ do build_on_update=true shift; ;; + --credential-id ) + credential_id="$2" + shift 2 + ;; --) shift; break @@ -220,6 +233,14 @@ do esac done +if [ -n "$credential_id" ]; then + if ! docker_config_dir=$(php "$(dirname "$0")/credential_config.php" --credential-id "$credential_id"); then + log_msg "ERROR" "Selected registry credential could not be loaded" + exit 1 + fi + export DOCKER_CONFIG="$docker_config_dir" +fi + # Build docker compose profile flags from canonical profile names. for profile_name in "${profile_names[@]}"; do profile_args+=("--profile" "$profile_name") diff --git a/source/compose.manager/scripts/compose_args.php b/source/compose.manager/scripts/compose_args.php index 24d9b53c..dcd9fcb4 100644 --- a/source/compose.manager/scripts/compose_args.php +++ b/source/compose.manager/scripts/compose_args.php @@ -33,6 +33,7 @@ function emitSuccess(array $data, string $format): void echo "projectDirectory\t" . ($data['projectDirectory'] ?? '') . "\n"; echo "useDefaultFileDiscovery\t" . ((($data['useDefaultFileDiscovery'] ?? false) ? 'true' : 'false')) . "\n"; echo "envFilePath\t" . ($data['envFilePath'] ?? '') . "\n"; + echo "credentialId\t" . ($data['credentialId'] ?? '') . "\n"; foreach (($data['composeFiles'] ?? []) as $filePath) { echo "composeFile\t" . $filePath . "\n"; } diff --git a/source/compose.manager/scripts/compose_autoupdate.sh b/source/compose.manager/scripts/compose_autoupdate.sh index b3f28591..f1b60171 100644 --- a/source/compose.manager/scripts/compose_autoupdate.sh +++ b/source/compose.manager/scripts/compose_autoupdate.sh @@ -11,7 +11,25 @@ PROJECT_NAME="$2" COMPOSE_FILE_LIST="${COMPOSE_FILE_LIST:-}" COMPOSE_ENV_FILE="${COMPOSE_ENV_FILE:-}" COMPOSE_PROJECT_DIR="${COMPOSE_PROJECT_DIR:-}" +COMPOSE_CREDENTIAL_ID="${COMPOSE_CREDENTIAL_ID:-}" COMPOSE_FILE="${COMPOSE_FILE_ARG:-${COMPOSE_FILE:-}}" +DOCKER_CONFIG_DIR="" + +# shellcheck disable=SC2317 # Invoked by EXIT trap. +cleanup_docker_config() { + if [ -n "$DOCKER_CONFIG_DIR" ]; then + php "$(dirname "$0")/credential_config.php" --remove "$DOCKER_CONFIG_DIR" >/dev/null 2>&1 || true + fi +} +trap cleanup_docker_config EXIT + +if [ -n "$COMPOSE_CREDENTIAL_ID" ]; then + if ! DOCKER_CONFIG_DIR=$(php "$(dirname "$0")/credential_config.php" --credential-id "$COMPOSE_CREDENTIAL_ID"); then + composeLogger "Selected registry credential could not be loaded for '$PROJECT_NAME'" error autoupdate daemon + exit 1 + fi + export DOCKER_CONFIG="$DOCKER_CONFIG_DIR" +fi # If this script is invoked by the background runner, the first positional # argument is the project name and compose files are supplied through env vars. diff --git a/source/compose.manager/scripts/credential_config.php b/source/compose.manager/scripts/credential_config.php new file mode 100644 index 00000000..da9b497f --- /dev/null +++ b/source/compose.manager/scripts/credential_config.php @@ -0,0 +1,21 @@ +materializeDockerConfig($id); +} catch (Throwable $error) { + fwrite(STDERR, $error->getMessage() . PHP_EOL); + exit(1); +} \ No newline at end of file diff --git a/tests/bootstrap.php b/tests/bootstrap.php index e0ca4e30..d1d6b9b7 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -43,6 +43,10 @@ function composeLogger($message, $data = null, $type = 'daemon', $level = 'info' define('COMPOSE_DM_ICON_RAM_DIR', sys_get_temp_dir() . '/compose_manager_dm_images_ram'); define('COMPOSE_DM_ICON_PERSIST_DIR', sys_get_temp_dir() . '/compose_manager_dm_images'); define('COMPOSE_DM_WEBUI_INFO_FILE', sys_get_temp_dir() . '/compose_manager_dm_docker.json'); +define('COMPOSE_CREDENTIAL_VAULT_FILE', sys_get_temp_dir() . '/compose_manager_credentials.vault'); +define('COMPOSE_CREDENTIAL_KEY_FILE', sys_get_temp_dir() . '/compose_manager_credentials.key'); +define('COMPOSE_DOCKER_CONFIG_DIR', sys_get_temp_dir() . '/compose_manager_docker_configs'); +define('COMPOSE_GITHUB_DEVICE_DIR', sys_get_temp_dir() . '/compose_manager_github_device'); // Point to the dev-env resvg binary when present; plugin path used on real Unraid define('COMPOSE_RESVG_BIN', is_executable('/tmp/resvg') ? '/tmp/resvg' : '/usr/local/emhttp/plugins/compose.manager/bin/resvg'); diff --git a/tests/unit/ComposeCommandBuilderTest.php b/tests/unit/ComposeCommandBuilderTest.php index 4bcb568b..f2a53164 100644 --- a/tests/unit/ComposeCommandBuilderTest.php +++ b/tests/unit/ComposeCommandBuilderTest.php @@ -86,6 +86,21 @@ public function testBuildForActionUsesRunningProfilesForUpdate(): void $this->assertSame(['hotfix', 'metrics'], $spec['profiles']); } + public function testBuildIncludesCredentialOnlyForRegistryActions(): void + { + $stack = 'credential-action'; + $stackDir = $this->tempRoot . '/' . $stack; + mkdir($stackDir); + file_put_contents($stackDir . '/compose.yaml', "services:\n"); + file_put_contents($stackDir . '/credential_id', 'credential-123'); + + $info = \StackInfo::fromProject($this->tempRoot, $stack); + $this->assertSame('credential-123', \ComposeCommandBuilder::buildForAction($info, 'pull')['credentialId']); + $this->assertSame('credential-123', \ComposeCommandBuilder::buildForAction($info, 'up')['credentialId']); + $this->assertSame('', \ComposeCommandBuilder::buildForAction($info, 'down')['credentialId']); + $this->assertSame('', \ComposeCommandBuilder::buildForAction($info, 'logs')['credentialId']); + } + public function testBuildForActionFallsBackToDefaultProfilesForUpdate(): void { $stack = 'profiles-update-default'; diff --git a/tests/unit/CredentialVaultTest.php b/tests/unit/CredentialVaultTest.php new file mode 100644 index 00000000..59c253c1 --- /dev/null +++ b/tests/unit/CredentialVaultTest.php @@ -0,0 +1,78 @@ +saveCredential([ + 'name' => 'Work GitHub', + 'provider' => 'github', + 'registry' => 'https://ghcr.io/', + 'username' => 'octocat', + 'secret' => 'github-secret-token', + ]); + + $this->assertSame('ghcr.io', $saved['registry']); + $this->assertArrayNotHasKey('secret', $saved); + $this->assertStringNotContainsString('github-secret-token', (string) file_get_contents(COMPOSE_CREDENTIAL_VAULT_FILE)); + $this->assertArrayNotHasKey('secret', $vault->listCredentials()[0]); + } + + public function testMaterializesMinimalDockerConfig(): void + { + $vault = new CredentialVault(); + $saved = $vault->saveCredential([ + 'name' => 'Docker Hub', + 'provider' => 'docker', + 'registry' => 'docker.io', + 'username' => 'user', + 'secret' => 'token', + ]); + + $directory = $vault->materializeDockerConfig($saved['id']); + $config = json_decode((string) file_get_contents($directory . '/config.json'), true); + $this->assertSame(base64_encode('user:token'), $config['auths']['https://index.docker.io/v1/']['auth']); + $this->assertCount(1, $config); + + CredentialVault::removeDockerConfig($directory); + $this->assertDirectoryDoesNotExist($directory); + } + + public function testUpdateWithoutSecretPreservesExistingToken(): void + { + $vault = new CredentialVault(); + $saved = $vault->saveCredential([ + 'name' => 'GitHub', 'provider' => 'github', 'registry' => 'ghcr.io', + 'username' => 'user', 'secret' => 'token', + ]); + $saved['name'] = 'Renamed GitHub'; + $updated = $vault->saveCredential($saved); + + $directory = $vault->materializeDockerConfig($updated['id']); + $config = json_decode((string) file_get_contents($directory . '/config.json'), true); + $this->assertSame(base64_encode('user:token'), $config['auths']['ghcr.io']['auth']); + } +} \ No newline at end of file diff --git a/tests/unit/ExecActionsTest.php b/tests/unit/ExecActionsTest.php index c96caa79..806b0050 100644 --- a/tests/unit/ExecActionsTest.php +++ b/tests/unit/ExecActionsTest.php @@ -47,6 +47,8 @@ protected function setUp(): void FunctionMocks::setPluginConfig('compose.manager', [ 'PROJECTS_FOLDER' => $this->testComposeRoot, ]); + @unlink(COMPOSE_CREDENTIAL_VAULT_FILE); + @unlink(COMPOSE_CREDENTIAL_KEY_FILE); } protected function tearDown(): void @@ -793,6 +795,33 @@ public function testGetStackSettingsReturnsData(): void $this->assertEquals('production', $result['defaultProfile']); } + public function testCredentialCrudAndStackAssignmentRoundTrip(): void + { + $stackPath = $this->createTestStack('test-stack'); + $saveOutput = $this->executeAction('saveCredential', [ + 'name' => 'Work GitHub', 'provider' => 'github', 'registry' => 'ghcr.io', + 'username' => 'octocat', 'secret' => 'read-only-token', + ]); + $saved = json_decode($saveOutput, true); + $this->assertSame('success', $saved['result']); + $credentialId = $saved['credential']['id']; + $this->assertArrayNotHasKey('secret', $saved['credential']); + + $settingsOutput = $this->executeAction('setStackSettings', [ + 'script' => 'test-stack', 'credentialId' => $credentialId, + ]); + $this->assertSame('success', json_decode($settingsOutput, true)['result']); + $this->assertSame($credentialId, trim((string) file_get_contents($stackPath . '/credential_id'))); + + $getOutput = $this->executeAction('getStackSettings', ['script' => 'test-stack']); + $this->assertSame($credentialId, json_decode($getOutput, true)['credentialId']); + + $deleteOutput = $this->executeAction('deleteCredential', ['id' => $credentialId]); + $deleteResult = json_decode($deleteOutput, true); + $this->assertSame('error', $deleteResult['result']); + $this->assertSame(['test-stack'], $deleteResult['stacks']); + } + public function testGetStackSettingsReturnsExternalComposeFileForFileMode(): void { $stackPath = $this->createTestStack('test-stack'); diff --git a/tests/unit/GitHubDeviceAuthTest.php b/tests/unit/GitHubDeviceAuthTest.php new file mode 100644 index 00000000..21cf2bc9 --- /dev/null +++ b/tests/unit/GitHubDeviceAuthTest.php @@ -0,0 +1,58 @@ + 'device-secret', 'user_code' => 'ABCD-EFGH', 'verification_uri' => 'https://github.com/login/device', 'expires_in' => 900, 'interval' => 5], + ['access_token' => 'github-access-token', 'token_type' => 'bearer', 'scope' => 'read:packages'], + ['login' => 'octocat'], + ]; + $request = static function () use (&$responses): array { + return array_shift($responses); + }; + $auth = new GitHubDeviceAuth('client-id', null, $request); + + $device = $auth->start(); + $result = $auth->poll($device['state']); + + $this->assertSame('success', $result['status']); + $this->assertSame('GitHub - octocat', $result['credential']['name']); + $this->assertArrayNotHasKey('secret', $result['credential']); + $this->assertStringNotContainsString('github-access-token', (string) file_get_contents(COMPOSE_CREDENTIAL_VAULT_FILE)); + } + + public function testPendingAuthorizationCanBePolledAgain(): void + { + $responses = [ + ['device_code' => 'device-secret', 'user_code' => 'ABCD-EFGH', 'verification_uri' => 'https://github.com/login/device', 'expires_in' => 900, 'interval' => 5], + ['error' => 'authorization_pending'], + ]; + $auth = new GitHubDeviceAuth('client-id', null, static function () use (&$responses): array { + return array_shift($responses); + }); + $device = $auth->start(); + + $this->assertSame('pending', $auth->poll($device['state'])['status']); + } +} \ No newline at end of file diff --git a/tests/unit/compose.bats b/tests/unit/compose.bats index 5c27c28f..9d754ac3 100644 --- a/tests/unit/compose.bats +++ b/tests/unit/compose.bats @@ -112,7 +112,7 @@ test_setup() { } @test "compose.sh combines exit cleanup handlers into one trap" { - run grep -F "trap 'release_lock; clear_follow_pid' EXIT" "$COMPOSE_SCRIPT" + run grep -F "trap 'release_lock; clear_follow_pid; cleanup_docker_config' EXIT" "$COMPOSE_SCRIPT" assert_success } From f07c4d03f880b74770a1fdb6764229690fe69976 Mon Sep 17 00:00:00 2001 From: mstrhakr Date: Sun, 13 Sep 2026 14:22:34 -0400 Subject: [PATCH 02/18] feat: add credential manager and stack selector UI --- .../compose.manager.settings.page | 21 +- .../include/ComposeManager.php | 10 + .../javascript/composeManagerMain.js | 21 +- .../javascript/credentialManager.js | 224 ++++++++++++++++++ 4 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 source/compose.manager/javascript/credentialManager.js diff --git a/source/compose.manager/compose.manager.settings.page b/source/compose.manager/compose.manager.settings.page index 1fbb14aa..fa019b66 100755 --- a/source/compose.manager/compose.manager.settings.page +++ b/source/compose.manager/compose.manager.settings.page @@ -30,6 +30,7 @@ $acePath = file_exists('/usr/local/emhttp/plugins/dynamix/javascript/ace/ace.js' + '); + } + $('.credential-close,.credential-cancel').on('click', closeModal); + $('#credential-provider').on('change', applyProviderDefaults); + $('.credential-save').on('click', saveCredential); + $('#credential-github-signin').on('click', startGitHubSignIn); + } + + function applyProviderDefaults() { + var provider = $('#credential-provider').val(); + $('#credential-github-signin').toggle(provider === 'github' && !$('#credential-id').val()); + $('#credential-github-device').hide(); + if (provider === 'github') $('#credential-registry').val('ghcr.io').prop('readonly', true); + else if (provider === 'docker') $('#credential-registry').val('docker.io').prop('readonly', true); + else $('#credential-registry').prop('readonly', false); + } + + function closeModal() { + if (githubPollTimer) window.clearTimeout(githubPollTimer); + githubPollTimer = null; + $('#compose-credential-modal').hide(); + } + + function startGitHubSignIn() { + var $button = $('#credential-github-signin').prop('disabled', true); + $('#credential-modal-error').hide(); + $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'startGitHubDeviceAuth' }).done(function(data) { + var response; + try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; } + if (response.result !== 'success') { + $('#credential-modal-error').text(response.message || 'Unable to start GitHub sign-in.').show(); + $button.prop('disabled', false); + return; + } + var device = response.device; + $('#credential-github-code').text(device.userCode); + $('#credential-github-link').attr('href', device.verificationUri); + $('#credential-github-device').show(); + window.open(device.verificationUri, '_blank', 'noopener'); + pollGitHubSignIn(device.state, device.interval || 5); + }).fail(function() { + $('#credential-modal-error').text('Unable to reach GitHub sign-in service.').show(); + $button.prop('disabled', false); + }); + } + + function pollGitHubSignIn(state, interval) { + githubPollTimer = window.setTimeout(function() { + $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'pollGitHubDeviceAuth', state: state }).done(function(data) { + var response; + try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; } + if (response.result !== 'success') { + $('#credential-github-status').text(response.message || 'GitHub sign-in failed.'); + $('#credential-github-signin').prop('disabled', false); + return; + } + var auth = response.auth || {}; + if (auth.status === 'pending') { + pollGitHubSignIn(state, auth.interval || interval); + return; + } + if (auth.status === 'success') { + var callback = onSaved; + closeModal(); + loadCredentials(function() { if (callback) callback(auth.credential); }); + return; + } + $('#credential-github-status').text(auth.status === 'denied' ? 'Authorization was denied.' : 'Authorization expired. Try again.'); + $('#credential-github-signin').prop('disabled', false); + }).fail(function() { + $('#credential-github-status').text('Unable to check authorization. Retrying...'); + pollGitHubSignIn(state, interval); + }); + }, Math.max(5, interval) * 1000); + } + + function openModal(credential, callback) { + ensureModal(); + credential = credential || {}; + onSaved = callback || null; + $('#credential-modal-title').text(credential.id ? 'Edit registry credential' : 'Add registry credential'); + $('#credential-id').val(credential.id || ''); + $('#credential-provider').val(credential.provider || 'github'); + $('#credential-name').val(credential.name || ''); + $('#credential-registry').val(credential.registry || ''); + $('#credential-username').val(credential.username || ''); + $('#credential-secret').val(''); + $('#credential-github-signin').prop('disabled', false); + $('#credential-modal-error').hide().text(''); + applyProviderDefaults(); + if (credential.registry) $('#credential-registry').val(credential.registry); + $('#compose-credential-modal').css('display', 'flex'); + $('#credential-name').trigger('focus'); + } + + function saveCredential() { + var $button = $('.credential-save').prop('disabled', true); + $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { + action: 'saveCredential', id: $('#credential-id').val(), name: $('#credential-name').val(), + provider: $('#credential-provider').val(), registry: $('#credential-registry').val(), + username: $('#credential-username').val(), secret: $('#credential-secret').val() + }).done(function(data) { + var response; + try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; } + if (response.result !== 'success') { + $('#credential-modal-error').text(response.message || 'Unable to save credential.').show(); + return; + } + closeModal(); + loadCredentials(function() { if (onSaved) onSaved(response.credential); }); + }).fail(function() { + $('#credential-modal-error').text('Unable to reach credential service.').show(); + }).always(function() { $button.prop('disabled', false); }); + } + + function loadCredentials(callback) { + $.post(window.caURL || '/plugins/compose.manager/include/Exec.php', { action: 'listCredentials' }).done(function(data) { + var response; + try { response = typeof data === 'string' ? JSON.parse(data) : data; } catch (error) { response = {}; } + credentials = response.result === 'success' ? (response.credentials || []) : []; + renderTable(); + $(document).trigger('compose:credentials-loaded', [credentials]); + if (callback) callback(credentials); + }); + } + + function renderTable() { + var $body = $('#credentials-tbody'); + if (!$body.length) return; + $body.empty(); + if (!credentials.length) { + $body.append('No registry credentials saved.'); + return; + } + credentials.forEach(function(credential) { + var $row = $(''); + $row.append($('').text(credential.name)); + $row.append($('').text(credential.provider)); + $row.append($('').text(credential.registry)); + $row.append($('').text(credential.username)); + $row.append($('').text((credential.stacks || []).join(', ') || 'Not assigned')); + var $actions = $(''); + $('').on('click', function() { openModal(credential); }).appendTo($actions); + $('').on('click', function() { deleteCredential(credential); }).appendTo($actions); + $row.append($actions).appendTo($body); + }); + } + + function deleteCredential(credential) { + if ((credential.stacks || []).length) { + swal({ title: 'Credential is in use', text: 'Remove it from: ' + credential.stacks.join(', '), type: 'warning' }); + return; + } + 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(loadCredentials); + }); + } + + function populateSelect($select, selectedId) { + $select.empty().append($('