settings = $settings; $this->daUser = $daUser; $this->pg = $pg; $this->runtimeDir = rtrim($settings->adminerSsoDir(), '/'); $this->ticketsDir = $this->runtimeDir . '/tickets'; } public function issueLoginUrl(?string $requestedDatabase = null): string { if (!$this->settings->enableAdminer()) { throw new RuntimeException('Integracja Adminera jest wyłączona.'); } $this->assertRuntimeReady(); $now = time(); $this->cleanupTicketFiles($now); $this->pg->cleanupExpiredAdminerRoles(self::TMP_ROLE_PREFIX); $databaseRows = $this->pg->listDatabasesForUser($this->daUser->username(), false); if (empty($databaseRows)) { throw new RuntimeException('Użytkownik nie ma żadnej bazy PostgreSQL do otwarcia w Adminerze.'); } $databaseNames = []; foreach ($databaseRows as $row) { $name = (string)($row['name'] ?? ''); if ($name !== '') { $databaseNames[] = $name; } } $databaseNames = array_values(array_unique($databaseNames)); if (empty($databaseNames)) { throw new RuntimeException('Nie znaleziono poprawnych nazw baz danych dla użytkownika.'); } $ticketTtl = $this->settings->adminerTicketTtl(); $sessionTtl = $this->settings->adminerSessionTtl(); $roleTtl = max($this->settings->adminerRoleTtl(), $sessionTtl + 120); $ticketExpiresAt = $now + $ticketTtl; $sessionExpiresAt = $now + $sessionTtl; $roleExpiresAt = $now + $roleTtl; $role = $this->generateTemporaryRoleName(); $password = $this->generateSecret(36); $endpoint = $this->pg->connectionEndpoint(); $targetDatabase = ''; if ($requestedDatabase !== null) { $requestedDatabase = trim($requestedDatabase); if ($requestedDatabase !== '') { if (!in_array($requestedDatabase, $databaseNames, true)) { throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.'); } $targetDatabase = $requestedDatabase; } } $createdRole = false; try { $this->pg->createTemporaryRole($role, $password, $roleExpiresAt); $createdRole = true; foreach ($databaseNames as $dbName) { $this->pg->grantTemporaryAdminerAccess($dbName, $role); } $ticketId = $this->generateTicketId(); $payload = [ 'v' => 1, 'da_user' => $this->daUser->username(), 'host' => (string)($endpoint['host'] ?? 'localhost'), 'port' => (int)($endpoint['port'] ?? 5432), 'role' => $role, 'password' => $password, 'database' => $targetDatabase, 'issued_at' => $now, 'expires_at' => $ticketExpiresAt, 'session_expires_at' => $sessionExpiresAt, 'role_expires_at' => $roleExpiresAt, 'user_binding' => $this->buildUserBinding(), ]; $this->writeTicket($ticketId, $payload); $targetBase = $this->buildAdminerBaseUrl(); $separator = str_contains($targetBase, '?') ? '&' : '?'; return $targetBase . $separator . 'ticket=' . rawurlencode($ticketId); } catch (Throwable $e) { if ($createdRole) { try { $this->pg->dropTemporaryRole($role); } catch (Throwable $cleanupError) { // Ignorujemy błąd cleanupu roli, aby zwrócić pierwotny wyjątek. } } throw new RuntimeException('Nie udało się przygotować sesji Adminera: ' . $e->getMessage(), 0, $e); } } private function assertRuntimeReady(): void { if (!is_dir($this->runtimeDir)) { throw new RuntimeException('Brak katalogu runtime Adminera: ' . $this->runtimeDir . '. Uruchom scripts/setup/adminer_install.sh'); } if (!is_dir($this->ticketsDir)) { throw new RuntimeException('Brak katalogu ticketów Adminera: ' . $this->ticketsDir . '. Uruchom scripts/setup/adminer_install.sh'); } if (!is_readable($this->ticketsDir) || !is_writable($this->ticketsDir)) { throw new RuntimeException('Brak uprawnień odczytu/zapisu do katalogu ticketów: ' . $this->ticketsDir); } } private function cleanupTicketFiles(int $now): void { $files = glob($this->ticketsDir . '/*.json'); if ($files === false) { return; } $hardTtl = max($this->settings->adminerRoleTtl(), 3600); foreach ($files as $path) { if (!is_file($path)) { continue; } $remove = false; $raw = @file_get_contents($path); if (is_string($raw) && $raw !== '') { $data = json_decode($raw, true); if (is_array($data) && isset($data['expires_at']) && is_numeric($data['expires_at'])) { $expiresAt = (int)$data['expires_at']; if ($expiresAt < ($now - 60)) { $remove = true; } } } if (!$remove) { $mtime = @filemtime($path); if ($mtime !== false && $mtime < ($now - $hardTtl)) { $remove = true; } } if ($remove) { @unlink($path); } } } /** * @param array $payload */ private function writeTicket(string $ticketId, array $payload): void { $path = $this->ticketsDir . '/' . $ticketId . '.json'; $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); if ($encoded === false) { throw new RuntimeException('Nie udało się zakodować ticketu Adminera.'); } $handle = @fopen($path, 'xb'); if (!is_resource($handle)) { throw new RuntimeException('Nie udało się utworzyć ticketu Adminera: ' . $path); } $written = @fwrite($handle, $encoded); @fclose($handle); if ($written === false || $written < strlen($encoded)) { @unlink($path); throw new RuntimeException('Nie udało się zapisać ticketu Adminera.'); } $this->alignTicketGroup($path); @chmod($path, 0644); } private function alignTicketGroup(string $path): void { $dirGroupId = @filegroup($this->ticketsDir); if (!is_int($dirGroupId) || $dirGroupId < 0) { return; } $targetGroup = null; if (function_exists('posix_getgrgid')) { $groupInfo = @posix_getgrgid($dirGroupId); if (is_array($groupInfo) && isset($groupInfo['name']) && is_string($groupInfo['name'])) { $targetGroup = $groupInfo['name']; } } if ($targetGroup === null || $targetGroup === '') { $targetGroup = (string)$dirGroupId; } @chgrp($path, $targetGroup); } private function generateTemporaryRoleName(): string { $base = strtolower($this->daUser->username()); $base = preg_replace('/[^a-z0-9_]+/', '_', $base) ?? 'user'; $base = trim($base, '_'); if ($base === '') { $base = 'user'; } for ($i = 0; $i < 5; $i++) { $suffix = bin2hex(random_bytes(6)); $maxBaseLen = NamePolicy::MAX_IDENTIFIER_LEN - strlen(self::TMP_ROLE_PREFIX) - 1 - strlen($suffix); if ($maxBaseLen < 1) { $maxBaseLen = 1; } $safeBase = substr($base, 0, $maxBaseLen); $role = self::TMP_ROLE_PREFIX . $safeBase . '_' . $suffix; if (!$this->pg->roleExists($role)) { return $role; } } throw new RuntimeException('Nie udało się wygenerować unikalnej tymczasowej roli dla Adminera.'); } private function generateTicketId(): string { return rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); } private function generateSecret(int $len): string { $value = rtrim(strtr(base64_encode(random_bytes(48)), '+/', 'AZ'), '='); if (strlen($value) >= $len) { return substr($value, 0, $len); } return str_pad($value, $len, 'x'); } private function buildUserBinding(): string { $ua = Http::server('HTTP_USER_AGENT'); if ($ua === '') { $raw = getenv('HTTP_USER_AGENT'); if ($raw !== false) { $ua = trim((string)$raw); } } return hash('sha256', $ua); } private function buildAdminerBaseUrl(): string { $path = $this->settings->adminerUrlPath(); $customBase = $this->settings->adminerPublicBaseUrl(); if ($customBase !== '') { return $customBase . $path; } $host = trim(Http::server('HTTP_HOST')); if ($host === '') { $host = trim(Http::server('SERVER_NAME')); } if ($host === '') { return $path; } $host = preg_replace('/:\d+$/', '', $host) ?? $host; $scheme = $this->detectRequestScheme(); if ($scheme !== '') { return $scheme . '://' . $host . $path; } // Fallback bezpieczny: odziedzicz protokół aktualnej strony (unika mixed content). return '//' . $host . $path; } private function detectRequestScheme(): string { $forwardedProto = strtolower(trim(Http::server('HTTP_X_FORWARDED_PROTO'))); if ($forwardedProto === 'https' || $forwardedProto === 'http') { return $forwardedProto; } $requestScheme = strtolower(trim(Http::server('REQUEST_SCHEME'))); if ($requestScheme === 'https' || $requestScheme === 'http') { return $requestScheme; } $frontEndHttps = strtolower(trim(Http::server('HTTP_FRONT_END_HTTPS'))); if ($frontEndHttps === 'on' || $frontEndHttps === '1' || $frontEndHttps === 'https') { return 'https'; } $https = strtolower(trim(Http::server('HTTPS'))); if ($https === 'on' || $https === '1' || $https === 'https' || $https === 'true') { return 'https'; } $serverPort = trim(Http::server('SERVER_PORT')); if ($serverPort === '443') { return 'https'; } if ($serverPort === '80') { return 'http'; } return ''; } }