Import postgresql plugin
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
final class AdminerSso
|
||||
{
|
||||
private const TMP_ROLE_PREFIX = 'da_tmp_adminer_';
|
||||
|
||||
private Settings $settings;
|
||||
private DirectAdminUser $daUser;
|
||||
private PostgresService $pg;
|
||||
private string $runtimeDir;
|
||||
private string $ticketsDir;
|
||||
|
||||
public function __construct(Settings $settings, DirectAdminUser $daUser, PostgresService $pg)
|
||||
{
|
||||
$this->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<string,mixed> $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 '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user