*/ private array $connections = []; public function __construct(array $credentials, Settings $settings) { $database = $credentials['database'] ?? 'mysql'; if ($database === '*' || trim((string)$database) === '') { $database = 'mysql'; } $host = trim((string)($credentials['host'] ?? 'localhost')); if ($host === '') { $host = 'localhost'; } $this->credentials = [ 'host' => $host, 'port' => (int)($credentials['port'] ?? 3306), 'database' => (string)$database, 'user' => (string)($credentials['user'] ?? 'root'), 'password' => (string)($credentials['password'] ?? ''), 'socket' => (string)($credentials['socket'] ?? ''), ]; $this->settings = $settings; } public function ensureMetadataSchema(): void { $this->query( 'CREATE TABLE IF NOT EXISTS da_plugin_access_hosts ( da_user VARCHAR(255) NOT NULL, role_name VARCHAR(255) NOT NULL, host_pattern VARCHAR(255) NOT NULL, note VARCHAR(180) NOT NULL DEFAULT \'\', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (role_name, host_pattern), KEY access_hosts_da_user_idx (da_user) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $this->query( 'CREATE TABLE IF NOT EXISTS da_plugin_grant_profiles ( db_name VARCHAR(255) NOT NULL, role_name VARCHAR(255) NOT NULL, privileges TEXT NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (db_name, role_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $this->query( 'CREATE TABLE IF NOT EXISTS da_plugin_database_owners ( db_name VARCHAR(255) NOT NULL, da_user VARCHAR(255) NOT NULL, role_name VARCHAR(255) NOT NULL, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (db_name), KEY database_owners_da_user_idx (da_user), KEY database_owners_role_idx (role_name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $this->query( 'CREATE TABLE IF NOT EXISTS da_plugin_sso_accounts ( role_name VARCHAR(255) NOT NULL, host_pattern VARCHAR(255) NOT NULL, expires_at INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (role_name, host_pattern), KEY sso_accounts_expires_idx (expires_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); $this->query( 'CREATE TABLE IF NOT EXISTS da_plugin_system_databases ( service_name VARCHAR(64) NOT NULL, db_name VARCHAR(255) NOT NULL, db_user VARCHAR(255) NOT NULL, endpoint_mode VARCHAR(32) NOT NULL DEFAULT \'tcp\', config_path VARCHAR(512) NOT NULL DEFAULT \'\', enabled TINYINT(1) NOT NULL DEFAULT 1, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (service_name), KEY system_databases_db_idx (db_name), KEY system_databases_user_idx (db_user) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); } /** * @return array{host:string,port:int} */ public function connectionEndpoint(): array { return [ 'host' => $this->credentials['host'], 'port' => (int)$this->credentials['port'], ]; } public function serverVersion(): string { $row = $this->fetchOne('SELECT VERSION() AS version'); return trim((string)($row['version'] ?? '')); } public function countDatabasesForUser(string $daUser): int { $row = $this->fetchOne( 'SELECT COUNT(*) AS cnt FROM information_schema.SCHEMATA WHERE SCHEMA_NAME LIKE ? ESCAPE \'\\\\\'', [$this->likePattern($daUser . '_')] ); return isset($row['cnt']) ? (int)$row['cnt'] : 0; } /** * @return array */ public function listDatabasesForUser(string $daUser, bool $includeSize = true): array { $sizeSql = $includeSize ? 'COALESCE(( SELECT SUM(t.DATA_LENGTH + t.INDEX_LENGTH) FROM information_schema.TABLES t WHERE t.TABLE_SCHEMA = s.SCHEMA_NAME ), 0) AS size_bytes' : '0 AS size_bytes'; $rows = $this->fetchAll( 'SELECT s.SCHEMA_NAME AS name, COALESCE(o.role_name, \'\') AS owner, ' . $sizeSql . ' FROM information_schema.SCHEMATA s LEFT JOIN da_plugin_database_owners o ON o.db_name = s.SCHEMA_NAME WHERE s.SCHEMA_NAME LIKE ? ESCAPE \'\\\\\' ORDER BY s.SCHEMA_NAME', [$this->likePattern($daUser . '_')] ); $out = []; foreach ($rows as $row) { $out[] = [ 'name' => (string)$row['name'], 'owner' => (string)($row['owner'] ?? ''), 'size' => $this->formatBytes((int)($row['size_bytes'] ?? 0)), ]; } return $out; } /** * @return array */ public function listRolesForUser(string $daUser): array { $rows = $this->fetchAll( 'SELECT DISTINCT User AS name FROM mysql.user WHERE User LIKE ? ESCAPE \'\\\\\' ORDER BY User', [$this->likePattern($daUser . '_')] ); $out = []; foreach ($rows as $row) { $name = (string)($row['name'] ?? ''); if ($name === '' || strpos($name, self::TMP_ROLE_PREFIX) === 0) { continue; } $out[] = [ 'name' => $name, 'can_login' => true, ]; } return $out; } /** * @return array> */ public function listDatabaseUsersMap(string $daUser): array { $rows = $this->fetchAll( 'SELECT db_name, role_name FROM da_plugin_grant_profiles WHERE db_name LIKE ? ESCAPE \'\\\\\' AND role_name LIKE ? ESCAPE \'\\\\\' ORDER BY db_name, role_name', [$this->likePattern($daUser . '_'), $this->likePattern($daUser . '_')] ); $map = []; foreach ($rows as $row) { $db = (string)($row['db_name'] ?? ''); $role = (string)($row['role_name'] ?? ''); if ($db === '' || $role === '') { continue; } $map[$db] ??= []; $map[$db][] = $role; } return $map; } /** * @return array> */ public function listRoleDatabasesMap(string $daUser): array { $dbMap = $this->listDatabaseUsersMap($daUser); $out = []; foreach ($dbMap as $db => $roles) { foreach ($roles as $role) { $out[$role] ??= []; $out[$role][] = $db; } } ksort($out); return $out; } /** * @return string[] */ public function listDatabaseUsers(string $daUser, string $database): array { $rows = $this->fetchAll( 'SELECT role_name FROM da_plugin_grant_profiles WHERE db_name = ? AND role_name LIKE ? ESCAPE \'\\\\\' ORDER BY role_name', [$database, $this->likePattern($daUser . '_')] ); $out = []; foreach ($rows as $row) { $role = (string)($row['role_name'] ?? ''); if ($role !== '') { $out[] = $role; } } return $out; } public function roleExists(string $role): bool { $row = $this->fetchOne('SELECT 1 AS ok FROM mysql.user WHERE User = ? LIMIT 1', [$role]); return !empty($row); } public function databaseExists(string $dbName): bool { $row = $this->fetchOne('SELECT 1 AS ok FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ? LIMIT 1', [$dbName]); return !empty($row); } public function getDatabaseOwner(string $dbName): ?string { $row = $this->fetchOne('SELECT role_name FROM da_plugin_database_owners WHERE db_name = ?', [$dbName]); if (empty($row['role_name'])) { return null; } return (string)$row['role_name']; } /** * @return array{name:string,owner:string,size:string,encoding:string,collation:string} */ public function getDatabaseInfo(string $dbName): array { $row = $this->fetchOne( 'SELECT s.SCHEMA_NAME AS name, COALESCE(o.role_name, \'\') AS owner, s.DEFAULT_CHARACTER_SET_NAME AS encoding, s.DEFAULT_COLLATION_NAME AS collation, COALESCE(( SELECT SUM(t.DATA_LENGTH + t.INDEX_LENGTH) FROM information_schema.TABLES t WHERE t.TABLE_SCHEMA = s.SCHEMA_NAME ), 0) AS size_bytes FROM information_schema.SCHEMATA s LEFT JOIN da_plugin_database_owners o ON o.db_name = s.SCHEMA_NAME WHERE s.SCHEMA_NAME = ?', [$dbName] ); if (empty($row)) { throw new RuntimeException('Nie znaleziono bazy danych: ' . $dbName); } return [ 'name' => (string)($row['name'] ?? $dbName), 'owner' => (string)($row['owner'] ?? ''), 'size' => $this->formatBytes((int)($row['size_bytes'] ?? 0)), 'encoding' => (string)($row['encoding'] ?? 'utf8mb4'), 'collation' => (string)($row['collation'] ?? ''), ]; } /** * @return array{tables:int,views:int,triggers:int,routines:int} */ public function getDatabaseObjectStats(string $dbName): array { $row = $this->fetchOne( 'SELECT COALESCE(SUM(CASE WHEN TABLE_TYPE = \'BASE TABLE\' THEN 1 ELSE 0 END), 0) AS tables_cnt, COALESCE(SUM(CASE WHEN TABLE_TYPE = \'VIEW\' THEN 1 ELSE 0 END), 0) AS views_cnt FROM information_schema.TABLES WHERE TABLE_SCHEMA = ?', [$dbName] ); $triggerRow = $this->fetchOne( 'SELECT COUNT(*) AS cnt FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA = ?', [$dbName] ); $routineRow = $this->fetchOne( 'SELECT COUNT(*) AS cnt FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = ?', [$dbName] ); return [ 'tables' => (int)($row['tables_cnt'] ?? 0), 'views' => (int)($row['views_cnt'] ?? 0), 'triggers' => (int)($triggerRow['cnt'] ?? 0), 'routines' => (int)($routineRow['cnt'] ?? 0), ]; } /** * @return string[] */ public function roleOwnedDatabases(string $role, string $daUser): array { $rows = $this->fetchAll( 'SELECT db_name FROM da_plugin_database_owners WHERE role_name = ? AND db_name LIKE ? ESCAPE \'\\\\\' ORDER BY db_name', [$role, $this->likePattern($daUser . '_')] ); $out = []; foreach ($rows as $row) { $name = (string)($row['db_name'] ?? ''); if ($name !== '') { $out[] = $name; } } return $out; } /** * @return string[] */ public function roleAssignedDatabases(string $role, string $daUser): array { $rows = $this->fetchAll( 'SELECT db_name FROM da_plugin_grant_profiles WHERE role_name = ? AND db_name LIKE ? ESCAPE \'\\\\\' ORDER BY db_name', [$role, $this->likePattern($daUser . '_')] ); $out = []; foreach ($rows as $row) { $name = (string)($row['db_name'] ?? ''); if ($name !== '') { $out[] = $name; } } return $out; } /** * @return array */ public function getRoleHostEntries(string $daUser, string $role): array { $rows = $this->fetchAll( 'SELECT host_pattern, note FROM da_plugin_access_hosts WHERE da_user = ? AND role_name = ? ORDER BY host_pattern', [$daUser, $role] ); $map = []; foreach ($rows as $row) { $host = (string)$row['host_pattern']; if ($host === '') { continue; } $map[$host] = $this->sanitizeHostNote((string)($row['note'] ?? '')); } if ($this->roleExists($role)) { $this->ensureDefaultRoleHostsProvisioned($role); foreach ($this->listActualRoleHosts($role) as $host) { if (!isset($map[$host])) { $map[$host] = $this->requiredAccessHostNote($host); } } foreach ($this->requiredAccessHosts() as $host) { if (!isset($map[$host])) { $map[$host] = $this->requiredAccessHostNote($host); } elseif ($map[$host] === '') { $map[$host] = $this->requiredAccessHostNote($host); } } } if (empty($map) && $this->roleExists($role)) { $map['localhost'] = $this->requiredAccessHostNote('localhost'); } ksort($map); $out = []; foreach ($map as $host => $note) { $out[] = ['host' => $host, 'note' => $note]; } return $out; } /** * @return string[] */ public function getRoleHosts(string $daUser, string $role): array { $entries = $this->getRoleHostEntries($daUser, $role); $out = []; foreach ($entries as $entry) { $out[] = $entry['host']; } return $out; } /** * @param string[] $hosts */ public function replaceRoleHosts(string $daUser, string $role, array $hosts): void { $entries = []; foreach ($hosts as $host) { $entries[] = ['host' => (string)$host, 'note' => '']; } $this->replaceRoleHostsWithNotes($daUser, $role, $entries); } /** * @param array $hostEntries */ public function replaceRoleHostsWithNotes(string $daUser, string $role, array $hostEntries): void { $normalized = []; foreach ($hostEntries as $entry) { $host = trim((string)($entry['host'] ?? '')); if ($host === '') { continue; } $normalized[$host] = $this->sanitizeHostNote((string)($entry['note'] ?? '')); } foreach ($this->requiredAccessHosts() as $requiredHost) { if (!isset($normalized[$requiredHost])) { $normalized[$requiredHost] = $this->requiredAccessHostNote($requiredHost); } elseif ($normalized[$requiredHost] === '') { $normalized[$requiredHost] = $this->requiredAccessHostNote($requiredHost); } } $currentHosts = $this->listActualRoleHosts($role); if (empty($currentHosts) && !$this->roleExists($role)) { throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role); } foreach (array_keys($normalized) as $host) { if (!in_array($host, $currentHosts, true)) { $this->cloneRoleToHost($role, $host); } } foreach ($currentHosts as $host) { if ($host === 'localhost' || isset($normalized[$host])) { continue; } $this->dropRoleHost($role, $host); } $this->query('DELETE FROM da_plugin_access_hosts WHERE da_user = ? AND role_name = ?', [$daUser, $role]); foreach ($normalized as $host => $note) { $this->query( 'INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE da_user = VALUES(da_user), note = VALUES(note), updated_at = CURRENT_TIMESTAMP', [$daUser, $role, $host, $note] ); } foreach ($this->roleAssignedDatabases($role, $daUser) as $dbName) { $profile = $this->getGrantProfile($dbName, $role); if (!empty($profile)) { $this->grantPrivileges($dbName, $role, $profile); } } } public function deleteRoleHosts(string $daUser, string $role): void { foreach ($this->listActualRoleHosts($role) as $host) { if ($host === 'localhost') { continue; } $this->dropRoleHost($role, $host); } $this->query('DELETE FROM da_plugin_access_hosts WHERE da_user = ? AND role_name = ?', [$daUser, $role]); } public function createRole(string $role, string $password): void { $qRole = $this->quoteLiteral($role); $qPassword = $this->quoteLiteral($password); $this->query('CREATE USER ' . $qRole . '@\'localhost\' IDENTIFIED BY ' . $qPassword); } public function changeRolePassword(string $role, string $password): void { $qPassword = $this->quoteLiteral($password); $this->ensureDefaultRoleHostsProvisioned($role); $hosts = $this->listActualRoleHosts($role); if (empty($hosts)) { throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role); } foreach ($hosts as $host) { $this->query( 'ALTER USER ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host) . ' IDENTIFIED BY ' . $qPassword ); } } public function createDatabase(string $dbName, string $owner): void { $qDb = $this->quoteIdentifier($dbName); $this->query('CREATE DATABASE ' . $qDb . ' CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'); $this->query( 'INSERT INTO da_plugin_database_owners (db_name, da_user, role_name) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE da_user = VALUES(da_user), role_name = VALUES(role_name), updated_at = CURRENT_TIMESTAMP', [$dbName, '', $owner] ); $this->grantAllPrivileges($dbName, $owner); } public function dropDatabase(string $dbName): void { $this->query('DROP DATABASE IF EXISTS ' . $this->quoteIdentifier($dbName)); $this->query('DELETE FROM da_plugin_database_owners WHERE db_name = ?', [$dbName]); $this->query('DELETE FROM da_plugin_grant_profiles WHERE db_name = ?', [$dbName]); } public function dropRole(string $role): void { foreach ($this->listActualRoleHosts($role) as $host) { $this->dropRoleHost($role, $host); } $this->query('DELETE FROM da_plugin_access_hosts WHERE role_name = ?', [$role]); $this->query('DELETE FROM da_plugin_grant_profiles WHERE role_name = ?', [$role]); $this->query('DELETE FROM da_plugin_database_owners WHERE role_name = ?', [$role]); $this->query('DELETE FROM da_plugin_sso_accounts WHERE role_name = ?', [$role]); } public function reassignOwnedObjects(string $dbName, string $fromRole, string $toRole): void { if ($fromRole === $toRole) { return; } $this->query( 'UPDATE da_plugin_database_owners SET role_name = ?, da_user = ?, updated_at = CURRENT_TIMESTAMP WHERE db_name = ? AND role_name = ?', [$toRole, '', $dbName, $fromRole] ); if ($this->databaseExists($dbName) && $this->roleExists($toRole)) { $this->grantAllPrivileges($dbName, $toRole); } } /** * @param string[] $privileges */ public function grantPrivileges(string $dbName, string $role, array $privileges): void { $privileges = array_values(array_unique($privileges)); if (empty($privileges)) { throw new RuntimeException('Brak uprawnień do nadania.'); } $this->ensureDefaultRoleHostsProvisioned($role); $hosts = $this->listActualRoleHosts($role); if (empty($hosts)) { throw new RuntimeException('Użytkownik MySQL nie istnieje: ' . $role); } if (in_array('ALL', $privileges, true)) { $this->grantAllPrivileges($dbName, $role); $this->saveGrantProfile($dbName, $role, ['ALL']); return; } $this->revokePrivilegesInternal($dbName, $role, false); $grantSql = implode(', ', array_map([$this, 'mapPrivilegeCodeToSql'], $privileges)); foreach ($hosts as $host) { $this->query( 'GRANT ' . $grantSql . ' ON ' . $this->quoteIdentifier($dbName) . '.* TO ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host) ); } sort($privileges); $this->saveGrantProfile($dbName, $role, $privileges); } public function revokePrivileges(string $dbName, string $role): void { $this->revokePrivilegesInternal($dbName, $role, true); } /** * @return string[] */ public function getGrantProfile(string $dbName, string $role): array { $row = $this->fetchOne( 'SELECT privileges FROM da_plugin_grant_profiles WHERE db_name = ? AND role_name = ?', [$dbName, $role] ); if (empty($row['privileges'])) { return []; } $parts = array_filter(array_map('trim', explode(',', (string)$row['privileges']))); $parts = array_map('strtoupper', $parts); return array_values(array_unique($parts)); } /** * @return array{ok:bool,output:string} */ public function syncHostRules(): array { if (!$this->settings->modifyServerConfiguration()) { return ['ok' => true, 'output' => '']; } $script = $this->settings->hostSyncScript(); if (!is_file($script)) { return ['ok' => false, 'output' => 'Brak skryptu synchronizacji hostów MySQL: ' . $script]; } $cmd = escapeshellarg($script) . ' 2>&1'; $output = []; $exitCode = 0; exec($cmd, $output, $exitCode); return [ 'ok' => $exitCode === 0, 'output' => trim(implode("\n", $output)), ]; } public function createTemporaryRole(string $role, string $password, int $expiresAt): void { $this->query( 'CREATE USER ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST) . ' IDENTIFIED BY ' . $this->quoteLiteral($password) ); $this->query( 'INSERT INTO da_plugin_sso_accounts (role_name, host_pattern, expires_at) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE expires_at = VALUES(expires_at)', [$role, self::TMP_ROLE_HOST, (string)$expiresAt] ); } public function grantTemporaryAdminerAccess(string $dbName, string $role): void { $this->query( 'GRANT ALL PRIVILEGES ON ' . $this->quoteIdentifier($dbName) . '.* TO ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST) ); } public function dropTemporaryRole(string $role): void { $this->tryQuery('DROP USER IF EXISTS ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral(self::TMP_ROLE_HOST)); $this->query('DELETE FROM da_plugin_sso_accounts WHERE role_name = ?', [$role]); } public function cleanupExpiredAdminerRoles(string $rolePrefix = self::TMP_ROLE_PREFIX): int { $rows = $this->fetchAll( 'SELECT role_name FROM da_plugin_sso_accounts WHERE role_name LIKE ? ESCAPE \'\\\\\' AND expires_at < ? ORDER BY role_name', [$this->likePattern($rolePrefix), (string)time()] ); $removed = 0; foreach ($rows as $row) { $role = (string)($row['role_name'] ?? ''); if ($role === '') { continue; } try { $this->dropTemporaryRole($role); $removed++; } catch (Throwable $e) { // Ignorujemy błędy cleanupu, aby nie blokować logowania. } } return $removed; } private function grantAllPrivileges(string $dbName, string $role): void { $this->ensureDefaultRoleHostsProvisioned($role); foreach ($this->listActualRoleHosts($role) as $host) { $this->query( 'GRANT ALL PRIVILEGES ON ' . $this->quoteIdentifier($dbName) . '.* TO ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host) ); } } private function revokePrivilegesInternal(string $dbName, string $role, bool $removeProfile): void { foreach ($this->listActualRoleHosts($role) as $host) { $this->tryQuery( 'REVOKE ALL PRIVILEGES, GRANT OPTION ON ' . $this->quoteIdentifier($dbName) . '.* FROM ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host) ); } if ($removeProfile) { $this->query('DELETE FROM da_plugin_grant_profiles WHERE db_name = ? AND role_name = ?', [$dbName, $role]); } } /** * @param string[] $privileges */ private function saveGrantProfile(string $dbName, string $role, array $privileges): void { sort($privileges); $serialized = implode(',', $privileges); $this->query( 'INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE privileges = VALUES(privileges), updated_at = CURRENT_TIMESTAMP', [$dbName, $role, $serialized] ); } /** * @return string[] */ private function listActualRoleHosts(string $role): array { $rows = $this->fetchAll( 'SELECT Host AS host FROM mysql.user WHERE User = ? ORDER BY CASE WHEN Host = \'localhost\' THEN 0 ELSE 1 END, Host', [$role] ); $hosts = []; foreach ($rows as $row) { $host = (string)($row['host'] ?? ''); if ($host !== '') { $hosts[] = $host; } } return array_values(array_unique($hosts)); } private function ensureDefaultRoleHostsProvisioned(string $role): void { $hosts = $this->listActualRoleHosts($role); if (empty($hosts)) { return; } foreach ($this->requiredAccessHosts() as $requiredHost) { if (!in_array($requiredHost, $hosts, true)) { $this->cloneRoleToHost($role, $requiredHost); $hosts[] = $requiredHost; } } } private function cloneRoleToHost(string $role, string $targetHost): void { $source = $this->fetchOne( 'SELECT Host AS host, plugin, authentication_string FROM mysql.user WHERE User = ? ORDER BY CASE WHEN Host = \'localhost\' THEN 0 ELSE 1 END, Host LIMIT 1', [$role] ); if (empty($source)) { throw new RuntimeException('Nie można utworzyć hosta dla użytkownika MySQL bez konta źródłowego.'); } $plugin = (string)($source['plugin'] ?? ''); $authString = (string)($source['authentication_string'] ?? ''); $quotedRole = $this->quoteLiteral($role); $quotedHost = $this->quoteLiteral($targetHost); if ($plugin !== '' && $authString !== '') { $sql = 'CREATE USER ' . $quotedRole . '@' . $quotedHost . ' IDENTIFIED WITH ' . $this->quotePluginName($plugin) . ' AS ' . $this->quoteLiteral($authString); } elseif ($authString !== '') { $sql = 'CREATE USER ' . $quotedRole . '@' . $quotedHost . ' IDENTIFIED BY PASSWORD ' . $this->quoteLiteral($authString); } else { throw new RuntimeException('Nie można sklonować uwierzytelnienia użytkownika MySQL dla nowego hosta.'); } $this->query($sql); } private function dropRoleHost(string $role, string $host): void { $this->tryQuery('DROP USER IF EXISTS ' . $this->quoteLiteral($role) . '@' . $this->quoteLiteral($host)); } private function sanitizeHostNote(string $note): string { $note = preg_replace('/[\r\n\t]+/', ' ', $note) ?? ''; $note = preg_replace('/\s+/', ' ', $note) ?? ''; $note = trim($note); if (strlen($note) > NamePolicy::MAX_HOST_COMMENT_LEN) { $note = substr($note, 0, NamePolicy::MAX_HOST_COMMENT_LEN); } return $note; } /** * @return string[] */ public function requiredAccessHosts(): array { $hosts = ['localhost']; if (!$this->isRemoteDatabaseHost()) { return $hosts; } foreach ($this->detectDirectAdminServerHosts() as $host) { if (!in_array($host, $hosts, true)) { $hosts[] = $host; } } return $hosts; } private function requiredAccessHostNote(string $host): string { if ($host === 'localhost') { return 'Dodawany automatycznie dla polaczen lokalnych.'; } if (in_array($host, $this->requiredAccessHosts(), true)) { return 'Adres serwera DirectAdmin dodawany automatycznie dla zdalnego MySQL.'; } return ''; } private function isRemoteDatabaseHost(): bool { $host = trim($this->credentials['host']); return !$this->isLocalHost($host); } /** * @return string[] */ private function detectDirectAdminServerHosts(): array { $hosts = []; $manual = trim($this->settings->getString('MYSQL_PLUGIN_LOCAL_SERVER_HOST', '')); if ($manual !== '') { foreach (preg_split('/[\s,;]+/', $manual) ?: [] as $candidate) { $candidate = trim((string)$candidate); if ($this->isUsableRemoteAccessHost($candidate) && !in_array($candidate, $hosts, true)) { $hosts[] = $candidate; } } } $serverAddr = trim(Http::server('SERVER_ADDR')); if ($this->isUsableRemoteAccessHost($serverAddr) && !in_array($serverAddr, $hosts, true)) { $hosts[] = $serverAddr; } $daConf = '/usr/local/directadmin/conf/directadmin.conf'; if (is_readable($daConf)) { $lines = file($daConf, FILE_IGNORE_NEW_LINES); if ($lines !== false) { foreach ($lines as $line) { $line = trim((string)$line); if ($line === '' || $line[0] === '#') { continue; } if (!preg_match('/^(?:ip|server_ip)\s*=\s*(.+)$/i', $line, $m)) { continue; } $candidate = trim((string)$m[1]); if ($this->isUsableRemoteAccessHost($candidate) && !in_array($candidate, $hosts, true)) { $hosts[] = $candidate; } } } } $resolved = @gethostbyname((string)php_uname('n')); if ($this->isUsableRemoteAccessHost($resolved) && !in_array($resolved, $hosts, true)) { $hosts[] = $resolved; } return $hosts; } private function isLocalHost(string $host): bool { $host = strtolower(trim($host)); return $host === '' || in_array($host, ['localhost', '127.0.0.1', '::1'], true); } private function isUsableRemoteAccessHost(string $host): bool { $host = trim($host); if ($host === '' || $this->isLocalHost($host)) { return false; } return filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; } private function likePattern(string $prefix): string { return str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $prefix) . '%'; } private function quoteIdentifier(string $name): string { if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) { throw new RuntimeException('Nieprawidłowy identyfikator MySQL: ' . $name); } if (strlen($name) > NamePolicy::MAX_IDENTIFIER_LEN) { throw new RuntimeException('Identyfikator MySQL przekracza 64 znaki: ' . $name); } return '`' . str_replace('`', '``', $name) . '`'; } private function quotePluginName(string $name): string { if (!preg_match('/^[A-Za-z0-9_]+$/', $name)) { throw new RuntimeException('Nieprawidłowa nazwa pluginu uwierzytelniania MySQL: ' . $name); } return $name; } private function quoteLiteral(string $value, string $database = ''): string { return '\'' . $this->connect($database)->real_escape_string($value) . '\''; } private function mapPrivilegeCodeToSql(string $code): string { return match ($code) { 'CREATE_VIEW' => 'CREATE VIEW', 'SHOW_VIEW' => 'SHOW VIEW', 'LOCK_TABLES' => 'LOCK TABLES', default => $code, }; } private function formatBytes(int $bytes): string { if ($bytes < 1024) { return $bytes . ' B'; } $units = ['KB', 'MB', 'GB', 'TB']; $value = $bytes; foreach ($units as $unit) { $value /= 1024; if ($value < 1024 || $unit === 'TB') { return number_format($value, $value >= 10 ? 0 : 1, '.', '') . ' ' . $unit; } } return $bytes . ' B'; } /** * @param array $params */ private function query(string $sql, array $params = [], string $database = ''): mysqli_result|bool { $conn = $this->connect($database); if ($params !== []) { $sql = $this->interpolateQuery($conn, $sql, $params); } $res = @$conn->query($sql); if ($res === false) { throw new RuntimeException(trim((string)$conn->error) . ' | SQL: ' . $sql); } return $res; } /** * @param array $params */ private function interpolateQuery(mysqli $conn, string $sql, array $params): string { $parts = explode('?', $sql); if (count($parts) - 1 !== count($params)) { throw new RuntimeException('Nieprawidłowa liczba parametrów SQL.'); } $out = array_shift($parts); foreach ($params as $index => $param) { $value = is_bool($param) ? ($param ? '1' : '0') : (string)$param; $out .= '\'' . $conn->real_escape_string($value) . '\'' . $parts[$index]; } return (string)$out; } /** * @param array $params */ private function tryQuery(string $sql, array $params = [], string $database = ''): void { try { $this->query($sql, $params, $database); } catch (Throwable $e) { // celowo ignorujemy błędy pomocnicze } } /** * @param array $params * @return array> */ private function fetchAll(string $sql, array $params = [], string $database = ''): array { $res = $this->query($sql, $params, $database); if (!$res instanceof mysqli_result) { return []; } $rows = $res->fetch_all(MYSQLI_ASSOC); $res->free(); return is_array($rows) ? $rows : []; } /** * @param array $params * @return array */ private function fetchOne(string $sql, array $params = [], string $database = ''): array { $rows = $this->fetchAll($sql, $params, $database); return $rows[0] ?? []; } private function connect(string $database = ''): mysqli { $targetDb = $database !== '' ? $database : $this->credentials['database']; if (isset($this->connections[$targetDb])) { return $this->connections[$targetDb]; } mysqli_report(MYSQLI_REPORT_OFF); $conn = mysqli_init(); if (!$conn instanceof mysqli) { throw new RuntimeException('Nie udało się zainicjalizować połączenia MySQL.'); } $socket = ''; if ($this->credentials['host'] === 'localhost' && $this->credentials['socket'] !== '') { $socket = $this->credentials['socket']; } $ok = @$conn->real_connect( $this->credentials['host'], $this->credentials['user'], $this->credentials['password'], $targetDb, $this->credentials['port'], $socket !== '' ? $socket : null ); if ($ok !== true) { error_log(sprintf( '[%s] MYSQL_CONNECT_FAIL host=%s port=%d database=%s error=%s', date('c'), $this->credentials['host'], $this->credentials['port'], $targetDb, $conn->connect_error ?? 'unknown' )); throw new RuntimeException('Nie udało się połączyć z MySQL.'); } $conn->set_charset('utf8mb4'); $this->connections[$targetDb] = $conn; return $conn; } }