commit d71fcf9ace8a34d13455f822d46877368fd1cd1c Author: Marek Miklewicz Date: Sat Jul 4 15:48:19 2026 +0200 Import alt-mysql plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a40718e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +._* +__MACOSX/ +error.log +data/* +assets/adminer/* diff --git a/admin/index.html b/admin/index.html new file mode 100755 index 0000000..1231c64 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,5 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini +hasPluginAccess()) { + http_response_code(403); + $langCode = $daUser->language(); + $lang = Lang::load($daUser->language()); + render_access_denied_message($lang, $daUser->skin(), $langCode); + exit; + } + + $credentials = Settings::loadMysqlCredentials($settings->mysqlCredentialsFile()); + $mysql = new MySQLService($credentials, $settings); + $mysql->ensureMetadataSchema(); + + $secret = $settings->loadOrCreateSecret(PLUGIN_ROOT . '/data/secret.key'); + $csrf = new CsrfGuard($secret, $daUser->username(), Http::server('SESSION_ID')); + + $lang = Lang::load($daUser->language()); + $ctx = new AppContext($daUser, $settings, $mysql, $csrf, $lang); + + $handlerFile = PLUGIN_HANDLERS_DIR . '/' . basename((string)PLUGIN_ACTION) . '.php'; + if (!is_file($handlerFile)) { + throw new RuntimeException('Brak handlera dla akcji: ' . (string)PLUGIN_ACTION); + } + + $handler = require $handlerFile; + if (!is_callable($handler)) { + throw new RuntimeException('Handler akcji nie jest wywoływalny: ' . (string)PLUGIN_ACTION); + } + + $handler($ctx); + exit; +} catch (Throwable $e) { + $msg = sprintf( + "[%s] EXCEPTION class=%s message=%s file=%s line=%d action=%s\n", + date('c'), + get_class($e), + $e->getMessage(), + $e->getFile(), + $e->getLine(), + defined('PLUGIN_ACTION') ? (string)PLUGIN_ACTION : 'unknown' + ); + @error_log($msg, 3, $pluginErrorLog); + @error_log("[stacktrace]\n" . $e->getTraceAsString() . "\n", 3, $pluginErrorLog); + + http_response_code(500); + header('Content-Type: text/html; charset=UTF-8'); + echo 'Błąd pluginu'; + echo '

Błąd pluginu MySQL

'; + echo '

' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

'; + echo '

Sprawdź konfigurację i uprawnienia pluginu.

'; + echo ''; + exit; +} + +function autoload_plugin_libs(): void +{ + $files = [ + PLUGIN_EXEC_DIR . '/lib/Http.php', + PLUGIN_EXEC_DIR . '/lib/Settings.php', + PLUGIN_EXEC_DIR . '/lib/PhpMyAdminRuntimeConfig.php', + PLUGIN_EXEC_DIR . '/lib/DirectAdminUser.php', + PLUGIN_EXEC_DIR . '/lib/Lang.php', + PLUGIN_EXEC_DIR . '/lib/LocalizedException.php', + PLUGIN_EXEC_DIR . '/lib/CsrfGuard.php', + PLUGIN_EXEC_DIR . '/lib/NamePolicy.php', + PLUGIN_EXEC_DIR . '/lib/MySQLService.php', + PLUGIN_EXEC_DIR . '/lib/PhpMyAdminSso.php', + PLUGIN_EXEC_DIR . '/lib/BackupQueueService.php', + PLUGIN_EXEC_DIR . '/lib/FilePicker.php', + PLUGIN_EXEC_DIR . '/lib/AppContext.php', + ]; + + foreach ($files as $file) { + require_once $file; + } +} + +function render_access_denied_message(Lang $lang, string $skin, string $langCode): void +{ + $path = PLUGIN_ROOT . '/user/' . strtolower(trim($skin)) . '.css'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/user/enhanced.css'; + } + $css = is_file($path) ? (string)file_get_contents($path) : ''; + + $message = $lang->t('Bazy danych MySQL nie są dostępne dla Twojego konta - skontaktuj się z pomocą techniczną aby uzyskać pomoc i więcej informacji'); + $title = $lang->t('Błąd'); + + header('Content-Type: text/html; charset=UTF-8'); + echo ''; + echo ''; + echo '' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . ''; + echo ''; + echo ''; + echo '
'; + echo '
'; + echo '
' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '
'; + echo '
'; + echo '
'; + echo ''; +} diff --git a/exec/handlers/assign_database.php b/exec/handlers/assign_database.php new file mode 100644 index 0000000..5383cb5 --- /dev/null +++ b/exec/handlers/assign_database.php @@ -0,0 +1,88 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $dbNames = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + $roleNames = array_map(static fn(array $r): string => $r['name'], $ctx->mysql->listRolesForUser($daUser)); + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('assign_database_submit'); + + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + $roleName = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + + if (!in_array($dbName, $dbNames, true)) { + throw new RuntimeException('Baza nie należy do bieżącego użytkownika DA: ' . $dbName); + } + if (!in_array($roleName, $roleNames, true)) { + throw new RuntimeException('Użytkownik MySQL nie należy do bieżącego konta DA: ' . $roleName); + } + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + + $ctx->mysql->grantPrivileges($dbName, $roleName, $privileges); + + $hostEntries = $ctx->mysql->getRoleHostEntries($daUser, $roleName); + $hasLocalhost = false; + foreach ($hostEntries as $entry) { + if ($entry['host'] === 'localhost') { + $hasLocalhost = true; + break; + } + } + + if (!$hasLocalhost) { + $hostEntries[] = ['host' => 'localhost', 'note' => '']; + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $roleName, $hostEntries); + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Przypisanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Przypisano bazę ' . $dbName . ' do użytkownika ' . $roleName . '.'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbOptions = ''; + foreach ($dbNames as $dbName) { + $dbOptions .= ''; + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $roleOptions .= ''; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $body = ''; + $body .= '
'; + $body .= $ctx->csrfField('assign_database_submit'); + $body .= '
'; + $body .= '
'; + $body .= '
'; + $body .= '
'; + $body .= '

Uprawnienia

' . $privCheckboxes . '
'; + $body .= '
'; + $body .= '
'; + + $ctx->renderPage('Przypisywanie Bazy do Użytkownika', $body, $ok, $errors); +}; diff --git a/exec/handlers/change_password.php b/exec/handlers/change_password.php new file mode 100644 index 0000000..70dd850 --- /dev/null +++ b/exec/handlers/change_password.php @@ -0,0 +1,563 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + $fixedHosts = array_flip($ctx->mysql->requiredAccessHosts()); + $passwordChangedFor = ''; + $changedPassword = ''; + $createdRoleName = ''; + $createdRolePassword = ''; + $createdRoleAssignedDbs = []; + $showCreateUserForm = (strtolower(trim($ctx->queryString('add_user'))) === '1'); + $deletePromptRoleRaw = trim($ctx->postString('delete_role')); + if ($deletePromptRoleRaw === '') { + $deletePromptRoleRaw = trim($ctx->queryString('delete_role')); + } + $deletePromptRole = ''; + $deletePromptAssignedDbs = []; + + $createRoleSuffixInput = trim($ctx->postString('create_role_name')); + if (strpos($createRoleSuffixInput, $prefix) === 0) { + $createRoleSuffixInput = substr($createRoleSuffixInput, strlen($prefix)); + } + $createPasswordInput = $ctx->postString('create_password'); + $createSelectedDbsRaw = $ctx->postArray('create_user_databases'); + $createSelectedDbs = array_values(array_unique(array_filter($createSelectedDbsRaw, static fn(string $db): bool => $db !== ''))); + + $databases = $ctx->mysql->listDatabasesForUser($daUser); + $allDbs = array_map(static fn(array $db): string => $db['name'], $databases); + $roles = $ctx->mysql->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $preferredRoleRaw = $ctx->postString('role_name'); + if ($preferredRoleRaw === '') { + $preferredRoleRaw = $ctx->queryString('role'); + } + + if ($ctx->isPost()) { + $postAction = $ctx->postString('form_action'); + try { + if ($postAction === 'create_user_inline') { + $ctx->requireCsrf('create_user_inline_submit'); + $showCreateUserForm = true; + + $newRole = NamePolicy::normalizeRoleName($ctx->postString('create_role_name'), $prefix); + if ($ctx->mysql->roleExists($newRole)) { + throw new RuntimeException('Użytkownik MySQL już istnieje: ' . $newRole); + } + + $newPassword = $ctx->postString('create_password'); + if (strlen($newPassword) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $selectedDbs = []; + foreach ($createSelectedDbs as $rawDbName) { + $dbName = NamePolicy::normalizeDatabaseName($rawDbName, $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + if (!in_array($dbName, $selectedDbs, true)) { + $selectedDbs[] = $dbName; + } + } + + $ctx->mysql->createRole($newRole, $newPassword); + try { + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $newRole, []); + + foreach ($selectedDbs as $dbName) { + $ctx->mysql->grantPrivileges($dbName, $newRole, NamePolicy::defaultPrivileges()); + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Użytkownik został utworzony, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $createdRoleName = $newRole; + $createdRolePassword = $newPassword; + $createdRoleAssignedDbs = $selectedDbs; + + if (empty($selectedDbs)) { + $ok[] = 'Utworzono użytkownika ' . $newRole . ' bez przypisanych baz danych.'; + } else { + $ok[] = 'Utworzono użytkownika ' . $newRole . ' i przypisano do ' . count($selectedDbs) . ' baz(y).'; + } + + $preferredRoleRaw = $newRole; + $showCreateUserForm = false; + $createRoleSuffixInput = ''; + $createPasswordInput = ''; + $createSelectedDbs = []; + } catch (Throwable $inner) { + try { + $ctx->mysql->deleteRoleHosts($daUser, $newRole); + } catch (Throwable $ignored) { + } + try { + $ctx->mysql->dropRole($newRole); + } catch (Throwable $ignored) { + } + throw $inner; + } + } elseif ($postAction === 'delete_user_confirm') { + $ctx->requireCsrf('delete_user_submit'); + + $roleToDelete = NamePolicy::normalizeRoleName($ctx->postString('delete_role'), $prefix); + if (!in_array($roleToDelete, $roleNames, true)) { + throw new RuntimeException('Użytkownik MySQL nie należy do konta DA: ' . $roleToDelete); + } + + $owned = array_values(array_unique($ctx->mysql->roleOwnedDatabases($roleToDelete, $daUser))); + if (!empty($owned)) { + throw new RuntimeException( + 'Nie można usunąć użytkownika, ponieważ jest właścicielem baz: ' . implode(', ', $owned) + ); + } + + $assigned = array_values(array_unique($ctx->mysql->roleAssignedDatabases($roleToDelete, $daUser))); + foreach ($assigned as $dbName) { + $owner = $ctx->mysql->getDatabaseOwner($dbName); + if ($owner !== null && $owner !== $roleToDelete) { + $ctx->mysql->reassignOwnedObjects($dbName, $roleToDelete, $owner); + } + $ctx->mysql->revokePrivileges($dbName, $roleToDelete); + } + + $ctx->mysql->deleteRoleHosts($daUser, $roleToDelete); + $ctx->mysql->dropRole($roleToDelete); + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Usunięto użytkownika ' . $roleToDelete . '.'; + if ($preferredRoleRaw === $roleToDelete) { + $preferredRoleRaw = ''; + } + $deletePromptRoleRaw = ''; + } else { + $ctx->requireCsrf('user_manage_submit'); + if (empty($roleNames)) { + throw new RuntimeException('Brak użytkowników MySQL do zarządzania.'); + } + + $selectedRole = NamePolicy::normalizeRoleName($ctx->postString('role_name', $preferredRoleRaw), $prefix); + if (!in_array($selectedRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik MySQL nie należy do konta DA: ' . $selectedRole); + } + $preferredRoleRaw = $selectedRole; + + if ($postAction === 'change_password') { + $password = $ctx->postString('password'); + if (strlen($password) < 12) { + throw new RuntimeException('Nowe hasło musi mieć minimum 12 znaków.'); + } + $ctx->mysql->changeRolePassword($selectedRole, $password); + $passwordChangedFor = $selectedRole; + $changedPassword = $password; + } elseif ($postAction === 'grant_db') { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + if (!in_array($dbName, $allDbs, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $dbName); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->mysql->grantPrivileges($dbName, $selectedRole, $privileges); + $ok[] = 'Przyznano dostęp do bazy ' . $dbName . ' użytkownikowi ' . $selectedRole . '.'; + } elseif ($postAction === 'revoke_db') { + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('db_name'), $prefix); + $ctx->mysql->revokePrivileges($dbName, $selectedRole); + $ok[] = 'Odebrano dostęp do bazy ' . $dbName . ' użytkownikowi ' . $selectedRole . '.'; + } elseif ($postAction === 'add_host' && $showRemoteHosts) { + $host = NamePolicy::normalizeRemoteIpv4Host($ctx->postString('host')); + $note = NamePolicy::normalizeHostComment($ctx->postString('host_note')); + $entries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + $map = []; + foreach ($entries as $entry) { + $map[$entry['host']] = $entry['note']; + } + $map[$host] = $note; + $customHostsCount = 0; + foreach (array_keys($map) as $mapHost) { + if (!isset($fixedHosts[$mapHost])) { + $customHostsCount++; + } + } + if ($customHostsCount > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla użytkownika MySQL.'); + } + $save = []; + foreach ($map as $h => $n) { + $save[] = ['host' => $h, 'note' => $n]; + } + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $selectedRole, $save); + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Host dodany, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + $ok[] = 'Dodano host ' . $host . ' dla użytkownika ' . $selectedRole . '.'; + } elseif ($postAction === 'remove_host' && $showRemoteHosts) { + $host = trim($ctx->postString('host')); + if (isset($fixedHosts[$host])) { + throw new RuntimeException('Ten host jest dodawany automatycznie i nie moze zostac usuniety.'); + } + $entries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + $save = []; + foreach ($entries as $entry) { + if ($entry['host'] === $host) { + continue; + } + $save[] = $entry; + } + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $selectedRole, $save); + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Host usunięty, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + $ok[] = 'Usunięto host ' . $host . ' dla użytkownika ' . $selectedRole . '.'; + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + if ($postAction === 'create_user_inline') { + $showCreateUserForm = true; + } + if ($postAction === 'delete_user_confirm') { + $deletePromptRoleRaw = trim($ctx->postString('delete_role')); + } + } + } + + $databases = $ctx->mysql->listDatabasesForUser($daUser); + $allDbs = array_map(static fn(array $db): string => $db['name'], $databases); + $roles = $ctx->mysql->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $selectedRole = ''; + if (!empty($roleNames)) { + $candidateRaw = $preferredRoleRaw !== '' ? $preferredRoleRaw : $roleNames[0]; + try { + $candidate = NamePolicy::normalizeRoleName($candidateRaw, $prefix); + } catch (Throwable $e) { + $candidate = $roleNames[0]; + } + if (!in_array($candidate, $roleNames, true)) { + $candidate = $roleNames[0]; + } + $selectedRole = $candidate; + } + + if ($deletePromptRoleRaw !== '') { + try { + $deletePromptRole = NamePolicy::normalizeRoleName($deletePromptRoleRaw, $prefix); + if (!in_array($deletePromptRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik MySQL nie należy do konta DA: ' . $deletePromptRole); + } + $deletePromptAssignedDbs = array_values(array_unique($ctx->mysql->roleAssignedDatabases($deletePromptRole, $daUser))); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deletePromptRole = ''; + $deletePromptAssignedDbs = []; + } + } + + $createSelectedDbSet = []; + foreach ($createSelectedDbs as $rawDbName) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDbName, $prefix); + } catch (Throwable $e) { + continue; + } + if (in_array($dbName, $allDbs, true)) { + $createSelectedDbSet[$dbName] = true; + } + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $sel = ($roleName === $selectedRole) ? ' selected' : ''; + $roleOptions .= ''; + } + + $assignedDbs = []; + $grantableDbs = $allDbs; + if ($selectedRole !== '') { + $assignedDbs = $ctx->mysql->roleAssignedDatabases($selectedRole, $daUser); + $grantableDbs = array_values(array_diff($allDbs, $assignedDbs)); + } + + $dbOptions = ''; + foreach ($grantableDbs as $dbName) { + $dbOptions .= ''; + } + + $dbRows = ''; + foreach ($assignedDbs as $dbName) { + $profile = $ctx->mysql->getGrantProfile($dbName, $selectedRole); + $label = (empty($profile) || in_array('ALL', $profile, true)) + ? 'Pełny dostęp' + : implode(', ', $profile); + + $dbRows .= ''; + $dbRows .= '' . AppContext::e($dbName) . ''; + $dbRows .= '' . AppContext::e($label) . ''; + $dbRows .= ''; + $dbRows .= 'Zarządzaj'; + $dbRows .= 'Przywileje'; + $dbRows .= '
'; + $dbRows .= $ctx->csrfField('user_manage_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
'; + $dbRows .= ''; + $dbRows .= ''; + } + if ($dbRows === '') { + $dbRows = 'Brak przypisanych baz danych.'; + } + + $hostEntries = []; + if ($selectedRole !== '') { + $hostEntries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + } + + $hostsRows = ''; + $hostsCount = count($hostEntries); + if ($showRemoteHosts && $selectedRole !== '') { + $hostsCount = count($hostEntries); + foreach ($hostEntries as $entry) { + $host = $entry['host']; + $note = $entry['note']; + $hostsRows .= ''; + $hostsRows .= '' . AppContext::e($host) . ''; + $hostsRows .= '' . AppContext::e($note) . ''; + $hostsRows .= ''; + if (!isset($fixedHosts[$host])) { + $hostsRows .= '
'; + $hostsRows .= $ctx->csrfField('user_manage_submit'); + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= ''; + $hostsRows .= '
'; + } else { + $hostsRows .= 'host stały'; + } + $hostsRows .= ''; + $hostsRows .= ''; + } + if ($hostsRows === '') { + $hostsRows = 'Brak dozwolonych hostów.'; + } + } + + $endpoint = $ctx->mysql->connectionEndpoint(); + $endpointHost = trim($endpoint['host']) !== '' ? $endpoint['host'] : 'localhost'; + $endpointPort = $endpoint['port'] > 0 ? (string)$endpoint['port'] : '3306'; + $createdUserCard = ''; + if ($createdRoleName !== '' && $createdRolePassword !== '') { + $assignedDbsHtml = ''; + if (!empty($createdRoleAssignedDbs)) { + $assignedItems = ''; + foreach ($createdRoleAssignedDbs as $assignedDbName) { + $assignedItems .= '
  • ' . AppContext::e($assignedDbName) . '
  • '; + } + $assignedDbsHtml .= '
    Przypisane bazy danych:
    '; + $assignedDbsHtml .= '
    '; + } + + $createdUserCard .= '
    '; + $createdUserCard .= '
    '; + $createdUserCard .= '
    '; + $createdUserCard .= '

    Dane dostępowe użytkownika MySQL

    '; + $createdUserCard .= '
    '; + $createdUserCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $createdUserCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($createdRoleName) . '
    '; + $createdUserCard .= '
    Hasło:
    ' . AppContext::e($createdRolePassword) . '
    '; + $createdUserCard .= $assignedDbsHtml; + $createdUserCard .= '
    '; + } + + $passwordChangedCard = ''; + if ($passwordChangedFor !== '' && $changedPassword !== '') { + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '

    Hasło zostało zmienione

    '; + $passwordChangedCard .= '
    '; + $passwordChangedCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $passwordChangedCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($passwordChangedFor) . '
    '; + $passwordChangedCard .= '
    Hasło:
    ' . AppContext::e($changedPassword) . '
    '; + $passwordChangedCard .= '
    '; + } + + $body = ''; + $body .= $createdUserCard; + $body .= $passwordChangedCard; + $body .= '
    '; + $body .= '

    Zarządzaj użytkownikami

    '; + $body .= '
    '; + $body .= '
    '; + $body .= 'Dodaj użytkownika'; + if ($showCreateUserForm) { + $body .= 'Anuluj'; + } + $body .= '
    '; + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= 'Usuń użytkownika'; + $body .= '
    '; + } else { + $body .= 'Brak użytkowników MySQL. Dodaj pierwszego użytkownika.'; + } + $body .= '
    '; + $body .= '

    Szczegółowe informacje o użytkowniku.

    '; + if ($selectedRole !== '') { + $body .= ''; + $body .= ''; + if ($showRemoteHosts) { + $body .= ''; + } else { + $fixedHostSummary = implode(', ', array_keys($fixedHosts)); + $body .= ''; + } + $body .= '
    Nazwa użytkownika
    ' . AppContext::e($selectedRole) . '
    Bazy danych
    ' . AppContext::e((string)count($assignedDbs)) . '
    Dozwolone hosty
    ' . AppContext::e((string)$hostsCount) . '
    Dozwolone hosty
    ' . AppContext::e($fixedHostSummary) . '
    '; + } else { + $body .= '

    Aby zarządzać użytkownikami najpierw utwórz użytkownika MySQL.

    '; + } + $body .= '
    '; + + if ($deletePromptRole !== '') { + $cancelUrl = $ctx->url('change_password.html', $selectedRole !== '' ? ['role' => $selectedRole] : []); + $body .= '
    '; + $body .= '
    Czy na pewno chcesz usunąć użytkownika ' . AppContext::e($deletePromptRole) . '?
    '; + + if (count($deletePromptAssignedDbs) === 1) { + $body .= '
    Uwaga użytkownik ' . AppContext::e($deletePromptRole) . ' jest przypisany do następującej bazy danych ' . AppContext::e($deletePromptAssignedDbs[0]) . '.
    '; + } elseif (count($deletePromptAssignedDbs) > 1) { + $body .= '
    Uwaga użytkownik ' . AppContext::e($deletePromptRole) . ' jest przypisany do następujących baz danych:
    '; + $body .= ''; + } + + $body .= '
    '; + $body .= $ctx->csrfField('delete_user_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + if ($showCreateUserForm) { + $body .= '
    '; + $body .= '

    Utwórz nowego użytkownika MySQL

    '; + $body .= '
    '; + $body .= $ctx->csrfField('create_user_inline_submit'); + $body .= ''; + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= 'Przypisz do baz danych (opcjonalnie)'; + if (empty($allDbs)) { + $body .= '

    Brak baz do przypisania.

    '; + } else { + $body .= '
    '; + foreach ($allDbs as $dbName) { + $checked = isset($createSelectedDbSet[$dbName]) ? ' checked' : ''; + $body .= ''; + } + $body .= '
    '; + } + $body .= '

    Możesz pozostawić użytkownika bez przypisania do baz i nadać dostęp później.

    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '

    Zarządzanie hasłami

    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Dostęp do baz danych

    '; + $body .= '' . $dbRows . '
    Nazwa bazy danychPrzywilejeAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if ($showRemoteHosts) { + $fixedHostsHelp = '`localhost` jest dodawany automatycznie.'; + if (count($fixedHosts) > 1) { + $fixedHostsHelp .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.'; + } + $body .= '
    '; + $body .= '

    Dozwolone hosty

    '; + $body .= '

    Lista źródłowych adresów IP, które mogą łączyć się z bazą przy użyciu poświadczeń użytkownika. ' . $fixedHostsHelp . '

    '; + $body .= '' . $hostsRows . '
    HostKomentarzAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('user_manage_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + } + + $ctx->renderPage('Zarządzaj użytkownikami', $body, $ok, $errors); +}; diff --git a/exec/handlers/create_database.php b/exec/handlers/create_database.php new file mode 100644 index 0000000..ca413c6 --- /dev/null +++ b/exec/handlers/create_database.php @@ -0,0 +1,19 @@ +isPost()) { + if ($ctx->postString('form_action') === '') { + $_POST['form_action'] = 'create_database'; + } + if ($ctx->postString('db_suffix') === '' && $ctx->postString('db_name') !== '') { + $_POST['db_suffix'] = $ctx->postString('db_name'); + } + + $handler = require __DIR__ . '/index.php'; + $handler($ctx); + return; + } + + Http::redirect($ctx->url('index.html')); +}; diff --git a/exec/handlers/create_user.php b/exec/handlers/create_user.php new file mode 100644 index 0000000..dbec26f --- /dev/null +++ b/exec/handlers/create_user.php @@ -0,0 +1,150 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + $fixedHosts = array_flip($ctx->mysql->requiredAccessHosts()); + + $databases = $ctx->mysql->listDatabasesForUser($daUser); + $dbNames = array_map(static fn(array $db): string => $db['name'], $databases); + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('create_user_submit'); + + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if ($ctx->mysql->roleExists($role)) { + throw new RuntimeException('Użytkownik MySQL już istnieje: ' . $role); + } + + $password = $ctx->postString('password'); + if (strlen($password) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $selectedDbsRaw = $ctx->postArray('databases'); + $selectedDbs = []; + foreach ($selectedDbsRaw as $db) { + $normalizedDb = NamePolicy::normalizeDatabaseName($db, $prefix); + if (!in_array($normalizedDb, $dbNames, true)) { + throw new RuntimeException('Nieprawidłowa baza do przypisania: ' . $normalizedDb); + } + $selectedDbs[] = $normalizedDb; + } + $selectedDbs = array_values(array_unique($selectedDbs)); + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + + $hostMap = []; + if ($showRemoteHosts) { + $remoteHostRaw = trim($ctx->postString('remote_host')); + if ($remoteHostRaw !== '') { + $remoteHost = NamePolicy::normalizeRemoteIpv4Host($remoteHostRaw); + $remoteComment = NamePolicy::normalizeHostComment($ctx->postString('remote_comment')); + $hostMap[$remoteHost] = $remoteComment; + } + } + $customHostsCount = 0; + foreach (array_keys($hostMap) as $host) { + if (!isset($fixedHosts[$host])) { + $customHostsCount++; + } + } + if ($customHostsCount > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla użytkownika MySQL.'); + } + + $hostEntries = []; + foreach ($hostMap as $host => $note) { + $hostEntries[] = ['host' => $host, 'note' => $note]; + } + + $ctx->mysql->createRole($role, $password); + try { + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $role, $hostEntries); + + foreach ($selectedDbs as $dbName) { + $ctx->mysql->grantPrivileges($dbName, $role, $privileges); + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Użytkownik został utworzony, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Utworzono użytkownika ' . $role . ' i przypisano ' . count($selectedDbs) . ' baz(y).'; + } catch (Throwable $inner) { + try { + foreach ($selectedDbs as $dbName) { + $ctx->mysql->revokePrivileges($dbName, $role); + } + $ctx->mysql->deleteRoleHosts($daUser, $role); + $ctx->mysql->dropRole($role); + } catch (Throwable $ignored) { + } + throw $inner; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbCheckboxes = ''; + foreach ($dbNames as $dbName) { + $dbCheckboxes .= ''; + } + if ($dbCheckboxes === '') { + $dbCheckboxes = '

    Brak baz do przypisania.

    '; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $remoteFields = ''; + if ($showRemoteHosts) { + $remoteFields .= '
    '; + $remoteFields .= '
    '; + $remoteFields .= '
    '; + $remoteFields .= '
    '; + } else { + $remoteFields .= '

    Hosty zdalne są wyłączone w konfiguracji pluginu (`allow_remote_hosts=0`).

    '; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('create_user_submit'); + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $defaultAccessText = '`localhost` jest dodawany automatycznie.'; + if (count($fixedHosts) > 1) { + $defaultAccessText .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.'; + } + + $body .= '
    '; + $body .= '

    ' . $defaultAccessText . '

    '; + $body .= '
    ' . $dbCheckboxes . '
    '; + $body .= '
    '; + + $body .= $remoteFields; + + $body .= '

    Uprawnienia dla przypisywanych baz

    ' . $privCheckboxes . '
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Tworzenie Użytkownika MySQL', $body, $ok, $errors); +}; diff --git a/exec/handlers/delete_databases.php b/exec/handlers/delete_databases.php new file mode 100644 index 0000000..f1f6ae0 --- /dev/null +++ b/exec/handlers/delete_databases.php @@ -0,0 +1,170 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + + $selectedDatabases = $ctx->postArray('databases'); + if (empty($selectedDatabases)) { + $queryDb = $ctx->queryString('db'); + if ($queryDb !== '') { + $selectedDatabases = [$queryDb]; + } + } + + $selectedDatabases = array_values(array_unique(array_filter($selectedDatabases, static fn(string $v): bool => $v !== ''))); + + $confirmDelete = $ctx->postString('confirm_delete') === '1'; + + if ($ctx->isPost() && !$confirmDelete) { + try { + $ctx->requireCsrf('select_delete_databases'); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $selectedDatabases = []; + } + } + + if ($ctx->isPost() && $confirmDelete) { + try { + $ctx->requireCsrf('delete_databases_confirm'); + + if (empty($selectedDatabases)) { + throw new RuntimeException('Nie wybrano baz danych do usunięcia.'); + } + + $deleteRelatedUsers = $ctx->postString('delete_related_users') === '1'; + + $deletedDbs = []; + $deletedUsers = []; + $keptUsers = []; + + foreach ($selectedDatabases as $rawDb) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDb, $prefix); + if (!in_array($dbName, $availableDbs, true)) { + $errors[] = 'Pominięto bazę spoza konta: ' . $dbName; + continue; + } + + $users = $ctx->mysql->listDatabaseUsers($daUser, $dbName); + $users = array_values(array_unique($users)); + + foreach ($users as $roleName) { + $ctx->mysql->revokePrivileges($dbName, $roleName); + } + + $ctx->mysql->dropDatabase($dbName); + $deletedDbs[] = $dbName; + + if ($deleteRelatedUsers) { + foreach ($users as $roleName) { + $assigned = $ctx->mysql->roleAssignedDatabases($roleName, $daUser); + $owned = $ctx->mysql->roleOwnedDatabases($roleName, $daUser); + + if (empty($assigned) && empty($owned)) { + try { + $ctx->mysql->deleteRoleHosts($daUser, $roleName); + $ctx->mysql->dropRole($roleName); + $deletedUsers[] = $roleName; + } catch (Throwable $dropErr) { + $keptUsers[] = $roleName . ' (' . $dropErr->getMessage() . ')'; + } + } else { + $keptUsers[] = $roleName . ' (ma nadal przypisane bazy)'; + } + } + } + } catch (Throwable $inner) { + $errors[] = $rawDb . ': ' . $inner->getMessage(); + } + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Operacja wykonana, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + if (!empty($deletedDbs)) { + $ok[] = 'Usunięto bazy: ' . implode(', ', array_values(array_unique($deletedDbs))); + } + if (!empty($deletedUsers)) { + $ok[] = 'Usunięto użytkowników powiązanych z usuniętymi bazami: ' . implode(', ', array_values(array_unique($deletedUsers))); + } + if (!empty($keptUsers)) { + $errors[] = 'Użytkownicy pozostawieni: ' . implode('; ', array_values(array_unique($keptUsers))); + } + + $selectedDatabases = []; + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if (empty($selectedDatabases)) { + $rows = ''; + foreach ($availableDbs as $dbName) { + $users = $ctx->mysql->listDatabaseUsers($daUser, $dbName); + $rows .= ''; + $rows .= ''; + $rows .= '' . AppContext::e($dbName) . ''; + $rows .= '' . AppContext::e(implode(', ', $users)) . ''; + $rows .= ''; + } + if ($rows === '') { + $rows = 'Brak baz do usunięcia.'; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('select_delete_databases'); + $body .= '' . $rows . '
    BazaPrzypisani użytkownicy
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Usuwanie Baz Danych', $body, $ok, $errors); + return; + } + + $confirmRows = ''; + foreach ($selectedDatabases as $rawDb) { + try { + $dbName = NamePolicy::normalizeDatabaseName($rawDb, $prefix); + $users = $ctx->mysql->listDatabaseUsers($daUser, $dbName); + + $confirmRows .= ''; + $confirmRows .= '' . AppContext::e($dbName) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $users)) . ''; + $confirmRows .= ''; + } catch (Throwable $e) { + $errors[] = $rawDb . ': ' . $e->getMessage(); + } + } + + if ($confirmRows === '') { + $ctx->renderPage('Usuwanie Baz Danych', '

    Brak poprawnych baz do usunięcia.

    ', $ok, $errors); + return; + } + + $body = ''; + $body .= '

    Potwierdź usunięcie baz danych. Operacja jest nieodwracalna.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_databases_confirm'); + $body .= ''; + $body .= '' . $confirmRows . '
    BazaPrzypisani użytkownicy
    '; + $body .= '

    '; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Potwierdzenie Usuwania Baz Danych', $body, $ok, $errors); +}; diff --git a/exec/handlers/delete_users.php b/exec/handlers/delete_users.php new file mode 100644 index 0000000..feffcf1 --- /dev/null +++ b/exec/handlers/delete_users.php @@ -0,0 +1,158 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $availableRoles = array_map(static fn(array $r): string => $r['name'], $ctx->mysql->listRolesForUser($daUser)); + + $selectedRoles = $ctx->postArray('roles'); + if (empty($selectedRoles)) { + $queryRole = $ctx->queryString('role'); + if ($queryRole !== '') { + $selectedRoles = [$queryRole]; + } + } + + $selectedRoles = array_values(array_unique(array_filter($selectedRoles, static fn(string $v): bool => $v !== ''))); + + $confirmDelete = $ctx->postString('confirm_delete') === '1'; + + if ($ctx->isPost() && !$confirmDelete) { + try { + $ctx->requireCsrf('select_delete_users'); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $selectedRoles = []; + } + } + + if ($ctx->isPost() && $confirmDelete) { + try { + $ctx->requireCsrf('delete_users_confirm'); + + if (empty($selectedRoles)) { + throw new RuntimeException('Nie wybrano użytkowników do usunięcia.'); + } + + $deleted = []; + $skipped = []; + + foreach ($selectedRoles as $rawRole) { + try { + $role = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($role, $availableRoles, true)) { + $skipped[] = $role . ' (nie istnieje)'; + continue; + } + + $owned = $ctx->mysql->roleOwnedDatabases($role, $daUser); + if (!empty($owned)) { + $skipped[] = $role . ' (właściciel baz: ' . implode(', ', $owned) . ')'; + continue; + } + + $assigned = $ctx->mysql->roleAssignedDatabases($role, $daUser); + foreach ($assigned as $dbName) { + $owner = $ctx->mysql->getDatabaseOwner($dbName); + if ($owner !== null && $owner !== $role) { + $ctx->mysql->reassignOwnedObjects($dbName, $role, $owner); + } + $ctx->mysql->revokePrivileges($dbName, $role); + } + + $ctx->mysql->deleteRoleHosts($daUser, $role); + $ctx->mysql->dropRole($role); + $deleted[] = $role; + } catch (Throwable $inner) { + $skipped[] = $rawRole . ' (' . $inner->getMessage() . ')'; + } + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + if (!empty($deleted)) { + $ok[] = 'Usunięto użytkowników: ' . implode(', ', $deleted); + } + if (!empty($skipped)) { + $errors[] = 'Nie usunięto: ' . implode('; ', $skipped); + } + + $selectedRoles = []; + $availableRoles = array_map(static fn(array $r): string => $r['name'], $ctx->mysql->listRolesForUser($daUser)); + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if (empty($selectedRoles)) { + $rows = ''; + foreach ($availableRoles as $roleName) { + $assigned = $ctx->mysql->roleAssignedDatabases($roleName, $daUser); + $owned = $ctx->mysql->roleOwnedDatabases($roleName, $daUser); + $rows .= ''; + $rows .= ''; + $rows .= '' . AppContext::e($roleName) . ''; + $rows .= '' . AppContext::e(implode(', ', $assigned)) . ''; + $rows .= '' . AppContext::e(implode(', ', $owned)) . ''; + $rows .= ''; + } + if ($rows === '') { + $rows = 'Brak użytkowników do usunięcia.'; + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('select_delete_users'); + $body .= '' . $rows . '
    UżytkownikPrzypisane bazyBazy, których jest właścicielem
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Usuwanie Użytkowników', $body, $ok, $errors); + return; + } + + $confirmRows = ''; + foreach ($selectedRoles as $rawRole) { + try { + $role = NamePolicy::normalizeRoleName($rawRole, $prefix); + $assigned = $ctx->mysql->roleAssignedDatabases($role, $daUser); + $owned = $ctx->mysql->roleOwnedDatabases($role, $daUser); + $confirmRows .= ''; + $confirmRows .= '' . AppContext::e($role) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $assigned)) . ''; + $confirmRows .= '' . AppContext::e(implode(', ', $owned)) . ''; + $confirmRows .= ''; + } catch (Throwable $e) { + $errors[] = $rawRole . ': ' . $e->getMessage(); + } + } + + if ($confirmRows === '') { + $ctx->renderPage('Usuwanie Użytkowników', '

    Brak poprawnych użytkowników do usunięcia.

    ', $ok, $errors); + return; + } + + $body = ''; + $body .= '

    Potwierdź usunięcie zaznaczonych użytkowników MySQL. Operacja jest nieodwracalna.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_users_confirm'); + $body .= ''; + $body .= ''; + $body .= $confirmRows; + $body .= '
    UżytkownikPrzypisane bazyBazy, których jest właścicielem
    '; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Potwierdzenie Usuwania Użytkowników', $body, $ok, $errors); +}; diff --git a/exec/handlers/download.php b/exec/handlers/download.php new file mode 100644 index 0000000..d4eeee8 --- /dev/null +++ b/exec/handlers/download.php @@ -0,0 +1,111 @@ +action(), + $ctx->daUser->username(), + Http::server('REMOTE_ADDR'), + Http::method(), + Http::server('REQUEST_URI'), + $message + ); + @error_log($entry, 3, $pluginErrorLog); + }; + + try { + if (!$ctx->daUser->hasPluginAccess()) { + throw new RuntimeException('Brak dostępu do pluginu.'); + } + $queue = new BackupQueueService($ctx->settings, $ctx->daUser); + $jobId = trim($ctx->queryString('job')); + $backupPath = trim($ctx->queryString('path')); + $downloadPath = ''; + $logLabel = ''; + + if ($jobId !== '') { + $logLabel = 'job_id=' . $jobId; + $downloadPath = $queue->resolveBackupTargetFileForCurrentUser($jobId); + } elseif ($backupPath !== '') { + $logLabel = 'path=' . $backupPath; + $downloadPath = $queue->resolveUserBackupFileForCurrentUser($backupPath); + } else { + throw new RuntimeException('Brak parametru job lub path.'); + } + + // Reuse streaming logic from index handler (simplified) + $log('start ' . $logLabel); + if (!is_file($downloadPath) || !is_readable($downloadPath)) { + throw new RuntimeException('Plik backupu nie istnieje lub brak odczytu.'); + } + $downloadName = basename($downloadPath); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $downloadPath); + $size = @filesize($downloadPath); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + if ($rawMode) { + $statusLine = 'HTTP/1.1 200 OK'; + echo $statusLine . "\r\n"; + echo 'Content-Type: ' . $contentType . "\r\n"; + echo 'Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . "\"\r\n"; + echo "X-Content-Type-Options: nosniff\r\n"; + echo "Cache-Control: no-store, no-cache, must-revalidate\r\n"; + echo "Pragma: no-cache\r\n"; + if (is_int($size) && $size > 0) { + echo 'Content-Length: ' . (string)$size . "\r\n"; + } + echo "\r\n"; + } else { + if (headers_sent($hsFile, $hsLine)) { + $log('headers_sent ' . $logLabel . ' file=' . $hsFile . ' line=' . (string)$hsLine); + throw new RuntimeException('Nagłówki zostały już wysłane.'); + } + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($size) && $size > 0) { + header('Content-Length: ' . (string)$size); + } + } + + $bytes = @readfile($downloadPath); + $log('done ' . $logLabel . ' bytes=' . (string)$bytes); + exit; + } catch (Throwable $e) { + $log('error message=' . $e->getMessage()); + if ($rawMode) { + echo "HTTP/1.1 404 Not Found\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + } else { + http_response_code(404); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + } + exit; + } +}; diff --git a/exec/handlers/file_picker.php b/exec/handlers/file_picker.php new file mode 100644 index 0000000..9be2b29 --- /dev/null +++ b/exec/handlers/file_picker.php @@ -0,0 +1,18 @@ +settings, $ctx->daUser); + $root = '/home/' . $ctx->daUser->username(); + + if (FilePicker::handleDirList($ctx, $queue, $root)) { + return; + } + if (FilePicker::handleDirCreate($ctx, $root, $ctx->daUser->username())) { + return; + } + + http_response_code(404); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Not Found'; +}; diff --git a/exec/handlers/index.php b/exec/handlers/index.php new file mode 100644 index 0000000..a2e7668 --- /dev/null +++ b/exec/handlers/index.php @@ -0,0 +1,792 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + $showRemoteHosts = $ctx->settings->allowRemoteHosts(); + $fixedHosts = array_flip($ctx->mysql->requiredAccessHosts()); + $dbLimit = $ctx->daUser->maxDatabases(); + + if ($showRemoteHosts && !$ctx->isPost()) { + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Synchronizacja zdalnych hostów MySQL nie powiodła się: ' . $sync['output']; + } + } + + $generatedPassword = ''; + $generatedRole = ''; + $generatedDb = ''; + $advancedPosted = false; + $formAction = $ctx->postString('form_action'); + $deletePromptDbRaw = ''; + $deletePromptSelectedRolesRaw = []; + $deleteBackupJobIdRaw = ''; + $deleteBackupJobId = ''; + $deleteBackupDbName = ''; + $backupQueue = new BackupQueueService($ctx->settings, $ctx->daUser); + $backupEnabled = $ctx->settings->enableBackup(); + $uploadBackupEnabled = $ctx->settings->enableUploadBackup(); + $pluginErrorLog = PLUGIN_ROOT . '/error.log'; + $logDownload = static function (string $message) use ($pluginErrorLog, $ctx, $daUser): void { + $entry = sprintf( + "[%s] DOWNLOAD action=%s user=%s remote=%s method=%s uri=%s %s\n", + date('c'), + $ctx->action(), + $daUser, + Http::server('REMOTE_ADDR'), + Http::method(), + Http::server('REQUEST_URI'), + $message + ); + @error_log($entry, 3, $pluginErrorLog); + }; + + $streamBackupDownload = static function (string $downloadJobId) use ($backupQueue, $logDownload): void { + $downloadJobId = trim($downloadJobId); + if ($downloadJobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $logDownload('start job_id=' . $downloadJobId); + $downloadPath = $backupQueue->resolveBackupTargetFileForCurrentUser($downloadJobId); + if (!is_file($downloadPath)) { + throw new RuntimeException('Plik backupu nie istnieje na serwerze.'); + } + if (!is_readable($downloadPath)) { + throw new RuntimeException('Brak uprawnień odczytu pliku backupu.'); + } + + $downloadName = basename($downloadPath); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $downloadPath); + $contentLength = @filesize($downloadPath); + $logDownload( + 'prepare job_id=' . $downloadJobId . + ' file=' . $downloadPath . + ' size=' . (is_int($contentLength) ? (string)$contentLength : 'unknown') . + ' ob_level=' . (string)ob_get_level() + ); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + if (headers_sent($hsFile, $hsLine)) { + $logDownload('headers_sent job_id=' . $downloadJobId . ' file=' . $hsFile . ' line=' . (string)$hsLine); + throw new RuntimeException('Nie można pobrać backupu (nagłówki zostały już wysłane).'); + } + + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($contentLength) && $contentLength > 0) { + header('Content-Length: ' . (string)$contentLength); + } + + $bytes = @readfile($downloadPath); + if ($bytes === false) { + throw new RuntimeException('Nie można odczytać pliku backupu do pobrania.'); + } + $logDownload('done job_id=' . $downloadJobId . ' bytes=' . (string)$bytes); + exit; + }; + + $action = Http::getString($_REQUEST, 'action'); + if ($action === 'download_backup') { + try { + $ctx->requireCsrf('download_backup_file_submit'); + $streamBackupDownload(Http::getString($_REQUEST, 'job')); + } catch (Throwable $e) { + $logDownload('error job_id=' . trim(Http::getString($_REQUEST, 'job')) . ' message=' . $e->getMessage()); + http_response_code(400); + header('Content-Type: text/plain; charset=UTF-8'); + echo 'Nie można pobrać backupu: ' . $e->getMessage(); + exit; + } + } + + if ($ctx->isPost() && $formAction === 'queue_backup') { + try { + $ctx->requireCsrf('queue_backup_submit'); + if (!$backupEnabled) { + throw new RuntimeException('Tworzenie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $dbName = NamePolicy::normalizeDatabaseName($ctx->postString('backup_db'), $prefix); + $availableDbs = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + if (!in_array($dbName, $availableDbs, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $dbName); + } + + $backupQueue->queueBackupJob($dbName); + $ok[] = 'Kopia zapasowa bazy danych ' . $dbName . ' została dodana do kolejki'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_backup_file') { + try { + $ctx->requireCsrf('delete_backup_file_submit'); + $jobId = trim($ctx->postString('backup_job_id')); + if ($jobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $deletedPath = $backupQueue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $backupQueue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // Ignore log cleanup failure. + } + $ok[] = 'Usunięto plik backupu: ' . $deletedPath; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_backup_confirm') { + try { + $ctx->requireCsrf('delete_backup_confirm_submit'); + $jobId = trim($ctx->postString('backup_job_id')); + if ($jobId === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + + $deletedPath = $backupQueue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $backupQueue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // Ignore log cleanup failure. + } + $ok[] = 'Usunięto plik backupu: ' . $deletedPath; + $deleteBackupJobIdRaw = ''; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deleteBackupJobIdRaw = trim($ctx->postString('backup_job_id')); + } + } + + if ($ctx->isPost() && $formAction === 'download_backup_file') { + try { + $ctx->requireCsrf('download_backup_file_submit'); + $streamBackupDownload($ctx->postString('backup_job_id')); + } catch (Throwable $e) { + $logDownload('error job_id=' . trim($ctx->postString('backup_job_id')) . ' message=' . $e->getMessage()); + $errors[] = 'Nie można pobrać backupu: ' . $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'create_database') { + try { + $ctx->requireCsrf('create_database_submit'); + + $limit = $ctx->daUser->maxDatabases(); + $used = $ctx->mysql->countDatabasesForUser($daUser); + if ($limit > 0 && $used >= $limit) { + throw new RuntimeException('Osiągnięto limit baz danych dla konta (' . $used . '/' . $limit . ').'); + } + + $dbRaw = trim($ctx->postString('db_suffix')); + if ($dbRaw === '') { + $dbRaw = trim($ctx->postString('db_name')); + } + $dbName = NamePolicy::normalizeDatabaseName($dbRaw, $prefix); + if ($ctx->mysql->databaseExists($dbName)) { + throw new RuntimeException('Baza danych już istnieje: ' . $dbName); + } + + $creationMode = strtolower($ctx->postString('creation_mode', 'simple')); + $roleModeRaw = strtolower($ctx->postString('role_mode', 'new')); + $advancedPosted = ($creationMode === 'advanced'); + if (!$advancedPosted) { + $advancedPosted = + trim($ctx->postString('role_name')) !== '' || + trim($ctx->postString('password')) !== '' || + trim($ctx->postString('existing_role')) !== '' || + $roleModeRaw === 'existing'; + } + + $roleMode = ($advancedPosted && $roleModeRaw === 'existing') ? 'existing' : 'new'; + $selectedRole = ''; + $createdRole = false; + $createdDatabase = false; + + if ($roleMode === 'existing') { + $selectedRole = NamePolicy::normalizeRoleName($ctx->postString('existing_role'), $prefix); + if (!$ctx->mysql->roleExists($selectedRole)) { + throw new RuntimeException('Wybrany użytkownik MySQL nie istnieje: ' . $selectedRole); + } + } else { + $roleInput = $ctx->postString('role_name'); + if ($roleInput === '') { + $roleInput = $dbName; + } + $selectedRole = NamePolicy::normalizeRoleName($roleInput, $prefix); + if ($ctx->mysql->roleExists($selectedRole)) { + throw new RuntimeException('Użytkownik MySQL już istnieje: ' . $selectedRole); + } + + $password = $ctx->postString('password'); + if ($advancedPosted && $password === '') { + throw new RuntimeException('Hasło użytkownika jest wymagane w trybie zaawansowanym.'); + } + if (!$advancedPosted) { + $password = rtrim(strtr(base64_encode(random_bytes(18)), '+/', 'AZ'), '='); + } + if (strlen($password) < 12) { + throw new RuntimeException('Hasło użytkownika musi mieć co najmniej 12 znaków.'); + } + + $ctx->mysql->createRole($selectedRole, $password); + $createdRole = true; + $generatedPassword = $password; + } + + try { + $ctx->mysql->createDatabase($dbName, $selectedRole); + $createdDatabase = true; + + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->mysql->grantPrivileges($dbName, $selectedRole, $privileges); + + $hostEntries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + $hostMap = []; + foreach ($hostEntries as $entry) { + $hostMap[$entry['host']] = $entry['note']; + } + + $needsHostUpdate = $createdRole; + + if ($showRemoteHosts && $advancedPosted) { + $remoteHostRaw = trim($ctx->postString('remote_host')); + if ($remoteHostRaw !== '') { + $remoteHost = NamePolicy::normalizeRemoteIpv4Host($remoteHostRaw); + $remoteComment = NamePolicy::normalizeHostComment($ctx->postString('remote_comment')); + if (!array_key_exists($remoteHost, $hostMap) || $hostMap[$remoteHost] !== $remoteComment) { + $needsHostUpdate = true; + } + $hostMap[$remoteHost] = $remoteComment; + } + } + + if ($needsHostUpdate) { + $customHostsCount = 0; + foreach (array_keys($hostMap) as $host) { + if (!isset($fixedHosts[$host])) { + $customHostsCount++; + } + } + if ($customHostsCount > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla użytkownika MySQL.'); + } + + $finalEntries = []; + foreach ($hostMap as $host => $note) { + $finalEntries[] = ['host' => $host, 'note' => $note]; + } + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $selectedRole, $finalEntries); + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Baza i uprawnienia zapisane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $generatedRole = $selectedRole; + $generatedDb = $dbName; + $ok[] = 'Baza danych została utworzona.'; + } catch (Throwable $inner) { + if ($createdDatabase) { + try { + $ctx->mysql->dropDatabase($dbName); + } catch (Throwable $ignored) { + } + } + if ($createdRole) { + try { + $ctx->mysql->dropRole($selectedRole); + } catch (Throwable $ignored) { + } + } + throw $inner; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($ctx->isPost() && $formAction === 'delete_database_confirm') { + try { + $ctx->requireCsrf('delete_database_submit'); + + $deletePromptDbRaw = trim($ctx->postString('delete_db')); + if ($deletePromptDbRaw === '') { + throw new RuntimeException('Nie wskazano bazy danych do usunięcia.'); + } + + $deletePromptSelectedRolesRaw = $ctx->postArray('delete_roles'); + $deleteMode = strtolower(trim($ctx->postString('delete_mode'))); + $dbToDelete = NamePolicy::normalizeDatabaseName($deletePromptDbRaw, $prefix); + + $availableBeforeDelete = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + if (!in_array($dbToDelete, $availableBeforeDelete, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $dbToDelete); + } + + $assignedRoles = array_values(array_unique($ctx->mysql->listDatabaseUsers($daUser, $dbToDelete))); + $singleMatchingRole = count($assignedRoles) === 1 && $assignedRoles[0] === $dbToDelete; + foreach ($assignedRoles as $roleName) { + $ctx->mysql->revokePrivileges($dbToDelete, $roleName); + } + + $ctx->mysql->dropDatabase($dbToDelete); + + $rolesToDelete = []; + if ($deleteMode === 'with_user' && $singleMatchingRole) { + $rolesToDelete[] = $assignedRoles[0]; + } elseif ($deleteMode !== 'db_only') { + foreach ($deletePromptSelectedRolesRaw as $rawRole) { + $roleName = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($roleName, $assignedRoles, true)) { + continue; + } + if (!in_array($roleName, $rolesToDelete, true)) { + $rolesToDelete[] = $roleName; + } + } + } + + $deletedUsers = []; + $skippedUsers = []; + foreach ($rolesToDelete as $roleName) { + $remainingAssigned = array_values(array_unique($ctx->mysql->roleAssignedDatabases($roleName, $daUser))); + foreach ($remainingAssigned as $remainingDb) { + $ctx->mysql->revokePrivileges($remainingDb, $roleName); + } + + $remainingOwned = array_values(array_unique($ctx->mysql->roleOwnedDatabases($roleName, $daUser))); + if (!empty($remainingOwned)) { + $skippedUsers[] = $roleName . ' (jest właścicielem baz: ' . implode(', ', $remainingOwned) . ')'; + continue; + } + + try { + $ctx->mysql->deleteRoleHosts($daUser, $roleName); + $ctx->mysql->dropRole($roleName); + $deletedUsers[] = $roleName; + } catch (Throwable $dropErr) { + $skippedUsers[] = $roleName . ' (' . $dropErr->getMessage() . ')'; + } + } + + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Usuwanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + if (count($deletedUsers) === 1) { + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . ' i użytkownika ' . $deletedUsers[0] . '.'; + } elseif (count($deletedUsers) > 1) { + $listItems = ''; + foreach ($deletedUsers as $deletedUser) { + $listItems .= "\n• " . $deletedUser; + } + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . ' i następujących użytkowników przypisanych do bazy danych ' . $dbToDelete . ':' . $listItems; + } else { + $ok[] = 'Usunięto bazę danych ' . $dbToDelete . '.'; + } + if (!empty($skippedUsers)) { + $errors[] = 'Nie udało się usunąć części użytkowników: ' . implode('; ', $skippedUsers) . '.'; + } + + $deletePromptDbRaw = ''; + $deletePromptSelectedRolesRaw = []; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + if ($deletePromptDbRaw === '') { + $deletePromptDbRaw = trim($ctx->postString('delete_db')); + } + if (empty($deletePromptSelectedRolesRaw)) { + $deletePromptSelectedRolesRaw = $ctx->postArray('delete_roles'); + } + } + } + + $showTableCount = $ctx->settings->enableTableCount(); + $showDbSize = $ctx->settings->countDatabaseSize(); + $databases = $ctx->mysql->listDatabasesForUser($daUser, $showDbSize); + $dbUsersMap = $ctx->mysql->listDatabaseUsersMap($daUser); + $roles = $ctx->mysql->listRolesForUser($daUser); + $roleNames = array_map(static fn(array $r): string => $r['name'], $roles); + $dbCount = count($databases); + $endpoint = $ctx->mysql->connectionEndpoint(); + $endpointHost = trim($endpoint['host']) !== '' ? $endpoint['host'] : 'localhost'; + $endpointPort = $endpoint['port'] > 0 ? (string)$endpoint['port'] : '3306'; + $serverVersion = ''; + try { + $serverVersion = $ctx->mysql->serverVersion(); + } catch (Throwable $e) { + $serverVersion = ''; + } + $advancedOpen = $advancedPosted || (strtolower($ctx->postString('creation_mode', 'simple')) === 'advanced'); + if (!$ctx->isPost() && $deletePromptDbRaw === '') { + $deletePromptDbRaw = trim($ctx->queryString('delete_db')); + } + if (!$ctx->isPost() && $deleteBackupJobIdRaw === '') { + $deleteBackupJobIdRaw = trim($ctx->queryString('delete_backup')); + } + /** @var array $overlayHtmlById */ + $overlayHtmlById = []; + $deletePromptDb = ''; + $deletePromptRows = ''; + $deletePromptWarnings = []; + $deletePromptSelectedRoles = []; + $deletePromptSingleRoleMode = false; + $deletePromptSingleRoleName = ''; + + $limitText = $dbLimit === 0 ? 'Bez ograniczeń' : (string)$dbLimit; + $usageWarn = ''; + + $tableCounts = []; + if ($showTableCount) { + foreach ($databases as $dbRow) { + $dbName = $dbRow['name']; + try { + $stats = $ctx->mysql->getDatabaseObjectStats($dbName); + $tableCounts[$dbName] = (int)$stats['tables']; + } catch (Throwable $e) { + $tableCounts[$dbName] = 0; + } + } + } + + $dbRows = ''; + foreach ($databases as $db) { + $name = $db['name']; + $size = $db['size']; + $users = $dbUsersMap[$name] ?? []; + + $dbRows .= ''; + $dbRows .= '' . AppContext::e($name) . ''; + if ($showDbSize) { + $dbRows .= '' . AppContext::e($size) . ''; + } + $dbRows .= '' . AppContext::e((string)count($users)) . ''; + if ($showTableCount) { + $dbRows .= '' . AppContext::e((string)($tableCounts[$name] ?? 0)) . ''; + } + $dbRows .= ''; + if ($ctx->settings->enableAdminer()) { + $dbRows .= '
    '; + $dbRows .= $ctx->csrfField('open_phpmyadmin_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
    '; + } + if ($backupEnabled) { + $locked = $backupQueue->isDbLocked($name); + if (!$locked) { + $dbRows .= '
    '; + $dbRows .= $ctx->csrfField('queue_backup_submit'); + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= ''; + $dbRows .= '
    '; + } else { + $dbRows .= ''; + } + } + $dbRows .= 'Zarządzaj'; + $dbRows .= 'Usuń bazę'; + $dbRows .= ''; + $dbRows .= ''; + } + if ($dbRows === '') { + $colspan = 3 + ($showDbSize ? 1 : 0) + ($showTableCount ? 1 : 0); + $dbRows = 'Nie znaleziono baz danych...'; + } + + if ($deletePromptDbRaw !== '') { + try { + $deletePromptDb = NamePolicy::normalizeDatabaseName($deletePromptDbRaw, $prefix); + $dbNames = array_map(static fn(array $db): string => $db['name'], $databases); + if (!in_array($deletePromptDb, $dbNames, true)) { + throw new RuntimeException('Wskazana baza danych nie należy do konta: ' . $deletePromptDb); + } + + foreach ($deletePromptSelectedRolesRaw as $rawRole) { + $roleName = NamePolicy::normalizeRoleName($rawRole, $prefix); + if (!in_array($roleName, $deletePromptSelectedRoles, true)) { + $deletePromptSelectedRoles[] = $roleName; + } + } + + $assignedRoles = array_values(array_unique($ctx->mysql->listDatabaseUsers($daUser, $deletePromptDb))); + if (count($assignedRoles) === 1 && $assignedRoles[0] === $deletePromptDb) { + $deletePromptSingleRoleMode = true; + $deletePromptSingleRoleName = $assignedRoles[0]; + } else { + foreach ($assignedRoles as $roleName) { + $assignedDbs = array_values(array_unique($ctx->mysql->roleAssignedDatabases($roleName, $daUser))); + $isChecked = in_array($roleName, $deletePromptSelectedRoles, true) ? ' checked' : ''; + $deletePromptRows .= ''; + $deletePromptRows .= ''; + $deletePromptRows .= '' . AppContext::e($roleName) . ''; + + if (empty($assignedDbs)) { + $deletePromptRows .= 'Brak przypisań'; + } else { + $dbList = ''; + foreach ($assignedDbs as $dbName) { + $dbList .= '
  • ' . AppContext::e($dbName) . '
  • '; + } + $deletePromptRows .= ''; + } + $deletePromptRows .= ''; + + if (count($assignedDbs) > 1) { + $deletePromptWarnings[] = 'Uwaga: wskazany użytkownik jest przypisany do wielu baz danych i jego usunięcie spowoduje brak możliwości dostępu do innych baz danych przy użyciu danych logowania użytkownika ' . $roleName . '.'; + } + } + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + $deletePromptDb = ''; + $deletePromptRows = ''; + $deletePromptWarnings = []; + $deletePromptSelectedRoles = []; + $deletePromptSingleRoleMode = false; + $deletePromptSingleRoleName = ''; + } + } + + $roleOptions = ''; + $postedExistingRole = $ctx->postString('existing_role'); + foreach ($roleNames as $roleName) { + $selected = ($postedExistingRole === $roleName) ? ' selected' : ''; + $roleOptions .= ''; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = ($code === 'ALL') ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $successCard = ''; + if ($generatedDb !== '' && $generatedRole !== '') { + $passwordLine = $generatedPassword !== '' + ? '
    Hasło:
    ' . AppContext::e($generatedPassword) . '
    ' + : ''; + + $successCard .= '
    '; + $successCard .= '
    '; + $successCard .= '
    '; + $successCard .= '

    Dane dostępowe do bazy danych

    '; + $successCard .= '
    '; + $successCard .= '
    Nazwa hosta:
    ' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')
    '; + $successCard .= '
    Baza danych:
    ' . AppContext::e($generatedDb) . '
    '; + $successCard .= '
    Nazwa użytkownika:
    ' . AppContext::e($generatedRole) . '
    '; + $successCard .= $passwordLine; + $successCard .= '
    '; + } + + $body = ''; + $body .= $successCard; + $body .= $usageWarn; + $phpMyAdminWarning = $ctx->settings->phpMyAdminIntegrationWarning(); + if ($phpMyAdminWarning !== '') { + $errors[] = $phpMyAdminWarning; + } + + if ($deleteBackupJobIdRaw !== '') { + try { + $deleteBackupJobId = trim($deleteBackupJobIdRaw); + $job = $backupQueue->getJobForCurrentUser($deleteBackupJobId); + if (($job['type'] ?? '') !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + if (($job['status'] ?? '') !== 'done_ok') { + throw new RuntimeException('Backup nie został zakończony powodzeniem.'); + } + $deleteBackupDbName = (string)($job['db_name'] ?? ''); + if ($deleteBackupDbName === '') { + $deleteBackupDbName = 'nieznana baza'; + } + + $body .= '
    '; + $body .= '
    Czy na pewno chcesz usunąć backup bazy danych - ' . AppContext::e($deleteBackupDbName) . '?
    '; + $body .= '
    '; + $body .= $ctx->csrfField('delete_backup_confirm_submit'); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($deletePromptDb !== '') { + $body .= '
    '; + if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') { + $body .= '
    Czy na pewno chcesz usunąć następującą bazę danych - ' . AppContext::e($deletePromptDb) . ' i przypisanego do niej użytkownika ' . AppContext::e($deletePromptSingleRoleName) . '.
    '; + } else { + $body .= '
    Czy na pewno chcesz usunąć następującą bazę danych - ' . AppContext::e($deletePromptDb) . '.
    '; + } + $body .= '
    '; + $body .= $ctx->csrfField('delete_database_submit'); + $body .= ''; + $body .= ''; + + if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') { + $body .= '
    '; + $body .= ''; + $body .= 'Nie'; + $body .= ''; + $body .= '
    '; + } else { + if ($deletePromptRows !== '') { + $body .= '

    Do bazy danych ' . AppContext::e($deletePromptDb) . ' przypisani są użytkownicy, z poniższej listy wybierz użytkowników, których chcesz usunąć.

    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '' . $deletePromptRows . '
    WybierzNazwa użytkownikaPrzypisane Bazy danych
    '; + } else { + $body .= '

    Do wskazanej bazy nie są przypisani użytkownicy MySQL.

    '; + } + + if (!empty($deletePromptWarnings)) { + foreach (array_values(array_unique($deletePromptWarnings)) as $warning) { + $body .= '
    ' . AppContext::e($warning) . '
    '; + } + } + + $body .= '
    '; + $body .= ''; + $body .= 'Anuluj'; + $body .= '
    '; + } + $body .= '
    '; + $body .= '
    '; + } + + $dbSuffixValue = $ctx->postString('db_suffix'); + if (strpos($dbSuffixValue, $prefix) === 0) { + $dbSuffixValue = substr($dbSuffixValue, strlen($prefix)); + } + $roleSuffixValue = $ctx->postString('role_name'); + if (strpos($roleSuffixValue, $prefix) === 0) { + $roleSuffixValue = substr($roleSuffixValue, strlen($prefix)); + } + + $body .= '
    '; + $body .= '

    Lista baz danych

    '; + $body .= '
    '; + if ($serverVersion !== '') { + $body .= '' . AppContext::e($ctx->t('Wersja serwera MySQL')) . ': ' . AppContext::e($serverVersion) . ''; + } + $body .= 'Ilość baz danych ' . AppContext::e((string)$dbCount) . '/' . AppContext::e($limitText) . ''; + $body .= '
    '; + $header = 'Nazwa bazy danych'; + if ($showDbSize) { + $header .= 'Rozmiar'; + } + $header .= 'Użytkownicy'; + if ($showTableCount) { + $header .= 'Liczba Tabel'; + } + $header .= 'Akcje'; + $body .= '' . $header . '' . $dbRows . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Utwórz bazę danych

    '; + $body .= '

    Tryb prosty tworzy użytkownika o tej samej nazwie co baza i automatycznie generuje hasło.

    '; + $body .= '
    '; + $body .= $ctx->csrfField('create_database_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '

    Użytkownik o tej samej nazwie i bezpiecznym haśle zostanie wygenerowany automatycznie.

    '; + + $body .= '
    '; + $body .= 'Tryb zaawansowany'; + $body .= '
    '; + $body .= '
    '; + $roleModeCurrent = strtolower($ctx->postString('role_mode', 'new')); + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    ' . AppContext::e($prefix) . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    W trybie zaawansowanym hasło jest wymagane.

    '; + $body .= '
    '; + + if ($showRemoteHosts) { + $defaultHostHelp = '`localhost` jest dodawany automatycznie.'; + if (count($fixedHosts) > 1) { + $defaultHostHelp .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.'; + } + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '

    ' . $defaultHostHelp . '

    '; + } + + $body .= '
    ' . $privCheckboxes . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if (!empty($overlayHtmlById)) { + $body .= implode('', array_values($overlayHtmlById)); + } + + $ctx->renderPage('Zarządzanie bazami danych MySQL', $body, $ok, $errors); +}; diff --git a/exec/handlers/manage_hosts.php b/exec/handlers/manage_hosts.php new file mode 100644 index 0000000..2f90e96 --- /dev/null +++ b/exec/handlers/manage_hosts.php @@ -0,0 +1,162 @@ +settings->allowRemoteHosts()) { + $ctx->renderFatal('Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).', 403); + return; + } + + $ok = []; + $errors = []; + + $daUser = $ctx->daUser->username(); + $prefix = $ctx->daUser->prefix(); + $fixedHosts = array_flip($ctx->mysql->requiredAccessHosts()); + + if (!$ctx->isPost()) { + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Synchronizacja zdalnych hostów MySQL nie powiodła się: ' . $sync['output']; + } + } + + $roleNames = array_map(static fn(array $r): string => $r['name'], $ctx->mysql->listRolesForUser($daUser)); + $selectedRole = $ctx->queryString('role'); + + if ($ctx->isPost()) { + $selectedRole = $ctx->postString('role_name'); + + try { + $ctx->requireCsrf('manage_hosts_submit'); + + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + if (!in_array($selectedRole, $roleNames, true)) { + throw new RuntimeException('Użytkownik MySQL nie należy do konta DA: ' . $selectedRole); + } + + $currentEntries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + $hostMap = []; + foreach ($currentEntries as $entry) { + $hostMap[$entry['host']] = $entry['note']; + } + + $removeHosts = $ctx->postArray('remove_hosts'); + $removeHosts = array_values(array_unique($removeHosts)); + foreach ($removeHosts as $rawHost) { + $host = trim($rawHost); + if ($host === '' || isset($fixedHosts[$host])) { + continue; + } + if (array_key_exists($host, $hostMap)) { + unset($hostMap[$host]); + } + } + + $addHostRaw = trim($ctx->postString('add_host')); + if ($addHostRaw !== '') { + $addHost = NamePolicy::normalizeRemoteIpv4Host($addHostRaw); + $addComment = NamePolicy::normalizeHostComment($ctx->postString('add_comment')); + $hostMap[$addHost] = $addComment; + } + + $customHostsCount = 0; + foreach (array_keys($hostMap) as $host) { + if (!isset($fixedHosts[$host])) { + $customHostsCount++; + } + } + if ($customHostsCount > $ctx->settings->maxHostsPerUser()) { + throw new RuntimeException('Przekroczono limit hostów dla użytkownika MySQL.'); + } + + $finalEntries = []; + foreach ($hostMap as $host => $note) { + $finalEntries[] = ['host' => $host, 'note' => $note]; + } + + $ctx->mysql->replaceRoleHostsWithNotes($daUser, $selectedRole, $finalEntries); + $sync = $ctx->mysql->syncHostRules(); + if (!$sync['ok']) { + $errors[] = 'Hosty zapisane, ale synchronizacja hostów MySQL nie powiodła się: ' . $sync['output']; + } + + $ok[] = 'Zaktualizowano hosty dostępu dla użytkownika ' . $selectedRole . '.'; + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + if ($selectedRole !== '') { + try { + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + } catch (Throwable $e) { + $selectedRole = ''; + } + } + + if ($selectedRole === '' && !empty($roleNames)) { + $selectedRole = $roleNames[0]; + } + + $currentEntries = []; + if ($selectedRole !== '') { + $currentEntries = $ctx->mysql->getRoleHostEntries($daUser, $selectedRole); + } + + $roleOptions = ''; + foreach ($roleNames as $roleName) { + $sel = ($roleName === $selectedRole) ? ' selected' : ''; + $roleOptions .= ''; + } + + $hostRows = ''; + if (empty($currentEntries)) { + $hostRows = 'Brak zapisanych hostów.'; + } else { + foreach ($currentEntries as $entry) { + $host = $entry['host']; + $note = $entry['note']; + $canRemove = !isset($fixedHosts[$host]); + + $hostRows .= ''; + $hostRows .= '' . AppContext::e($host) . ''; + $hostRows .= '' . AppContext::e($note) . ''; + if ($canRemove) { + $hostRows .= ''; + } else { + $hostRows .= 'host stały'; + } + $hostRows .= ''; + } + } + + $body = ''; + $body .= '
    '; + $body .= $ctx->csrfField('manage_hosts_submit'); + + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $infoText = 'Dozwolone są wyłącznie pojedyncze adresy IPv4, bez CIDR i bez nazw hostów.'; + if (count($fixedHosts) > 1) { + $infoText .= ' `localhost` oraz adres IP serwera DirectAdmin sa hostami stalymi i nie mozna ich usunac.'; + } else { + $infoText .= ' `localhost` jest hostem stalym i nie mozna go usunac.'; + } + + $body .= '
    '; + $body .= '
    '; + $body .= '

    ' . $infoText . '

    '; + $body .= '
    '; + + $body .= '

    Aktualne hosty

    '; + $body .= '' . $hostRows . '
    HostKomentarzAkcja
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + $ctx->renderPage('Hosty Zdalne Użytkownika', $body, $ok, $errors); +}; diff --git a/exec/handlers/modify_privileges.php b/exec/handlers/modify_privileges.php new file mode 100644 index 0000000..8c5b1f7 --- /dev/null +++ b/exec/handlers/modify_privileges.php @@ -0,0 +1,202 @@ +daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $dbNames = array_map(static fn(array $db): string => $db['name'], $ctx->mysql->listDatabasesForUser($daUser)); + $roleNames = array_map(static fn(array $r): string => $r['name'], $ctx->mysql->listRolesForUser($daUser)); + + if (empty($dbNames)) { + $ctx->renderPage('Zarządzaj bazą danych', '

    Brak baz danych do zarządzania.

    ', $ok, $errors); + return; + } + + $selectedDb = $ctx->postString('db_name'); + if ($selectedDb === '') { + $selectedDb = $ctx->queryString('db'); + } + if ($selectedDb === '') { + $selectedDb = $dbNames[0]; + } + + try { + $selectedDb = NamePolicy::normalizeDatabaseName($selectedDb, $prefix); + } catch (Throwable $e) { + $selectedDb = $dbNames[0]; + } + if (!in_array($selectedDb, $dbNames, true)) { + $selectedDb = $dbNames[0]; + } + + $selectedRole = $ctx->postString('role_name'); + if ($selectedRole === '') { + $selectedRole = $ctx->queryString('role'); + } + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('modify_privileges_submit'); + + $selectedDb = NamePolicy::normalizeDatabaseName($ctx->postString('db_name', $selectedDb), $prefix); + if (!in_array($selectedDb, $dbNames, true)) { + throw new RuntimeException('Baza nie należy do konta DA: ' . $selectedDb); + } + + $action = $ctx->postString('form_action', 'save_privileges'); + if ($action === 'revoke_user') { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik MySQL: ' . $role); + } + $ctx->mysql->revokePrivileges($selectedDb, $role); + $selectedRole = $role; + $ok[] = 'Odebrano dostęp użytkownikowi ' . $role . ' do bazy ' . $selectedDb . '.'; + } elseif ($action === 'grant_user') { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik MySQL: ' . $role); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('grant_privileges')); + if (empty($privileges)) { + $privileges = NamePolicy::defaultPrivileges(); + } + $ctx->mysql->grantPrivileges($selectedDb, $role, $privileges); + $selectedRole = $role; + $ok[] = 'Przyznano dostęp użytkownikowi ' . $role . ' do bazy ' . $selectedDb . '.'; + } else { + $role = NamePolicy::normalizeRoleName($ctx->postString('role_name'), $prefix); + if (!in_array($role, $roleNames, true)) { + throw new RuntimeException('Nieprawidłowy użytkownik MySQL: ' . $role); + } + $privileges = NamePolicy::sanitizePrivileges($ctx->postArray('privileges')); + if (empty($privileges)) { + throw new RuntimeException('Wybierz przynajmniej jedno uprawnienie.'); + } + $ctx->mysql->grantPrivileges($selectedDb, $role, $privileges); + $selectedRole = $role; + $ok[] = 'Zaktualizowano uprawnienia użytkownika ' . $role . '.'; + } + } catch (Throwable $e) { + $errors[] = $e->getMessage(); + } + } + + $dbInfo = $ctx->mysql->getDatabaseInfo($selectedDb); + $dbStats = $ctx->mysql->getDatabaseObjectStats($selectedDb); + $dbUsers = $ctx->mysql->listDatabaseUsers($daUser, $selectedDb); + + if ($selectedRole !== '') { + try { + $selectedRole = NamePolicy::normalizeRoleName($selectedRole, $prefix); + } catch (Throwable $e) { + $selectedRole = ''; + } + } + if ($selectedRole === '' && !empty($dbUsers)) { + $selectedRole = $dbUsers[0]; + } + + $currentProfile = []; + if ($selectedRole !== '') { + $currentProfile = $ctx->mysql->getGrantProfile($selectedDb, $selectedRole); + if (empty($currentProfile) && in_array($selectedRole, $dbUsers, true)) { + $currentProfile = ['ALL']; + } + } + + $dbOptions = ''; + foreach ($dbNames as $dbName) { + $sel = ($dbName === $selectedDb) ? ' selected' : ''; + $dbOptions .= ''; + } + + $assignableRoles = array_values(array_diff($roleNames, $dbUsers)); + $assignOptions = ''; + foreach ($assignableRoles as $role) { + $assignOptions .= ''; + } + + $usersRows = ''; + foreach ($dbUsers as $role) { + $profile = $ctx->mysql->getGrantProfile($selectedDb, $role); + $label = (empty($profile) || in_array('ALL', $profile, true)) + ? 'Pełny dostęp' + : implode(', ', $profile); + + $usersRows .= ''; + $usersRows .= '' . AppContext::e($role) . ''; + $usersRows .= '' . AppContext::e($label) . ''; + $usersRows .= ''; + $usersRows .= 'Zarządzaj'; + $usersRows .= 'Przywileje'; + $usersRows .= '
    '; + $usersRows .= $ctx->csrfField('modify_privileges_submit'); + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= ''; + $usersRows .= '
    '; + $usersRows .= ''; + $usersRows .= ''; + } + if ($usersRows === '') { + $usersRows = 'Brak użytkowników z dostępem do bazy.'; + } + + $privCheckboxes = ''; + foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) { + $checked = in_array($code, $currentProfile, true) ? ' checked' : ''; + $privCheckboxes .= ''; + } + + $body = ''; + $body .= '
    '; + $body .= '

    Zarządzaj bazą danych

    '; + $body .= '
    '; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '

    Szczegółowe informacje o bazie danych.

    '; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    Nazwa bazy danych
    ' . AppContext::e($dbInfo['name']) . '
    Domyślne kodowanie znaków
    ' . AppContext::e($dbInfo['encoding']) . '
    Domyślne porównywanie znaków
    ' . AppContext::e($dbInfo['collation']) . '
    Rozmiar
    ' . AppContext::e($dbInfo['size']) . '
    Użytkownicy
    ' . AppContext::e((string)count($dbUsers)) . '
    Liczba Tabel
    ' . AppContext::e((string)$dbStats['tables']) . '
    Widoki
    ' . AppContext::e((string)$dbStats['views']) . '
    Wyzwalacze
    ' . AppContext::e((string)$dbStats['triggers']) . '
    Rutyny i procedury
    ' . AppContext::e((string)$dbStats['routines']) . '
    '; + $body .= '
    '; + + $body .= '
    '; + $body .= '

    Dostęp użytkownika

    '; + $body .= '

    Lista użytkowników, którzy mają dostęp do tej bazy danych wraz z podsumowaniem uprawnień.

    '; + $body .= '' . $usersRows . '
    Nazwa użytkownikaPrzywilejeAkcje
    '; + $body .= '
    '; + $body .= $ctx->csrfField('modify_privileges_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + + if ($selectedRole !== '') { + $body .= '
    '; + $body .= '

    Przywileje użytkownika: ' . AppContext::e($selectedRole) . '

    '; + $body .= '
    '; + $body .= $ctx->csrfField('modify_privileges_submit'); + $body .= ''; + $body .= ''; + $body .= ''; + $body .= '
    ' . $privCheckboxes . '
    '; + $body .= '
    '; + $body .= '
    '; + $body .= '
    '; + } + + $ctx->renderPage('Zarządzaj bazą danych', $body, $ok, $errors); +}; diff --git a/exec/handlers/open_phpmyadmin.php b/exec/handlers/open_phpmyadmin.php new file mode 100644 index 0000000..796e1be --- /dev/null +++ b/exec/handlers/open_phpmyadmin.php @@ -0,0 +1,135 @@ +&1', $lines, $code); + $output = implode("\n", $lines); + return $code; + }; + + $healthOutput = ''; + if ($runScript($healthScript, $healthOutput) === 0) { + return; + } + + $repairOutput = ''; + $repairCode = $runScript($repairScript, $repairOutput); + if ($repairCode !== 0) { + throw new RuntimeException( + "Nie udało się naprawić konfiguracji phpMyAdmin.\n" . trim($repairOutput) + ); + } + + $healthAfterRepair = ''; + if ($runScript($healthScript, $healthAfterRepair) !== 0) { + throw new RuntimeException( + "Konfiguracja phpMyAdmin nadal nie przechodzi health-check po repair.\n" . trim($healthAfterRepair) + ); + } + }; + + if (!Http::isPost()) { + $message = 'Nieprawidłowa metoda żądania.'; + if ($rawMode) { + echo "HTTP/1.1 405 Method Not Allowed\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(405); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } + + try { + $ensurePhpMyAdminReady(); + } catch (Throwable $e) { + $message = 'Nie można przygotować phpMyAdmin: ' . $e->getMessage(); + if ($rawMode) { + echo "HTTP/1.1 500 Internal Server Error\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(500); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } + + if (!$ctx->settings->enableAdminer()) { + $message = 'Integracja phpMyAdmin jest wyłączona na tym serwerze.'; + if ($rawMode) { + echo "HTTP/1.1 403 Forbidden\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(403); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } + + try { + $ctx->requireCsrf('open_phpmyadmin_submit'); + $requestedDb = trim($ctx->postString('adminer_db')); + if ($requestedDb === '') { + $requestedDb = null; + } + + $sso = new PhpMyAdminSso($ctx->settings, $ctx->daUser, $ctx->mysql); + $payload = $sso->issueLoginPayload($requestedDb); + $target = (string)($payload['target'] ?? ''); + if ($target === '') { + throw new RuntimeException('Nie udało się przygotować danych sesji do phpMyAdmin.'); + } + + if ($rawMode) { + echo "HTTP/1.1 302 Found\r\n"; + echo "Location: " . $target . "\r\n"; + echo "Cache-Control: no-store, no-cache, must-revalidate\r\n"; + echo "Pragma: no-cache\r\n\r\n"; + } else { + header('Location: ' . $target, true, 302); + } + exit; + } catch (Throwable $e) { + $message = 'Nie można otworzyć phpMyAdmin: ' . $e->getMessage(); + @error_log( + sprintf( + "[%s] PHPMYADMIN_SSO_FAIL user=%s remote=%s message=%s\n", + date('c'), + $ctx->daUser->username(), + Http::server('REMOTE_ADDR'), + $e->getMessage() + ), + 3, + PLUGIN_ROOT . '/error.log' + ); + + if ($rawMode) { + echo "HTTP/1.1 400 Bad Request\r\n"; + echo "Content-Type: text/plain; charset=UTF-8\r\n\r\n"; + echo $message; + } else { + http_response_code(400); + header('Content-Type: text/plain; charset=UTF-8'); + echo $message; + } + exit; + } +}; diff --git a/exec/handlers/upload_backup.php b/exec/handlers/upload_backup.php new file mode 100644 index 0000000..063d127 --- /dev/null +++ b/exec/handlers/upload_backup.php @@ -0,0 +1,1290 @@ + $ctx->t($key, $vars); + $et = static fn(string $key, array $vars = []): string => AppContext::e($ctx->t($key, $vars)); + + $daUser = $ctx->daUser->username(); + $prefix = $ctx->daUser->prefix(); + + $queue = new BackupQueueService($ctx->settings, $ctx->daUser); + try { + $queue->ensureInfrastructure(); + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + + if (!$ctx->settings->enableUploadBackup()) { + $ctx->renderPage( + 'Przywróć Backup', + '

    ' . $et('Obsługa przywracania backupów jest wyłączona (`ENABLE_UPLOAD_BACKUP=false`).') . '

    ', + $ok, + $errors + ); + return; + } + + $jsonResponse = static function (array $payload, int $statusCode = 200): void { + http_response_code($statusCode); + header('Content-Type: text/plain; charset=UTF-8'); + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($json === false) { + $json = '{"ok":false,"error":"JSON encode failed"}'; + } + $marker = '__DA_MYSQL_UPLOAD_JSON__'; + echo $marker . "\n" . $json . "\n" . $marker; + }; + + $requestAction = strtolower($ctx->queryString('action')); + if ($ctx->isPost() && $requestAction === 'upload_blob') { + $jsonResponse([ + 'ok' => false, + 'error' => $ctx->t('Tryb przesyłania został zaktualizowany. Odśwież stronę i spróbuj ponownie.'), + ], 409); + return; + } + + $activeTab = strtolower($ctx->postString('active_tab', $ctx->queryString('tab', 'local'))); + if (!in_array($activeTab, ['local', 'user', 'file'], true)) { + $activeTab = 'local'; + } + + $sourceFilePathValue = $ctx->postString('source_file_path'); + if ($sourceFilePathValue === '') { + $rawPath = $_POST['source_file_path'] ?? null; + if (is_array($rawPath) && isset($rawPath[0])) { + $sourceFilePathValue = trim((string)$rawPath[0]); + } + } + if ($sourceFilePathValue === '') { + $sourceFilePathValue = $ctx->queryString('source_file_path'); + } + $uploadedTmpPathValue = $ctx->postString('uploaded_tmp_path', $ctx->queryString('uploaded_tmp_path')); + $uploadedOriginalNameValue = $ctx->postString('uploaded_original_name', $ctx->queryString('uploaded_original_name')); + $sourceLocationMode = strtolower($ctx->postString('source_mode', $ctx->queryString('source_mode', 'local'))); + if (!in_array($sourceLocationMode, ['local', 'server'], true)) { + $sourceLocationMode = 'local'; + } + + $allowRestoreOther = $ctx->settings->allowRestoreToOtherDatabase(); + $restoreMode = strtolower($ctx->postString('restore_mode', $ctx->queryString('restore_mode', 'overwrite'))); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + if (!$allowRestoreOther) { + $restoreMode = 'overwrite'; + } + $restoreTargetDb = trim($ctx->postString('target_db', $ctx->queryString('target_db', ''))); + if ($restoreTargetDb !== '' && strpos($restoreTargetDb, $prefix) !== 0) { + $restoreTargetDb = $prefix . $restoreTargetDb; + } + + $availableDbs = []; + try { + $availableDbs = array_map(static fn(array $db): string => (string)$db['name'], $ctx->mysql->listDatabasesForUser($daUser, false)); + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + if ($restoreTargetDb !== '' && !in_array($restoreTargetDb, $availableDbs, true)) { + $restoreTargetDb = ''; + } + if ($restoreTargetDb === '' && !empty($availableDbs)) { + $restoreTargetDb = $availableDbs[0]; + } + + $renderTargetRadios = function (array $dbs, string $selected, string $name, array $exclude = [], bool $required = false, bool $disabled = false, string $idPrefix = '', string $formId = '') use ($et): string { + $exclude = array_fill_keys($exclude, true); + $filtered = []; + foreach ($dbs as $dbName) { + if (isset($exclude[$dbName])) { + continue; + } + $filtered[] = $dbName; + } + + if (empty($filtered)) { + return '

    ' . $et('Brak baz danych do wyboru.') . '

    '; + } + + if ($selected === '' || !in_array($selected, $filtered, true)) { + $selected = $filtered[0] ?? ''; + } + + $safePrefix = preg_replace('/[^A-Za-z0-9_-]+/', '_', $idPrefix) ?? ''; + $items = '
    '; + foreach ($filtered as $idx => $dbName) { + $id = 'restore-target-' . $safePrefix . '-' . $idx; + $checked = ($dbName === $selected) ? ' checked' : ''; + $req = ($required && $idx === 0) ? ' required' : ''; + $dis = $disabled ? ' disabled' : ''; + $formAttr = $formId !== '' ? ' form="' . AppContext::e($formId) . '"' : ''; + $items .= ''; + } + $items .= '
    '; + return $items; + }; + + $ensureDbExists = static function (AppContext $ctx, string $dbName): void { + try { + $ctx->mysql->getDatabaseInfo($dbName); + } catch (Throwable $e) { + throw new RuntimeException('Wskazana docelowa baza danych nie istnieje. Utwórz ją ręcznie w DirectAdmin.'); + } + }; + + $formatBackupLabel = static function (array $entry): string { + $date = (string)($entry['date'] ?? ''); + $time = (string)($entry['time'] ?? ''); + $raw = trim($date . ' ' . $time); + if ($raw === '') { + return '-'; + } + $ts = strtotime($raw); + if ($ts === false) { + return $raw; + } + return date('d-m-Y H:i:s', $ts); + }; + + $displayFileName = static function (string $value): string { + $name = basename(trim($value)); + if ($name === '') { + return ''; + } + if (preg_match('/^stream-[^_]+_(.+)$/', $name, $m) === 1) { + $name = $m[1]; + } + if (preg_match('/^[A-Za-z]+-[0-9]{8,}-[0-9a-f]{6,}_(.+)$/', $name, $m) === 1) { + $name = $m[1]; + } + return $name; + }; + + $renderHiddenInputs = static function (array $fields): string { + $out = ''; + foreach ($fields as $name => $value) { + if (!is_string($name)) { + continue; + } + if (is_array($value)) { + foreach ($value as $item) { + if (is_array($item) || is_object($item)) { + continue; + } + $out .= ''; + } + continue; + } + if (is_object($value)) { + continue; + } + $out .= ''; + } + return $out; + }; + + $renderConfirmOverlay = static function (string $bodyHtml, array $fields) use ($ctx, $et, $renderHiddenInputs): string { + $hidden = $renderHiddenInputs($fields); + $html = ''; + $html .= '
    '; + $html .= '
    '; + $html .= '

    ' . $et('Potwierdzenie operacji') . '

    '; + $html .= $bodyHtml; + $html .= '
    '; + $html .= $ctx->csrfField('upload_backup_submit'); + $html .= $hidden; + $html .= ''; + $html .= '
    '; + $html .= '' . $et('Nie') . ''; + $html .= ''; + $html .= '
    '; + $html .= '
    '; + $html .= '
    '; + return $html; + }; + + $confirmOverlay = ''; + + $findUserBackupEntry = static function (BackupQueueService $queue, string $relPath): ?array { + $relPath = ltrim(trim($relPath), '/'); + if ($relPath === '') { + return null; + } + $all = $queue->listUserBackupsByDate(800); + foreach ($all as $entries) { + foreach ($entries as $entry) { + if (!is_array($entry)) { + continue; + } + if (($entry['rel_path'] ?? '') === $relPath) { + return $entry; + } + } + } + return null; + }; + + $streamUserBackup = static function (string $path): void { + $downloadName = basename($path); + $downloadName = preg_replace('/[^A-Za-z0-9._-]+/', '_', $downloadName) ?? 'backup.sql'; + $contentType = 'application/octet-stream'; + if (preg_match('/\.sql$/i', $downloadName)) { + $contentType = 'application/sql'; + } elseif (preg_match('/\.gz$/i', $downloadName)) { + $contentType = 'application/gzip'; + } + + clearstatcache(true, $path); + $contentLength = @filesize($path); + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', '0'); + @ini_set('output_buffering', '0'); + + header_remove('Content-Encoding'); + header('Content-Type: ' . $contentType); + header('Content-Disposition: attachment; filename="' . str_replace('"', '', $downloadName) . '"'); + header('X-Content-Type-Options: nosniff'); + header('Cache-Control: no-store, no-cache, must-revalidate'); + header('Pragma: no-cache'); + if (is_int($contentLength) && $contentLength > 0) { + header('Content-Length: ' . (string)$contentLength); + } + + @readfile($path); + exit; + }; + + if ($ctx->isPost()) { + try { + $ctx->requireCsrf('upload_backup_submit'); + $action = $ctx->postString('form_action'); + $restoreMode = strtolower($ctx->postString('restore_mode', $restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + if (!$allowRestoreOther) { + $restoreMode = 'overwrite'; + } + + if ($action === 'restore_local') { + $backupId = $ctx->postString('local_backup'); + $entry = $queue->getLocalBackupEntry($backupId); + $sourceDb = (string)($entry['db_name'] ?? ''); + $backupLabel = $formatBackupLabel($entry); + $confirmAction = $ctx->postString('confirm_action') === '1'; + + $targetDb = $sourceDb; + if ($restoreMode === 'new_db') { + $targetDb = NamePolicy::normalizeDatabaseName($ctx->postString('target_db'), $prefix); + if ($sourceDb !== '' && $targetDb === $sourceDb) { + throw new RuntimeException('Docelowa baza danych musi być inna niż źródłowa baza z backupu.'); + } + $ensureDbExists($ctx, $targetDb); + } + + if (!$confirmAction) { + $sourceHtml = '' . AppContext::e($sourceDb !== '' ? $sourceDb : $targetDb) . ''; + $targetHtml = '' . AppContext::e($targetDb !== '' ? $targetDb : $sourceDb) . ''; + $targetHtmlDanger = '' . AppContext::e($targetDb !== '' ? $targetDb : $sourceDb) . ''; + $dateHtml = '' . AppContext::e($backupLabel) . ''; + $overwriteHtml = '' . $et('nadpisując obecne dane') . ''; + + if ($restoreMode === 'new_db') { + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z dn. {date} do innej bazy danych o nazwie {target}?', + ['source' => $sourceHtml, 'date' => $dateHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.', + ['target' => $targetHtmlDanger, 'source' => $sourceHtml] + ); + $confirmBody = '

    ' . $line1 . '

    ' . $line2 . '

    '; + } else { + $confirmBody = '

    ' . $ctx->t( + 'Czy na pewno chcesz przywrócić kopię zapasową bazy danych {source} z dn. {date} {overwrite} znajdujące się w bazie danych {target}?', + ['source' => $sourceHtml, 'date' => $dateHtml, 'overwrite' => $overwriteHtml, 'target' => $targetHtml] + ) . '

    '; + } + + $confirmOverlay = $renderConfirmOverlay($confirmBody, [ + 'active_tab' => 'local', + 'form_action' => 'restore_local', + 'local_backup' => $backupId, + 'restore_mode' => $restoreMode, + 'target_db' => $targetDb, + ]); + $activeTab = 'local'; + } else { + if ($restoreMode === 'new_db') { + $jobId = $queue->queueRestoreFromLocal($backupId, $restoreMode, $targetDb); + } else { + $jobId = $queue->queueRestoreFromLocal($backupId, $restoreMode, ''); + } + $ok[] = $tr('Dodano zadanie przywracania backupu lokalnego (ID: {id}).', ['id' => $jobId]); + $activeTab = 'local'; + } + } elseif ($action === 'restore_file') { + $targetDb = NamePolicy::normalizeDatabaseName($ctx->postString('target_db'), $prefix); + $ensureDbExists($ctx, $targetDb); + $confirmAction = $ctx->postString('confirm_action') === '1'; + $sourceMode = strtolower($ctx->postString('source_mode', 'local')); + if (!in_array($sourceMode, ['local', 'server'], true)) { + $sourceMode = 'local'; + } + $stagedPath = trim($ctx->postString('uploaded_tmp_path')); + $uploadedOriginalName = trim($ctx->postString('uploaded_original_name')); + $localUploadField = trim($ctx->postString('source_file_upload')); + if ($localUploadField === '') { + $rawUploadField = $_POST['source_file_upload'] ?? null; + if (is_array($rawUploadField) && isset($rawUploadField[0])) { + $localUploadField = trim((string)$rawUploadField[0]); + } elseif (!is_array($rawUploadField) && $rawUploadField !== null) { + $localUploadField = trim((string)$rawUploadField); + } + } + $serverPath = trim($sourceFilePathValue); + if ($sourceMode === 'server' && $serverPath === '') { + throw new RuntimeException('Wskaż ścieżkę do pliku SQL lub SQL.GZ.'); + } + if ($sourceMode === 'local' && $stagedPath === '') { + $uploadFile = $_FILES['source_file_upload'] ?? null; + if (is_array($uploadFile) && !empty($uploadFile) && (int)($uploadFile['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_NO_FILE) { + $stagedPath = $queue->stageRestoreUploadFromPhpUpload($uploadFile, $uploadedOriginalName); + if ($uploadedOriginalName === '') { + $uploadedOriginalName = basename((string)($uploadFile['name'] ?? '')); + } + } elseif ($localUploadField !== '') { + $stagedPath = $queue->stageRestoreUploadFromDirectAdminTemp($localUploadField, $uploadedOriginalName); + } else { + throw new RuntimeException('Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.'); + } + } + if ($sourceMode === 'local' && $stagedPath === '') { + throw new RuntimeException('Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.'); + } + + $fileLabel = ''; + if ($sourceMode === 'local') { + if ($uploadedOriginalName !== '') { + $fileLabel = $displayFileName($uploadedOriginalName); + } + if ($fileLabel === '' && $stagedPath !== '') { + $fileLabel = $displayFileName($stagedPath); + } + } else { + $fileLabel = $displayFileName($serverPath); + } + if ($fileLabel === '') { + $fileLabel = 'backup.sql.gz'; + } + // Bezpieczeństwo: dla restore z pliku nie wykrywamy i nie ufamy nazwie bazy z nazwy pliku. + // Import zawsze ma trafić do bazy docelowej wskazanej przez użytkownika. + $sourceDbName = ''; + $postedRestoreMode = strtolower(trim($ctx->postString('restore_mode'))); + $restoreModeFile = in_array($postedRestoreMode, ['overwrite', 'new_db'], true) ? $postedRestoreMode : 'overwrite'; + if (!$allowRestoreOther) { + $restoreModeFile = 'overwrite'; + } + + @error_log(sprintf( + "[%s] RESTORE_FILE action=%s user=%s source_mode=%s has_files=%s local_field=%s source_path=%s uploaded_tmp=%s uploaded_name=%s source_db=%s target_db=%s restore_mode=%s confirm=%s\n", + date('c'), + $ctx->action(), + $daUser, + $sourceMode, + empty($_FILES) ? '0' : '1', + $localUploadField, + $serverPath, + $stagedPath, + $uploadedOriginalName, + $sourceDbName, + $targetDb, + $restoreModeFile, + $confirmAction ? '1' : '0' + ), 3, PLUGIN_ROOT . '/error.log'); + + if (!$confirmAction) { + $fileHtml = '' . AppContext::e($fileLabel) . ''; + $targetHtmlDanger = '' . AppContext::e($targetDb) . ''; + if ($restoreModeFile === 'new_db' && $sourceDbName !== '') { + $sourceHtml = '' . AppContext::e($sourceDbName) . ''; + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z pliku {file} do innej bazy danych o nazwie {target}?', + ['source' => $sourceHtml, 'file' => $fileHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.', + ['target' => $targetHtmlDanger, 'source' => $sourceHtml] + ); + } else { + $line1 = $ctx->t( + 'Czy na pewno chcesz przywrócić plik kopii zapasowej {file} do bazy danych {target}?', + ['file' => $fileHtml, 'target' => $targetHtmlDanger] + ); + $line2 = $ctx->t( + 'Uwaga: jeśli baza danych {target} zawiera dane, zostaną one nadpisane.', + ['target' => $targetHtmlDanger] + ); + } + $confirmOverlay = $renderConfirmOverlay('

    ' . $line1 . '

    ' . $line2 . '

    ', [ + 'active_tab' => 'file', + 'form_action' => 'restore_file', + 'source_mode' => $sourceMode, + 'source_file_path' => $serverPath, + 'uploaded_tmp_path' => $stagedPath, + 'uploaded_original_name' => ($uploadedOriginalName !== '' ? $uploadedOriginalName : $fileLabel), + 'source_db_name' => $sourceDbName, + 'restore_mode' => $restoreModeFile, + 'target_db' => $targetDb, + ]); + $activeTab = 'file'; + } else { + if ($sourceMode === 'local') { + $jobId = $queue->queueRestoreFromFilePath($targetDb, $stagedPath, $restoreModeFile, $sourceDbName); + } else { + $filePath = $serverPath; + $jobId = $queue->queueRestoreFromFilePath($targetDb, $filePath, $restoreModeFile, $sourceDbName); + } + $ok[] = $tr('Zadanie przywrócenia kopii zapasowej {file} do bazy danych {db} zostało dodane do kolejki', [ + 'file' => $fileLabel, + 'db' => $targetDb, + ]); + $activeTab = 'file'; + } + } elseif ($action === 'download_user_backup') { + $jobId = trim($ctx->postString('backup_job_id')); + $backupPath = trim($ctx->postString('backup_path')); + $path = ''; + if ($jobId !== '') { + $path = $queue->resolveBackupTargetFileForCurrentUser($jobId); + } + if ($path === '' && $backupPath !== '') { + $path = $queue->resolveUserBackupFileForCurrentUser($backupPath); + } + if ($path === '') { + throw new RuntimeException('Nie można odnaleźć pliku backupu.'); + } + $streamUserBackup($path); + } elseif ($action === 'delete_user_backup') { + $jobId = trim($ctx->postString('backup_job_id')); + $backupPath = trim($ctx->postString('backup_path')); + if ($jobId === '' && $backupPath === '') { + throw new RuntimeException('Brak identyfikatora zadania backupu.'); + } + $confirmAction = $ctx->postString('confirm_action') === '1'; + + if (!$confirmAction) { + $dbLabel = 'nieznana baza'; + $dateLabel = '-'; + if ($jobId !== '') { + $job = $queue->getJobForCurrentUser($jobId); + if (($job['type'] ?? '') !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + $dbLabel = (string)($job['db_name'] ?? '') !== '' ? (string)$job['db_name'] : $dbLabel; + $startTs = (int)($job['start_ts'] ?? 0); + $dateLabel = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : '-'; + } elseif ($backupPath !== '') { + $entry = $findUserBackupEntry($queue, $backupPath); + if ($entry !== null) { + $dbLabel = (string)($entry['db_name'] ?? '') !== '' ? (string)$entry['db_name'] : $dbLabel; + $ts = (int)($entry['mtime'] ?? 0); + $dateLabel = $ts > 0 ? date('Y-m-d H:i:s', $ts) : $dateLabel; + } + } + $confirmBody = '

    ' . $ctx->t( + 'Czy na pewno chcesz usunąć backup bazy danych {db} z dn. {date}?', + ['db' => '' . AppContext::e($dbLabel) . '', 'date' => '' . AppContext::e($dateLabel) . ''] + ) . '

    '; + $confirmOverlay = $renderConfirmOverlay($confirmBody, [ + 'active_tab' => 'user', + 'form_action' => 'delete_user_backup', + 'backup_job_id' => $jobId, + 'backup_path' => $backupPath, + 'user_month' => $ctx->postString('user_month', $ctx->queryString('user_month')), + 'user_date' => $ctx->postString('user_date', $ctx->queryString('user_date')), + 'user_slot' => $ctx->postString('user_slot', $ctx->queryString('user_slot')), + ]); + $activeTab = 'user'; + } else { + if ($jobId !== '') { + $deletedPath = $queue->deleteBackupTargetFileForCurrentUser($jobId); + try { + $queue->deleteBackupLogForCurrentUser($jobId); + } catch (Throwable $logErr) { + // ignore log cleanup failure + } + } else { + $deletedPath = $queue->deleteUserBackupFileForCurrentUser($backupPath); + } + $ok[] = $tr('Usunięto plik backupu: {path}', ['path' => $deletedPath]); + $activeTab = 'user'; + } + } + } catch (Throwable $e) { + $errors[] = $ctx->formatException($e); + } + } + + $backupsByDate = $queue->listLocalBackupsByDate(); + $availableDates = array_keys($backupsByDate); + $availableMonths = []; + foreach ($availableDates as $dateKey) { + if (strlen($dateKey) >= 7) { + $availableMonths[substr($dateKey, 0, 7)] = true; + } + } + $availableMonths = array_keys($availableMonths); + rsort($availableMonths, SORT_STRING); + + $selectedMonth = trim($ctx->postString('backup_month', $ctx->queryString('month', ''))); + if ($selectedMonth === '' || (!empty($availableMonths) && !in_array($selectedMonth, $availableMonths, true))) { + $selectedMonth = $availableMonths[0] ?? date('Y-m'); + } + + $selectedDate = trim($ctx->postString('backup_date', $ctx->queryString('backup_date'))); + $today = date('Y-m-d'); + $monthRequested = ($ctx->postString('backup_month', '') !== '' || $ctx->queryString('month', '') !== ''); + $dateRequested = ($ctx->postString('backup_date', '') !== '' || $ctx->queryString('backup_date', '') !== ''); + if (!$monthRequested && !$dateRequested && isset($backupsByDate[$today])) { + $selectedMonth = substr($today, 0, 7); + $selectedDate = $today; + } + + if ($selectedDate === '' || !isset($backupsByDate[$selectedDate]) || substr($selectedDate, 0, 7) !== $selectedMonth) { + $selectedDate = ''; + foreach ($availableDates as $dayKey) { + if (substr($dayKey, 0, 7) === $selectedMonth) { + $selectedDate = $dayKey; + break; + } + } + } + + $selectedEntries = ($selectedDate !== '' && isset($backupsByDate[$selectedDate])) ? $backupsByDate[$selectedDate] : []; + $selectedSlot = trim($ctx->postString('backup_slot', $ctx->queryString('backup_slot', ''))); + $timeSlots = []; + foreach ($selectedEntries as $entry) { + $timeLabel = trim((string)($entry['time'] ?? '')); + $mtime = (int)($entry['mtime'] ?? 0); + if ($timeLabel === '' && $mtime > 0) { + $timeLabel = date('H:i:s', $mtime); + } + if ($timeLabel === '') { + $timeLabel = '00:00:00'; + } + if (!isset($timeSlots[$timeLabel])) { + $timeSlots[$timeLabel] = []; + } + $timeSlots[$timeLabel][] = $entry; + } + $hasMultipleSlots = count($timeSlots) > 1; + if ($hasMultipleSlots) { + if ($selectedSlot === '' || !isset($timeSlots[$selectedSlot])) { + $slotKeys = array_keys($timeSlots); + $selectedSlot = $slotKeys[0] ?? ''; + } + } else { + $selectedSlot = ''; + } + $entriesToShow = $hasMultipleSlots && $selectedSlot !== '' ? ($timeSlots[$selectedSlot] ?? []) : $selectedEntries; + + $formatMonthLabel = static function (string $monthValue, string $langCode): string { + if (!preg_match('/^(\\d{4})-(\\d{2})$/', $monthValue, $m)) { + return $monthValue; + } + $year = $m[1]; + $month = (int)$m[2]; + $monthsPl = [ + 1 => 'styczeń', 2 => 'luty', 3 => 'marzec', 4 => 'kwiecień', + 5 => 'maj', 6 => 'czerwiec', 7 => 'lipiec', 8 => 'sierpień', + 9 => 'wrzesień', 10 => 'październik', 11 => 'listopad', 12 => 'grudzień', + ]; + $monthsEn = [ + 1 => 'January', 2 => 'February', 3 => 'March', 4 => 'April', + 5 => 'May', 6 => 'June', 7 => 'July', 8 => 'August', + 9 => 'September', 10 => 'October', 11 => 'November', 12 => 'December', + ]; + $list = ($langCode === 'pl') ? $monthsPl : $monthsEn; + $name = $list[$month] ?? $monthValue; + return $name . ' ' . $year; + }; + + $formatFullDate = static function (string $dateValue, string $langCode): string { + if (!preg_match('/^(\\d{4})-(\\d{2})-(\\d{2})$/', $dateValue, $m)) { + return $dateValue; + } + $year = (int)$m[1]; + $month = (int)$m[2]; + $day = (int)$m[3]; + $monthsPl = [ + 1 => 'stycznia', 2 => 'lutego', 3 => 'marca', 4 => 'kwietnia', + 5 => 'maja', 6 => 'czerwca', 7 => 'lipca', 8 => 'sierpnia', + 9 => 'września', 10 => 'października', 11 => 'listopada', 12 => 'grudnia', + ]; + if ($langCode === 'pl') { + $monthName = $monthsPl[$month] ?? $m[2]; + return $day . ' ' . $monthName . ' ' . $year; + } + $dt = DateTime::createFromFormat('Y-m-d', $dateValue); + return $dt instanceof DateTime ? $dt->format('F j, Y') : $dateValue; + }; + + $formatTimeLabel = static function (string $timeValue): string { + if (preg_match('/^(\\d{2}:\\d{2})/', $timeValue, $m)) { + return $m[1]; + } + return $timeValue; + }; + + $monthNavTargets = static function (array $availableMonths, string $selectedMonth): array { + $prev = ''; + $next = ''; + $idx = array_search($selectedMonth, $availableMonths, true); + if ($idx !== false) { + if ($idx < count($availableMonths) - 1) { + $prev = $availableMonths[$idx + 1]; + } + if ($idx > 0) { + $next = $availableMonths[$idx - 1]; + } + } + return ['prev' => $prev, 'next' => $next]; + }; + + $calendarWeekdays = static function (string $langCode): array { + if ($langCode === 'pl') { + return ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd']; + } + return ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + }; + + $langCode = $ctx->daUser->language(); + $selectedDateLabel = $selectedDate !== '' ? $formatFullDate($selectedDate, $langCode) : ''; + + $userBackupsByDate = $queue->listUserBackupsByDate(500); + $userAvailableDates = array_keys($userBackupsByDate); + $userAvailableMonths = []; + foreach ($userAvailableDates as $dateKey) { + if (strlen($dateKey) >= 7) { + $userAvailableMonths[substr($dateKey, 0, 7)] = true; + } + } + $userAvailableMonths = array_keys($userAvailableMonths); + rsort($userAvailableMonths, SORT_STRING); + + $userSelectedMonth = trim($ctx->postString('user_month', $ctx->queryString('user_month', ''))); + if ($userSelectedMonth === '' || (!empty($userAvailableMonths) && !in_array($userSelectedMonth, $userAvailableMonths, true))) { + $userSelectedMonth = $userAvailableMonths[0] ?? date('Y-m'); + } + + $userSelectedDate = trim($ctx->postString('user_date', $ctx->queryString('user_date'))); + $userMonthRequested = ($ctx->postString('user_month', '') !== '' || $ctx->queryString('user_month', '') !== ''); + $userDateRequested = ($ctx->postString('user_date', '') !== '' || $ctx->queryString('user_date', '') !== ''); + if (!$userMonthRequested && !$userDateRequested && isset($userBackupsByDate[$today])) { + $userSelectedMonth = substr($today, 0, 7); + $userSelectedDate = $today; + } + + if ($userSelectedDate === '' || !isset($userBackupsByDate[$userSelectedDate]) || substr($userSelectedDate, 0, 7) !== $userSelectedMonth) { + $userSelectedDate = ''; + foreach ($userAvailableDates as $dayKey) { + if (substr($dayKey, 0, 7) === $userSelectedMonth) { + $userSelectedDate = $dayKey; + break; + } + } + } + + $userSelectedEntries = ($userSelectedDate !== '' && isset($userBackupsByDate[$userSelectedDate])) ? $userBackupsByDate[$userSelectedDate] : []; + $userSelectedSlot = trim($ctx->postString('user_slot', $ctx->queryString('user_slot', ''))); + $userTimeSlots = []; + foreach ($userSelectedEntries as $entry) { + $timeLabel = trim((string)($entry['time'] ?? '')); + $mtime = (int)($entry['mtime'] ?? 0); + if ($timeLabel === '' && $mtime > 0) { + $timeLabel = date('H:i:s', $mtime); + } + if ($timeLabel === '') { + $timeLabel = '00:00:00'; + } + if (!isset($userTimeSlots[$timeLabel])) { + $userTimeSlots[$timeLabel] = []; + } + $userTimeSlots[$timeLabel][] = $entry; + } + $userHasMultipleSlots = count($userTimeSlots) > 1; + if ($userHasMultipleSlots) { + if ($userSelectedSlot === '' || !isset($userTimeSlots[$userSelectedSlot])) { + $slotKeys = array_keys($userTimeSlots); + $userSelectedSlot = $slotKeys[0] ?? ''; + } + } else { + $userSelectedSlot = ''; + } + $userEntriesToShow = $userHasMultipleSlots && $userSelectedSlot !== '' ? ($userTimeSlots[$userSelectedSlot] ?? []) : $userSelectedEntries; + $userSelectedDateLabel = $userSelectedDate !== '' ? $formatFullDate($userSelectedDate, $langCode) : ''; + + $jobs = $queue->listJobsForCurrentUser(500); + + $tabs = ''; + $tabs .= ''; + + $restoreModeSection = ''; + if ($activeTab === 'local' && $allowRestoreOther) { + $restoreModeSection .= '
    '; + $restoreModeSection .= '

    ' . $et('Tryb przywracania') . '

    '; + $restoreModeSection .= '
    '; + $restoreModeSection .= ''; + $restoreModeSection .= ''; + $restoreModeSection .= '
    '; + $restoreModeSection .= '
    '; + } + + $renderRestorePicker = function () use ( + $ctx, + $selectedEntries, + $entriesToShow, + $hasMultipleSlots, + $timeSlots, + $selectedSlot, + $selectedDateLabel, + $selectedDate, + $selectedMonth, + $restoreMode, + $allowRestoreOther, + $availableDbs, + $restoreTargetDb, + $renderTargetRadios, + $formatTimeLabel, + $et + ): string { + $html = ''; + if (empty($selectedEntries)) { + $html .= '

    ' . $et('Brak backupów HITME.PL dla wybranej daty.') . '

    '; + return $html; + } + + if ($selectedDateLabel !== '') { + $html .= '
    ' . $et('Data:') . ' ' . AppContext::e($selectedDateLabel) . '
    '; + } + + if ($hasMultipleSlots) { + $html .= '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
    ' . $et('Wybierz godzinę backupu') . '
    '; + $html .= '
    '; + foreach ($timeSlots as $slotKey => $_entries) { + $timeLabel = $formatTimeLabel($slotKey); + $checked = ($slotKey === $selectedSlot) ? ' checked' : ''; + $html .= ''; + } + $html .= '
    '; + } + + $showTargetColumn = $allowRestoreOther; + $rows = ''; + foreach ($entriesToShow as $entry) { + $sourceDb = (string)($entry['source_db'] ?? ''); + $formId = 'restore-local-' . preg_replace('/[^A-Za-z0-9_-]+/', '_', (string)$entry['id']); + $timeLabel = $formatTimeLabel(trim((string)($entry['time'] ?? ''))); + $dateLabel = $selectedDateLabel !== '' ? $selectedDateLabel : (string)$entry['date']; + if ($hasMultipleSlots && $timeLabel !== '') { + $dateLabel .= ' ' . $timeLabel; + } + + $rows .= ''; + $rows .= '' . AppContext::e(trim($dateLabel)) . ''; + $rows .= '' . AppContext::e($sourceDb !== '' ? $sourceDb : '-') . ''; + if ($showTargetColumn) { + $rows .= ''; + if ($sourceDb !== '') { + $rows .= '
    '; + $rows .= $renderTargetRadios($availableDbs, $restoreTargetDb, 'target_db', [$sourceDb], $restoreMode === 'new_db', $restoreMode !== 'new_db', $entry['id'], $formId); + $rows .= '
    '; + } else { + $rows .= '' . $et('Nie można ustalić bazy z nazwy pliku.') . ''; + } + $rows .= ''; + } + $rows .= '' . AppContext::e($entry['file']) . ''; + $rows .= '' . AppContext::e($entry['size']) . ''; + $rows .= ''; + $rows .= '
    '; + $rows .= $ctx->csrfField('upload_backup_submit'); + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= ''; + if ($sourceDb !== '') { + $rows .= ''; + } else { + $rows .= ''; + } + $rows .= '
    '; + $rows .= ''; + $rows .= ''; + } + + $html .= ''; + $header = ''; + if ($showTargetColumn) { + $header .= ''; + } + $header .= ''; + $html .= '' . $header . ''; + $html .= '' . $rows . '
    ' . $et('Data i godzina') . '' . $et('Baza źródłowa') . '' . $et('Baza docelowa') . '' . $et('Plik') . '' . $et('Rozmiar') . '' . $et('Akcja') . '
    '; + return $html; + }; + + $renderUserBackupPicker = function () use ( + $ctx, + $userSelectedEntries, + $userEntriesToShow, + $userHasMultipleSlots, + $userTimeSlots, + $userSelectedSlot, + $userSelectedDateLabel, + $userSelectedDate, + $userSelectedMonth, + $formatTimeLabel, + $et + ): string { + $html = ''; + if (empty($userSelectedEntries)) { + $html .= '

    ' . $et('Brak backupów użytkownika dla wybranej daty.') . '

    '; + return $html; + } + + if ($userSelectedDateLabel !== '') { + $html .= '
    ' . $et('Data:') . ' ' . AppContext::e($userSelectedDateLabel) . '
    '; + } + + if ($userHasMultipleSlots) { + $html .= '
    '; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= '
    ' . $et('Wybierz godzinę backupu') . '
    '; + $html .= '
    '; + foreach ($userTimeSlots as $slotKey => $_entries) { + $timeLabel = $formatTimeLabel($slotKey); + $checked = ($slotKey === $userSelectedSlot) ? ' checked' : ''; + $html .= ''; + } + $html .= '
    '; + } + + $rows = ''; + foreach ($userEntriesToShow as $entry) { + $jobId = (string)($entry['job_id'] ?? ''); + $dbName = (string)($entry['db_name'] ?? ''); + $hasFile = (bool)($entry['has_file'] ?? false); + $relPath = (string)($entry['rel_path'] ?? ''); + $overlayId = 'log-overlay-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', $jobId); + + $rows .= ''; + $rows .= '' . AppContext::e($dbName !== '' ? $dbName : '-') . ''; + $rows .= ''; + + if ($hasFile) { + $rows .= '
    '; + if ($jobId !== '') { + $rows .= ''; + } elseif ($relPath !== '') { + $rows .= ''; + } + $rows .= ''; + $rows .= '
    '; + } + + if ($jobId !== '') { + $rows .= ''; + } + + if ($hasFile) { + $rows .= '
    '; + $rows .= $ctx->csrfField('upload_backup_submit'); + $rows .= ''; + $rows .= ''; + if ($jobId !== '') { + $rows .= ''; + } + if ($relPath !== '') { + $rows .= ''; + } + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= ''; + $rows .= '
    '; + } + + $rows .= ''; + $rows .= ''; + } + + $html .= ''; + $html .= ''; + $html .= '' . $rows . '
    ' . $et('Nazwa bazy danych') . '' . $et('Akcje') . '
    '; + return $html; + }; + + if ($ctx->queryString('action') === 'local_picker') { + header('Content-Type: text/html; charset=UTF-8'); + echo $renderRestorePicker(); + return; + } + + if ($ctx->queryString('action') === 'user_picker') { + header('Content-Type: text/html; charset=UTF-8'); + echo $renderUserBackupPicker(); + return; + } + + $localSection = ''; + if ($activeTab === 'local') { + $localSection .= '
    '; + $localSection .= '

    ' . $et('Backupy HITME.PL') . '

    '; + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '

    ' . $et('Kalendarz backupów') . '

    '; + + $nav = $monthNavTargets($availableMonths, $selectedMonth); + $langCode = $ctx->daUser->language(); + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= '
    '; + $localSection .= '
    ' . AppContext::e($formatMonthLabel($selectedMonth, $langCode)) . '
    '; + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= '
    '; + $localSection .= '
    '; + + $monthTs = strtotime($selectedMonth . '-01'); + if ($monthTs === false) { + $monthTs = strtotime(date('Y-m-01')); + } + $calendarDays = (int)date('t', $monthTs); + $calendarOffset = (int)date('N', $monthTs); + $weekdays = $calendarWeekdays($langCode); + + $localSection .= '
    '; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + $localSection .= ''; + foreach ($weekdays as $dayLabel) { + $localSection .= ''; + } + $localSection .= ''; + $dayNum = 1; + for ($row = 0; $row < 6; $row++) { + $localSection .= ''; + for ($col = 1; $col <= 7; $col++) { + if (($row === 0 && $col < $calendarOffset) || $dayNum > $calendarDays) { + $localSection .= ''; + continue; + } + $dayKey = date('Y-m-', $monthTs) . str_pad((string)$dayNum, 2, '0', STR_PAD_LEFT); + $hasBackup = isset($backupsByDate[$dayKey]); + $isSelected = ($dayKey === $selectedDate); + $localSection .= ''; + $dayNum++; + } + $localSection .= ''; + if ($dayNum > $calendarDays) { + break; + } + } + $localSection .= '
    ' . AppContext::e($dayLabel) . '
    '; + if ($hasBackup) { + $localSection .= ''; + } else { + $localSection .= '' . AppContext::e((string)$dayNum) . ''; + } + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '
    '; + + $localSection .= '
    '; + $localSection .= $renderRestorePicker(); + $localSection .= '
    '; + $localSection .= '
    '; + $localSection .= '
    '; + } + + $userSection = ''; + if ($activeTab === 'user') { + $userSection .= '
    '; + $userSection .= '

    ' . $et('Backupy użytkownika') . '

    '; + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= '

    ' . $et('Kalendarz backupów') . '

    '; + + $userNav = $monthNavTargets($userAvailableMonths, $userSelectedMonth); + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= '
    '; + $userSection .= '
    ' . AppContext::e($formatMonthLabel($userSelectedMonth, $langCode)) . '
    '; + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= '
    '; + $userSection .= '
    '; + + $userMonthTs = strtotime($userSelectedMonth . '-01'); + if ($userMonthTs === false) { + $userMonthTs = strtotime(date('Y-m-01')); + } + $userCalendarDays = (int)date('t', $userMonthTs); + $userCalendarOffset = (int)date('N', $userMonthTs); + $weekdays = $calendarWeekdays($langCode); + + $userSection .= '
    '; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + $userSection .= ''; + foreach ($weekdays as $dayLabel) { + $userSection .= ''; + } + $userSection .= ''; + $dayNum = 1; + for ($row = 0; $row < 6; $row++) { + $userSection .= ''; + for ($col = 1; $col <= 7; $col++) { + if (($row === 0 && $col < $userCalendarOffset) || $dayNum > $userCalendarDays) { + $userSection .= ''; + continue; + } + $dayKey = date('Y-m-', $userMonthTs) . str_pad((string)$dayNum, 2, '0', STR_PAD_LEFT); + $hasBackup = isset($userBackupsByDate[$dayKey]); + $isSelected = ($dayKey === $userSelectedDate); + $userSection .= ''; + $dayNum++; + } + $userSection .= ''; + if ($dayNum > $userCalendarDays) { + break; + } + } + $userSection .= '
    ' . AppContext::e($dayLabel) . '
    '; + if ($hasBackup) { + $userSection .= ''; + } else { + $userSection .= '' . AppContext::e((string)$dayNum) . ''; + } + $userSection .= '
    '; + $userSection .= '
    '; + $userSection .= '
    '; + + $userSection .= '
    '; + $userSection .= $renderUserBackupPicker(); + $userSection .= '
    '; + + $userSection .= '
    '; + $userSection .= '
    '; + } + + $fileSection = ''; + if ($activeTab === 'file') { + $fileSection .= '
    '; + $fileSection .= '

    ' . $et('Backupy z pliku') . '

    '; + $fileSection .= '

    ' . AppContext::e($tr('Wskaż plik `.sql`, `.gz` lub `.sql.gz` z komputera albo podaj ścieżkę pliku na serwerze (w katalogu `/home/{user}`).', ['user' => $daUser])) . '

    '; + $fileSection .= '
    '; + $fileSection .= $ctx->csrfField('upload_backup_submit'); + $fileSection .= ''; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= ''; + $fileSection .= ''; + + $fileSection .= ''; + $fileSection .= '
    ' . $et('Po dodaniu pliku zostanie on przesłany na serwer i dodany do kolejki przywracania.') . '
    '; + + $fileSection .= ''; + $fileSection .= '
    '; + $fileSection .= '
    ' . $renderTargetRadios($availableDbs, $restoreTargetDb, 'target_db', [], true, false, 'file') . '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + $fileSection .= '
    '; + + $fileSection .= FilePicker::renderOverlay( + $ctx, + 'file-picker-overlay', + '/home/' . $daUser, + $ctx->url('file_picker.html'), + 'source-file-path' + ); + } + + $statusRows = ''; + $logOverlays = []; + foreach ($jobs as $job) { + $statusLabel = match ($job['status']) { + 'pending' => $tr('Oczekuje'), + 'processing' => $tr('W toku'), + 'done_ok' => $tr('Zakończone OK'), + 'done_fail' => $tr('Błąd'), + 'done_cancel' => $tr('Anulowane'), + default => $job['status'], + }; + $jobType = (string)($job['type'] ?? ''); + $operationLabel = ($jobType === 'backup') ? $tr('Tworzenie kopii zapasowej') : $tr('Przywracanie kopii zapasowej'); + $restoreModeRaw = strtolower(trim((string)($job['restore_mode'] ?? ''))); + $restoreModeLabel = $jobType === 'restore' + ? ($restoreModeRaw === 'new_db' ? $tr('Przywracanie do nowej bazy') : $tr('Przywracanie z nadpisaniem')) + : $tr('Nie dotyczy'); + + $jobId = (string)($job['job_id'] ?? ''); + $overlayId = 'log-overlay-' . preg_replace('/[^A-Za-z0-9_-]+/', '-', $jobId); + $contentId = $overlayId . '-content'; + $sourceDbName = trim((string)($job['source_db_name'] ?? '')); + $targetDbName = (string)($job['db_name'] ?? '-'); + $hasDistinctSourceTarget = ($jobType === 'restore' && $sourceDbName !== '' && $sourceDbName !== $targetDbName); + $dbDisplay = $hasDistinctSourceTarget + ? ($sourceDbName . ' → ' . $targetDbName) + : $targetDbName; + $logText = ''; + try { + $logText = $queue->readLogForCurrentUser($jobId); + } catch (Throwable $e) { + $logText = $ctx->formatException($e); + } + + $statusRows .= ''; + $statusRows .= '' . AppContext::e($job['start_ts'] > 0 ? date('Y-m-d H:i:s', $job['start_ts']) : '-') . ''; + $statusRows .= '' . AppContext::e($dbDisplay) . ''; + $statusRows .= '' . AppContext::e($operationLabel) . ''; + $statusRows .= '' . AppContext::e($restoreModeLabel) . ''; + $statusRows .= '' . AppContext::e($statusLabel) . ''; + $statusRows .= ''; + $statusRows .= ''; + $statusRows .= ''; + $statusRows .= ''; + + $logMeta = '
    '; + $logMeta .= '
    ' . $et('Data rozpoczęcia') . ': ' . AppContext::e($job['start_ts'] > 0 ? date('Y-m-d H:i:s', $job['start_ts']) : '-') . '
    '; + $logMeta .= '
    ' . $et('Rodzaj operacji') . ': ' . AppContext::e($operationLabel) . '
    '; + $logMeta .= '
    ' . $et('Typ przywracania') . ': ' . AppContext::e($restoreModeLabel) . '
    '; + if ($hasDistinctSourceTarget) { + $logMeta .= '
    ' . $et('Baza źródłowa') . ': ' . AppContext::e($sourceDbName !== '' ? $sourceDbName : '-') . '
    '; + $logMeta .= '
    ' . $et('Baza docelowa') . ': ' . AppContext::e($targetDbName) . '
    '; + } else { + $logMeta .= '
    ' . $et('Nazwa bazy') . ': ' . AppContext::e($targetDbName) . '
    '; + } + $logMeta .= '
    ' . $et('Status') . ': ' . AppContext::e($statusLabel) . '
    '; + $logMeta .= '
    '; + + $logOverlays[] = ''; + } + if ($statusRows === '') { + $statusRows = '' . $et('Brak zadań backup/restore do wyświetlenia.') . ''; + } + + $statusTable = '' . $statusRows . '
    ' . $et('Data rozpoczęcia') . '' . $et('Nazwa bazy') . '' . $et('Rodzaj operacji') . '' . $et('Typ przywracania') . '' . $et('Status') . '' . $et('Akcja') . '
    '; + $statusInner = $statusTable . (!empty($logOverlays) ? implode('', $logOverlays) : ''); + + if ($ctx->queryString('action') === 'status') { + header('Content-Type: text/html; charset=UTF-8'); + echo $statusInner; + return; + } + + $logsSection = ''; + $logsSection .= '
    '; + $logsSection .= '

    ' . $et('Kolejka zadań i logi') . '

    '; + $logsSection .= '
    ' . $statusInner . '
    '; + $logsSection .= '
    '; + + $body = $tabs . $restoreModeSection . $localSection . $userSection . $fileSection . $confirmOverlay; + $shouldRefresh = false; + foreach ($jobs as $job) { + if (in_array($job['status'], ['pending', 'processing'], true)) { + $shouldRefresh = true; + break; + } + } + if ($shouldRefresh) { + $body .= '
    '; + } + $ctx->renderPage('Przywróć Backup', $body, $ok, $errors, $logsSection); +}; diff --git a/exec/lib/AppContext.php b/exec/lib/AppContext.php new file mode 100644 index 0000000..4ba1e24 --- /dev/null +++ b/exec/lib/AppContext.php @@ -0,0 +1,333 @@ +daUser = $daUser; + $this->settings = $settings; + $this->mysql = $mysql; + $this->csrf = $csrf; + $this->lang = $lang; + } + + public function action(): string + { + return (string)PLUGIN_ACTION; + } + + public function baseUrl(): string + { + return '/CMD_PLUGINS/alt-mysql'; + } + + public function url(string $page, array $query = []): string + { + $path = $this->baseUrl() . '/' . ltrim($page, '/'); + if (!empty($query)) { + $path .= '?' . http_build_query($query); + } + return $path; + } + + public function isPost(): bool + { + return Http::isPost(); + } + + public function postString(string $key, string $default = ''): string + { + return Http::getString($_POST, $key, $default); + } + + /** + * @return array + */ + public function postArray(string $key): array + { + $raw = Http::getArray($_POST, $key); + $out = []; + foreach ($raw as $item) { + if (is_array($item)) { + continue; + } + $out[] = trim((string)$item); + } + return $out; + } + + public function queryString(string $key, string $default = ''): string + { + return Http::getString($_GET, $key, $default); + } + + /** + * @param array $vars + */ + public function t(string $key, array $vars = []): string + { + return $this->lang->t($key, $vars); + } + + public function formatException(Throwable $e): string + { + if ($e instanceof LocalizedException) { + return $this->t($e->key(), $e->vars()); + } + return $this->t($e->getMessage()); + } + + public function csrfField(string $intent): string + { + $issued = $this->csrf->issue($intent); + $ts = (string)$issued['ts']; + $sid = htmlspecialchars((string)($issued['sid'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $token = htmlspecialchars($issued['token'], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return '' . + '' . + ''; + } + + public function requireCsrf(string $intent): void + { + $ts = $this->postString('csrf_ts'); + $sid = $this->postString('csrf_sid'); + $token = $this->postString('csrf_token'); + if (!$this->csrf->validate($intent, $ts, $token, 3600, $sid)) { + $debug = sprintf( + '[%s] CSRF_FAIL intent=%s action=%s method=%s ts=%s sid=%s token_prefix=%s post_keys=%s', + date('c'), + $intent, + $this->action(), + Http::method(), + $ts, + $sid, + substr($token, 0, 12), + implode(',', array_keys($_POST)) + ); + @error_log($debug . "\n", 3, PLUGIN_ROOT . '/error.log'); + throw new RuntimeException('Nieprawidłowy lub wygasły token CSRF.'); + } + } + + /** + * @param string[] $okMessages + * @param string[] $errorMessages + */ + public function renderPage(string $title, string $body, array $okMessages = [], array $errorMessages = [], string $logs = ''): void + { + $alerts = ''; + foreach ($okMessages as $msg) { + $alerts .= '
    ' . self::e($msg) . '
    '; + } + foreach ($errorMessages as $msg) { + $alerts .= '
    ' . self::e($msg) . '
    '; + } + + $alerts = $this->lang->translateHtml($alerts); + $body = $this->lang->translateHtml($body); + $logs = $this->lang->translateHtml($logs); + + $safeTitle = self::e($this->t($title)); + $topActions = $this->renderTopActions(); + $langCode = $this->daUser->language(); + + header('Content-Type: text/html; charset=UTF-8'); + echo ''; + echo ''; + echo '' . $safeTitle . ''; + echo ''; + echo ''; + echo '
    '; + echo ''; + $headerClass = in_array($this->action(), ['index', 'upload_backup'], true) ? ' class="header-center"' : ''; + echo '

    ' . $safeTitle . '

    '; + echo '
    ' . $topActions . '
    '; + echo '
    ' . $alerts . $body . '
    '; + echo '
    ' . $logs . '
    '; + echo '
    '; + $jsConfig = 'window.DA_MYSQL_BASE_URL=' . json_encode($this->baseUrl(), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_MYSQL_INDEX_URL=' . json_encode($this->url('index.html'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_MYSQL_USER_URL=' . json_encode($this->url('user/index.html'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_MYSQL_UPLOAD_RAW_URL=' . json_encode($this->url('upload_backup.raw'), JSON_UNESCAPED_SLASHES) . ';'; + $jsConfig .= 'window.DA_MYSQL_I18N=' . json_encode($this->jsI18n(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ';'; + echo ''; + echo ''; + echo ''; + } + + public function renderFatal(string $message, int $code = 400): void + { + http_response_code($code); + $body = '

    ' . self::e($this->t($message)) . '

    '; + $body .= '

    ' . self::e($this->t('Powrót')) . '

    '; + $this->renderPage('Błąd', $body, [], []); + } + + public static function e(string $value): string + { + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + } + + private function renderTopActions(): string + { + $links = []; + + $links[] = '' . self::e($this->t('BAZY DANYCH')) . ''; + $links[] = '' . self::e($this->t('UŻYTKOWNICY BAZ DANYCH')) . ''; + if ($this->settings->enableUploadBackup()) { + $links[] = '' . self::e($this->t('BACKUPY BAZ DANYCH')) . ''; + } + if ($this->settings->enableAdminer()) { + $links[] = '
    ' . + $this->csrfField('open_phpmyadmin_submit') . + '' . + '
    '; + } + + return implode('', $links); + } + + private function styles(): string + { + $skin = $this->daUser->skin(); + $path = PLUGIN_ROOT . '/user/' . $skin . '.css'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/user/enhanced.css'; + } + $css = is_file($path) ? file_get_contents($path) : ''; + if ($css === false) { + return ''; + } + return $css; + } + + /** + * @return array + */ + private function jsI18n(): array + { + $keys = [ + 'Pobieranie...', + 'Nieudana odpowiedź serwera ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})', + 'Serwer zwrócił pusty plik.', + 'Nie można pobrać pliku backupu.', + 'Pobierz plik backupu', + 'Brak plików .sql/.gz', + 'Brak katalogów', + 'Nie udało się wczytać katalogów.', + 'Podaj nazwę katalogu.', + 'Nie udało się utworzyć katalogu.', + 'Wysyłanie pliku...', + 'Nie udało się wysłać pliku backupu.', + 'Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.', + 'Wybierz plik .sql/.gz/.sql.gz do przywrócenia.', + ]; + + $out = []; + foreach ($keys as $key) { + $out[$key] = $this->t($key); + } + return $out; + } + + private function scripts(): string + { + $js = <<<'JS' +(function () { + var __i18n = (typeof window.DA_MYSQL_I18N === 'object' && window.DA_MYSQL_I18N) ? window.DA_MYSQL_I18N : {}; + function tr(key) { + return (__i18n && Object.prototype.hasOwnProperty.call(__i18n, key)) ? __i18n[key] : key; + } + function trf(key, vars) { + var out = tr(key); + if (!vars) { + return out; + } + for (var k in vars) { + if (!Object.prototype.hasOwnProperty.call(vars, k)) { + continue; + } + out = out.replace(new RegExp('\\{' + k + '\\}', 'g'), String(vars[k])); + } + return out; + } + + function generatePassword(len) { + var size = len || 20; + var alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*_-+='; + var bytes = new Uint32Array(size); + if (window.crypto && window.crypto.getRandomValues) { + window.crypto.getRandomValues(bytes); + } else { + for (var i = 0; i < size; i += 1) { + bytes[i] = Math.floor(Math.random() * 0xffffffff); + } + } + var out = ''; + for (var j = 0; j < size; j += 1) { + out += alphabet.charAt(bytes[j] % alphabet.length); + } + return out; + } + + function bindPasswordHelpers() { + document.querySelectorAll('[data-generate-target]').forEach(function (btn) { + btn.addEventListener('click', function () { + var input = document.getElementById(btn.getAttribute('data-generate-target')); + if (!input) { return; } + input.value = generatePassword(20); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }); + + document.querySelectorAll('[data-toggle-target]').forEach(function (btn) { + btn.addEventListener('click', function () { + var input = document.getElementById(btn.getAttribute('data-toggle-target')); + if (!input) { return; } + input.type = input.type === 'password' ? 'text' : 'password'; + }); + }); + } + + function bindDownloadForms() { + document.querySelectorAll('form[data-backup-download]').forEach(function (form) { + form.addEventListener('submit', function (ev) { + var btn = form.querySelector('button[type="submit"],input[type="submit"]'); + if (btn) { + btn.disabled = true; + btn.dataset.originalText = btn.textContent; + btn.textContent = tr('Pobieranie...'); + } + window.setTimeout(function () { + if (btn) { + btn.disabled = false; + if (btn.dataset.originalText) { + btn.textContent = btn.dataset.originalText; + } + } + }, 4000); + }); + }); + } + + window.daMysqlI18n = { tr: tr, trf: trf }; + bindPasswordHelpers(); + bindDownloadForms(); +}()); +JS; + + return FilePicker::scripts() . "\n" . $js; + } +} diff --git a/exec/lib/BackupQueueService.php b/exec/lib/BackupQueueService.php new file mode 100644 index 0000000..bc36136 --- /dev/null +++ b/exec/lib/BackupQueueService.php @@ -0,0 +1,1618 @@ +settings = $settings; + $this->daUser = $daUser; + $this->pluginRoot = defined('PLUGIN_ROOT') ? PLUGIN_ROOT : dirname(__DIR__, 2); + $this->dataDir = $this->pluginRoot . '/data'; + $this->jobsPendingDir = $this->dataDir . '/jobs/pending'; + $this->jobsProcessingDir = $this->dataDir . '/jobs/processing'; + $this->jobsDoneDir = $this->dataDir . '/jobs/done'; + $this->logsDir = $this->dataDir . '/logs'; + $this->lockDir = $this->dataDir . '/lock'; + $this->uploadsDir = $this->dataDir . '/uploads'; + } + + public function ensureInfrastructure(): void + { + $dirs = [ + $this->dataDir, + $this->dataDir . '/jobs', + $this->jobsPendingDir, + $this->jobsProcessingDir, + $this->jobsDoneDir, + $this->logsDir, + $this->lockDir, + $this->dataDir . '/pids', + $this->dataDir . '/cancel', + $this->uploadsDir, + $this->uploadsDir . '/' . $this->daUser->username(), + ]; + + foreach ($dirs as $dir) { + if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) { + throw new RuntimeException('Nie można utworzyć katalogu: ' . $dir); + } + @chmod($dir, 0700); + } + } + + public function backupEnabled(): bool + { + return $this->settings->enableBackup(); + } + + public function uploadBackupEnabled(): bool + { + return $this->settings->enableUploadBackup(); + } + + public function userBackupRoot(): string + { + $root = $this->settings->userBaseBackupDir($this->daUser->username()); + if ($root === '') { + $root = '/home/' . $this->daUser->username() . '/mysql_backup'; + } + + return rtrim($root, '/'); + } + + public function scriptBackupRoot(): string + { + $root = $this->settings->hitmeBackupLocation(); + if ($root === '') { + $root = '/home/admin/mysql_backup'; + } + + return rtrim($root, '/'); + } + + public function userBackupDateDir(): string + { + $format = $this->settings->userBackupDateFormat(); + $dir = date($format); + if ($dir === '' || strpos($dir, '..') !== false || strpos($dir, '/') !== false || strpos($dir, '\\') !== false) { + return date('d.m.Y-H.i'); + } + + if (!preg_match('/[Hhis]/', $format)) { + $dir .= '-' . date('H.i.s'); + } + + $base = $dir; + $root = $this->userBackupRoot(); + $attempt = 0; + while (is_dir($root . '/' . $dir)) { + $attempt++; + $dir = $base . '-' . date('His') . ($attempt > 1 ? '-' . $attempt : ''); + } + + return $dir; + } + + public function queueBackupJob(string $dbName): string + { + if (!$this->backupEnabled()) { + throw new RuntimeException('Tworzenie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $jobId = $this->buildJobId('backup'); + $lockPath = $this->dbLockPath($dbName); + $compress = $this->settings->compressBackup() ? '1' : '0'; + $backupRoot = $this->userBackupRoot(); + $dateDir = $this->userBackupDateDir(); + + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $compress, $backupRoot, $dateDir): string { + $this->acquireDbLock($dbName, 'backup', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/backup-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'backup', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'START_TS' => (string)time(), + 'DATE_DIR' => $dateDir, + 'COMPRESS_BACKUP' => $compress, + 'USER_BASE_BACKUP_DIR' => $backupRoot, + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } + + public function queueRestoreFromLocal(string $relativeBackupPath, string $restoreMode = 'overwrite', string $targetDb = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $resolved = $this->resolveLocalBackupEntry($relativeBackupPath); + $sourceDb = $resolved['db_name']; + if ($sourceDb === '') { + throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); + } + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + $dbName = $sourceDb; + if ($restoreMode === 'new_db') { + if ($targetDb === '') { + throw new RuntimeException('Wskaż docelową bazę danych.'); + } + $dbName = $targetDb; + } + $this->assertDatabaseBelongsToUser($dbName); + + $jobId = $this->buildJobId('restore'); + $lockPath = $this->dbLockPath($dbName); + + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $resolved, $restoreMode, $sourceDb): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'SOURCE_DB_NAME' => $sourceDb, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_FILE' => $resolved['path'], + 'SOURCE_KIND' => 'local', + 'START_TS' => (string)time(), + 'DATE_DIR' => $resolved['date'], + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } + + public function queueRestoreFromFilePath(string $dbName, string $filePath, string $restoreMode = 'overwrite', string $sourceDbName = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $filePath = trim($filePath); + if ($filePath === '') { + throw new RuntimeException('Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.'); + } + + if (!is_file($filePath) || !is_readable($filePath)) { + throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); + } + + $realPath = @realpath($filePath); + if ($realPath === false) { + throw new RuntimeException('Nie można odczytać pliku: ' . $filePath); + } + + $allowedRoots = [ + '/home/' . $this->daUser->username(), + $this->uploadsDir . '/' . $this->daUser->username(), + ]; + $inAllowedRoot = false; + foreach ($allowedRoots as $rootPath) { + $realRoot = @realpath($rootPath); + if ($realRoot === false) { + continue; + } + if ($realPath === $realRoot || strpos($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) === 0) { + $inAllowedRoot = true; + break; + } + } + if (!$inAllowedRoot) { + throw new RuntimeException('Ścieżka pliku musi znajdować się w katalogu /home/' . $this->daUser->username() . ' lub pochodzić z uploadu formularza.'); + } + + if (!$this->isAllowedSqlFile($filePath)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $jobId = $this->buildJobId('restore'); + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($filePath)) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; + if (!@copy($filePath, $copiedFile)) { + throw new RuntimeException('Nie udało się skopiować pliku do kolejki restore.'); + } + @chmod($copiedFile, 0600); + + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + // Bezpieczeństwo: dla restore z pliku ignorujemy źródłową nazwę bazy. + // Import ma być wykonywany wyłącznie do DB wskazanej przez użytkownika. + $sourceDbName = ''; + + $lockPath = $this->dbLockPath($dbName); + + try { + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode, $sourceDbName): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_DB_NAME' => $sourceDbName, + 'SOURCE_FILE' => $copiedFile, + 'SOURCE_KIND' => 'file', + 'START_TS' => (string)time(), + 'DATE_DIR' => date('Y-m-d'), + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } catch (Throwable $e) { + @unlink($copiedFile); + throw $e; + } + } + + public function stageRestoreUploadFromStream(string $originalName): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $originalName = basename(trim($originalName)); + if ($originalName === '') { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'stream'); + + $in = false; + $candidates = (PHP_SAPI === 'cli') + ? ['php://stdin', 'php://input'] + : ['php://input', 'php://stdin']; + foreach ($candidates as $candidate) { + $stream = @fopen($candidate, 'rb'); + if (is_resource($stream)) { + $in = $stream; + break; + } + } + if (!is_resource($in)) { + throw new RuntimeException('Nie można odczytać przesyłanego pliku.'); + } + $out = @fopen($targetFile, 'wb'); + if (!is_resource($out)) { + @fclose($in); + throw new RuntimeException('Nie można zapisać przesyłanego pliku na serwerze.'); + } + + $written = @stream_copy_to_stream($in, $out); + @fclose($out); + @fclose($in); + if ($written === false || (int)$written <= 0) { + $len = (string)($_SERVER['CONTENT_LENGTH'] ?? (getenv('CONTENT_LENGTH') ?: '')); + @error_log(sprintf( + "[%s] UPLOAD_BLOB_STREAM_EMPTY user=%s name=%s content_length=%s target=%s\n", + date('c'), + $this->daUser->username(), + $originalName, + $len, + $targetFile + ), 3, PLUGIN_ROOT . '/error.log'); + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + @chmod($targetFile, 0600); + return $targetFile; + } + + /** + * @param array $upload + */ + public function stageRestoreUploadFromPhpUpload(array $upload, string $nameHint = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; + if ($error !== UPLOAD_ERR_OK) { + if ($error === UPLOAD_ERR_NO_FILE) { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); + } + + $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; + if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; + if ($originalName === '') { + $originalName = basename(trim($nameHint)); + } + if ($originalName === '') { + $originalName = basename($tmpName); + } + if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'php'); + $moved = @move_uploaded_file($tmpName, $targetFile); + if (!$moved) { + if (!@copy($tmpName, $targetFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @unlink($tmpName); + } + + @chmod($targetFile, 0600); + clearstatcache(true, $targetFile); + $size = @filesize($targetFile); + if (!is_int($size) || $size <= 0) { + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + return $targetFile; + } + + public function stageRestoreUploadFromDirectAdminTemp(string $uploadReference, string $originalName = ''): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + + $uploadReference = trim($uploadReference); + if ($uploadReference === '') { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + + $token = basename(str_replace('\\', '/', $uploadReference)); + if ($token === '') { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $sourceCandidates = []; + if (str_starts_with($uploadReference, '/') && is_file($uploadReference)) { + $sourceCandidates[] = $uploadReference; + } + $tmpDirs = [ + (string)(getenv('UPLOAD_TMP_DIR') ?: ''), + (string)(getenv('DA_UPLOAD_TMP_DIR') ?: ''), + '/home/tmp', + '/tmp', + '/var/tmp', + sys_get_temp_dir(), + ]; + foreach ($tmpDirs as $dir) { + $dir = trim((string)$dir); + if ($dir === '') { + continue; + } + $sourceCandidates[] = rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $token; + } + $sourceCandidates = array_values(array_unique($sourceCandidates)); + + $sourcePath = ''; + foreach ($sourceCandidates as $candidate) { + if (is_file($candidate) && is_readable($candidate)) { + $sourcePath = $candidate; + break; + } + } + if ($sourcePath === '') { + @error_log(sprintf( + "[%s] UPLOAD_DA_TMP_MISSING user=%s ref=%s token=%s candidates=%s\n", + date('c'), + $this->daUser->username(), + $uploadReference, + $token, + implode('|', $sourceCandidates) + ), 3, PLUGIN_ROOT . '/error.log'); + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu. Wybierz plik ponownie.'); + } + + $originalName = basename(trim($originalName)); + if ($originalName === '' || !$this->isAllowedSqlFile($originalName)) { + $originalName = $this->isAllowedSqlFile($token) ? $token : 'restore.sql'; + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $targetFile = $this->buildStagedUploadPath($originalName, 'da'); + if (!@copy($sourcePath, $targetFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @chmod($targetFile, 0600); + clearstatcache(true, $targetFile); + $size = @filesize($targetFile); + if (!is_int($size) || $size <= 0) { + @unlink($targetFile); + throw new RuntimeException('Przesłany plik jest pusty lub uszkodzony.'); + } + + @error_log(sprintf( + "[%s] UPLOAD_DA_TMP_STAGED user=%s ref=%s src=%s dst=%s size=%s\n", + date('c'), + $this->daUser->username(), + $uploadReference, + $sourcePath, + $targetFile, + (string)$size + ), 3, PLUGIN_ROOT . '/error.log'); + + return $targetFile; + } + + /** + * @param array $upload + */ + public function queueRestoreFromUploadedFile(string $dbName, array $upload, string $restoreMode = 'overwrite'): string + { + if (!$this->uploadBackupEnabled()) { + throw new RuntimeException('Przywracanie backupu jest wyłączone w konfiguracji pluginu.'); + } + + $this->ensureInfrastructure(); + $this->assertDatabaseBelongsToUser($dbName); + + $error = isset($upload['error']) ? (int)$upload['error'] : UPLOAD_ERR_NO_FILE; + if ($error !== UPLOAD_ERR_OK) { + if ($error === UPLOAD_ERR_NO_FILE) { + throw new RuntimeException('Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.'); + } + throw new RuntimeException('Nie udało się przesłać pliku backupu (kod błędu: ' . $error . ').'); + } + + $tmpName = isset($upload['tmp_name']) ? trim((string)$upload['tmp_name']) : ''; + if ($tmpName === '' || !is_file($tmpName) || !is_readable($tmpName)) { + throw new RuntimeException('Nie można odczytać przesłanego pliku backupu.'); + } + + $originalName = isset($upload['name']) ? basename((string)$upload['name']) : ''; + if ($originalName === '') { + $originalName = 'restore.sql'; + } + if (!$this->isAllowedSqlFile($originalName)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $jobId = $this->buildJobId('restore'); + $restoreMode = strtolower(trim($restoreMode)); + if (!in_array($restoreMode, ['overwrite', 'new_db'], true)) { + $restoreMode = 'overwrite'; + } + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', $originalName) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $copiedFile = $targetDir . '/' . $jobId . '_' . $safeBase; + $moved = @move_uploaded_file($tmpName, $copiedFile); + if (!$moved) { + if (!@copy($tmpName, $copiedFile)) { + throw new RuntimeException('Nie udało się zapisać przesłanego pliku do kolejki restore.'); + } + @unlink($tmpName); + } + @chmod($copiedFile, 0600); + + $lockPath = $this->dbLockPath($dbName); + + try { + return $this->withQueueLock(function () use ($dbName, $jobId, $lockPath, $copiedFile, $restoreMode): string { + $this->acquireDbLock($dbName, 'restore', $jobId, $lockPath); + try { + $jobFile = $this->jobsPendingDir . '/restore-' . $jobId . '.env'; + $payload = [ + 'JOB_ID' => $jobId, + 'JOB_TYPE' => 'restore', + 'DA_USER' => $this->daUser->username(), + 'DB_NAME' => $dbName, + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_FILE' => $copiedFile, + 'SOURCE_KIND' => 'file', + 'START_TS' => (string)time(), + 'DATE_DIR' => date('Y-m-d'), + 'DB_LOCK_FILE' => $lockPath, + ]; + $this->writeJobFile($jobFile, $payload); + } catch (Throwable $e) { + $this->releaseDbLock($lockPath); + throw $e; + } + + $this->triggerWorker(); + return $jobId; + }); + } catch (Throwable $e) { + @unlink($copiedFile); + throw $e; + } + } + + /** + * @return array> + */ + public function listLocalBackupsByDate(): array + { + $root = $this->scriptBackupRoot(); + if (!is_dir($root)) { + return []; + } + + $out = []; + $dateDirs = []; + $items = @scandir($root); + if ($items === false) { + return []; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $root . '/' . $item; + if (is_dir($full)) { + $dateDirs[] = $full; + } + } + + if (empty($dateDirs)) { + $dateDirs[] = $root; + } + + foreach ($dateDirs as $dirPath) { + $dirName = basename($dirPath); + $files = @scandir($dirPath); + if ($files === false) { + continue; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + + $fullPath = $dirPath . '/' . $file; + if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { + continue; + } + + $mtime = (int)@filemtime($fullPath); + $rawDateKey = $dirPath === $root ? date('Y-m-d', $mtime) : $dirName; + $dateKey = $this->normalizeDateKey($rawDateKey, $mtime); + $relative = ltrim(substr($fullPath, strlen(rtrim($root, '/'))), '/'); + + $sourceDb = $this->extractSourceDbFromBackupFile($file); + if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { + continue; + } + + $entry = [ + 'id' => $relative, + 'file' => $file, + 'path' => $fullPath, + 'date' => $dateKey, + 'time' => $mtime > 0 ? date('H:i:s', $mtime) : '', + 'size' => $this->humanFileSize((int)@filesize($fullPath)), + 'source_db' => $sourceDb, + 'mtime' => $mtime, + ]; + + if (!isset($out[$dateKey])) { + $out[$dateKey] = []; + } + $out[$dateKey][] = $entry; + } + } + + foreach ($out as $date => $entries) { + usort($entries, static fn(array $a, array $b): int => ($b['mtime'] <=> $a['mtime'])); + $out[$date] = $entries; + } + + uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); + return $out; + } + + /** + * @return array> + */ + public function listUserBackupsByDate(int $limit = 500): array + { + $this->ensureInfrastructure(); + + $root = $this->userBackupRoot(); + $out = []; + if (!is_dir($root)) { + return []; + } + + $rootReal = @realpath($root); + $jobs = $this->listJobsForCurrentUser($limit); + $jobsByTarget = []; + foreach ($jobs as $job) { + if (($job['type'] ?? '') !== 'backup') { + continue; + } + $jobId = (string)($job['job_id'] ?? ''); + if ($jobId === '') { + continue; + } + $targetFile = $this->resolveBackupTargetPathFromJob($job, $jobId); + if ($targetFile === '') { + continue; + } + $realTarget = @realpath($targetFile); + if ($realTarget === false) { + continue; + } + if ($rootReal !== false) { + if (strpos($realTarget, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realTarget !== $rootReal) { + continue; + } + } + $jobsByTarget[$realTarget] = $job; + } + + $dateDirs = []; + $items = @scandir($root); + if (is_array($items)) { + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + $full = $root . '/' . $item; + if (is_dir($full)) { + $dateDirs[] = $full; + } + } + } + if (empty($dateDirs)) { + $dateDirs[] = $root; + } + + $dirFormat = $this->settings->userBackupDateFormat(); + foreach ($dateDirs as $dirPath) { + $dirName = basename($dirPath); + $dirTs = 0; + if ($dirPath !== $root && $dirName !== '') { + $dt = @DateTime::createFromFormat($dirFormat, $dirName); + if ($dt instanceof DateTime && $dt->format($dirFormat) === $dirName) { + $dirTs = $dt->getTimestamp(); + } + } + + $files = @scandir($dirPath); + if ($files === false) { + continue; + } + + foreach ($files as $file) { + if ($file === '.' || $file === '..') { + continue; + } + if (preg_match('/-current\\.sql(\\.gz)?$/i', $file)) { + continue; + } + + $fullPath = $dirPath . '/' . $file; + if (!is_file($fullPath) || !$this->isAllowedSqlFile($fullPath)) { + continue; + } + + $realPath = @realpath($fullPath); + if ($realPath === false) { + continue; + } + if ($rootReal !== false) { + if (strpos($realPath, rtrim($rootReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0 && $realPath !== $rootReal) { + continue; + } + } + + $mtime = (int)@filemtime($realPath); + $ts = $dirTs > 0 ? $dirTs : $mtime; + if ($ts <= 0) { + $ts = (int)time(); + } + $dateKey = date('Y-m-d', $ts); + $timeLabel = date('H:i:s', $ts); + + $sourceDb = $this->extractSourceDbFromBackupFile($file); + if ($sourceDb !== '' && strpos($sourceDb, $this->daUser->prefix()) !== 0) { + continue; + } + + $job = $jobsByTarget[$realPath] ?? null; + $jobId = is_array($job) ? (string)($job['job_id'] ?? '') : ''; + $status = is_array($job) ? (string)($job['status'] ?? '') : 'done_ok'; + $logExists = is_array($job) ? (bool)($job['log_exists'] ?? false) : false; + $dbName = is_array($job) && (string)($job['db_name'] ?? '') !== '' ? (string)$job['db_name'] : $sourceDb; + + $relative = ltrim(substr($realPath, strlen(rtrim($root, '/'))), '/'); + + $entry = [ + 'job_id' => $jobId, + 'db_name' => $dbName, + 'date' => $dateKey, + 'time' => $timeLabel, + 'mtime' => $ts, + 'size' => $this->humanFileSize((int)@filesize($realPath)), + 'status' => $status, + 'log_exists' => $logExists, + 'target_file' => $realPath, + 'has_file' => true, + 'path' => $realPath, + 'rel_path' => $relative, + ]; + + if (!isset($out[$dateKey])) { + $out[$dateKey] = []; + } + $out[$dateKey][] = $entry; + } + } + + foreach ($out as $date => $entries) { + usort($entries, static fn(array $a, array $b): int => ((int)($b['mtime'] ?? 0) <=> (int)($a['mtime'] ?? 0))); + $out[$date] = $entries; + } + + uksort($out, static fn(string $a, string $b): int => strcmp($b, $a)); + return $out; + + } + + public function resolveUserBackupFileForCurrentUser(string $relativePath): string + { + $relativePath = ltrim(trim($relativePath), '/'); + if ($relativePath === '' || strpos($relativePath, '..') !== false) { + throw new RuntimeException('Nieprawidłowa ścieżka pliku backupu.'); + } + + $root = $this->userBackupRoot(); + $candidate = $root . '/' . $relativePath; + $realRoot = @realpath($root); + $realPath = @realpath($candidate); + if ($realRoot === false || $realPath === false) { + throw new RuntimeException('Nie można zlokalizować pliku backupu.'); + } + if (!str_starts_with($realPath, rtrim($realRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR)) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + if (!is_file($realPath) || !$this->isAllowedSqlFile($realPath)) { + throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); + } + + return $realPath; + } + + public function deleteUserBackupFileForCurrentUser(string $relativePath): string + { + $path = $this->resolveUserBackupFileForCurrentUser($relativePath); + + $currentCandidate = ''; + if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { + $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); + } + + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); + } + if ($currentCandidate !== '' && is_file($currentCandidate)) { + @unlink($currentCandidate); + } + + $parentDir = dirname($path); + if (is_dir($parentDir)) { + $items = @scandir($parentDir) ?: []; + $onlyDots = true; + foreach ($items as $item) { + if ($item !== '.' && $item !== '..') { + $onlyDots = false; + break; + } + } + if ($onlyDots) { + @rmdir($parentDir); + } + } + + return $path; + } + + private function normalizeDateKey(string $raw, int $fallbackTs = 0): string + { + $raw = trim($raw); + if ($raw === '') { + return $fallbackTs > 0 ? date('Y-m-d', $fallbackTs) : ''; + } + + $formats = ['Y-m-d', 'd-m-Y', 'Y.m.d', 'd.m.Y']; + foreach ($formats as $fmt) { + $dt = @DateTime::createFromFormat($fmt, $raw); + if ($dt instanceof DateTime && $dt->format($fmt) === $raw) { + return $dt->format('Y-m-d'); + } + } + + if ($fallbackTs > 0) { + return date('Y-m-d', $fallbackTs); + } + + return $raw; + } + + /** + * @return array + */ + public function listJobsForCurrentUser(int $limit = 150): array + { + $this->ensureInfrastructure(); + + $candidates = []; + $this->collectJobFiles($this->jobsPendingDir, 'pending', $candidates); + $this->collectJobFiles($this->jobsProcessingDir, 'processing', $candidates); + $this->collectDoneJobFiles($this->jobsDoneDir, $candidates); + + $out = []; + foreach ($candidates as $candidate) { + $meta = $this->parseJobFile($candidate['path']); + if (($meta['DA_USER'] ?? '') !== $this->daUser->username()) { + $fallback = $this->inferJobMetaFromLog($candidate['job_id']); + if ($fallback === null) { + continue; + } + $meta = array_merge($fallback, $meta); + } + + $jobId = (string)($meta['JOB_ID'] ?? $candidate['job_id']); + if ($jobId === '') { + continue; + } + + $out[] = [ + 'job_id' => $jobId, + 'type' => (string)($meta['JOB_TYPE'] ?? 'unknown'), + 'db_name' => (string)($meta['DB_NAME'] ?? ''), + 'status' => $candidate['status'], + 'start_ts' => (int)($meta['START_TS'] ?? @filemtime($candidate['path']) ?: 0), + 'updated_ts' => (int)@filemtime($candidate['path']), + 'file' => $candidate['path'], + 'log_exists' => is_file($this->logsDir . '/' . $jobId . '.log'), + 'source_file' => (string)($meta['SOURCE_FILE'] ?? ''), + 'target_file' => (string)($meta['TARGET_FILE'] ?? ''), + 'date_dir' => (string)($meta['DATE_DIR'] ?? ''), + 'restore_mode' => (string)($meta['RESTORE_MODE'] ?? ''), + 'source_db_name' => (string)($meta['SOURCE_DB_NAME'] ?? ''), + ]; + } + + usort( + $out, + static function (array $a, array $b): int { + if ($a['start_ts'] === $b['start_ts']) { + return strcmp($b['job_id'], $a['job_id']); + } + return $b['start_ts'] <=> $a['start_ts']; + } + ); + + if ($limit > 0 && count($out) > $limit) { + return array_slice($out, 0, $limit); + } + + return $out; + } + + /** + * @return array{ + * job_id:string, + * type:string, + * db_name:string, + * status:string, + * start_ts:int, + * updated_ts:int, + * file:string, + * log_exists:bool, + * source_file:string, + * target_file:string, + * date_dir:string + * } + */ + public function getJobForCurrentUser(string $jobId): array + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(500); + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + return $job; + } + } + + throw new RuntimeException('Nie znaleziono zadania lub brak dostępu.'); + } + + public function resolveBackupTargetFileForCurrentUser(string $jobId): string + { + $job = $this->getJobForCurrentUser($jobId); + if ($job['type'] !== 'backup') { + throw new RuntimeException('Wskazane zadanie nie jest zadaniem backupu.'); + } + if ($job['status'] !== 'done_ok') { + throw new RuntimeException('Backup nie został zakończony powodzeniem.'); + } + + $path = $this->resolveBackupTargetPathFromJob($job, $jobId); + if ($path === '') { + throw new RuntimeException('Brak ścieżki pliku backupu w metadanych zadania.'); + } + + if (!is_file($path) || !is_readable($path)) { + throw new RuntimeException('Plik backupu nie istnieje lub brak uprawnień odczytu (sprawdź uprawnienia katalogu i pliku backupu dla diradmin).'); + } + + $userRoot = $this->userBackupRoot(); + $globalRoot = rtrim($this->settings->hitmeBackupLocation(), '/'); + if (!$this->pathInDir($path, $userRoot) && !$this->pathInDir($path, $globalRoot)) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + + return $path; + } + + public function hasBackupTargetFileForCurrentUser(string $jobId): bool + { + try { + $path = $this->resolveBackupTargetFileForCurrentUser($jobId); + return $path !== '' && is_file($path) && is_readable($path); + } catch (Throwable $e) { + return false; + } + } + + public function deleteBackupTargetFileForCurrentUser(string $jobId): string + { + $path = $this->resolveBackupTargetFileForCurrentUser($jobId); + if (!is_file($path)) { + throw new RuntimeException('Plik backupu nie istnieje.'); + } + + $currentCandidate = ''; + if (preg_match('/^(.*)\\.sql(\\.gz)?$/i', $path, $m)) { + $currentCandidate = $m[1] . '-current.sql' . ($m[2] ?? ''); + } + + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć pliku backupu: ' . $path); + } + if ($currentCandidate !== '' && is_file($currentCandidate)) { + @unlink($currentCandidate); + } + + $parentDir = dirname($path); + if (is_dir($parentDir)) { + $items = @scandir($parentDir) ?: []; + $onlyDots = true; + foreach ($items as $item) { + if ($item !== '.' && $item !== '..') { + $onlyDots = false; + break; + } + } + if ($onlyDots) { + @rmdir($parentDir); + } + } + + return $path; + } + + public function readLogForCurrentUser(string $jobId): string + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(); + $allowed = false; + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + $allowed = true; + break; + } + } + if (!$allowed) { + throw new RuntimeException('Brak dostępu do logu zadania.'); + } + + $path = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($path) || !is_readable($path)) { + return 'Brak logu dla wybranego zadania.'; + } + + $content = @file_get_contents($path); + if ($content === false || trim($content) === '') { + return 'Log jest pusty.'; + } + + return $content; + } + + public function deleteBackupLogForCurrentUser(string $jobId): string + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + throw new RuntimeException('Nieprawidłowy identyfikator zadania.'); + } + + $jobs = $this->listJobsForCurrentUser(); + $allowed = false; + foreach ($jobs as $job) { + if ($job['job_id'] === $jobId) { + $allowed = true; + break; + } + } + if (!$allowed) { + throw new RuntimeException('Brak dostępu do logu zadania.'); + } + + $path = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($path)) { + throw new RuntimeException('Log nie istnieje.'); + } + if (!@unlink($path)) { + throw new RuntimeException('Nie udało się usunąć logu zadania.'); + } + + return $path; + } + + public function isDbLocked(string $dbName): bool + { + return is_file($this->dbLockPath($dbName)); + } + + /** + * @return array{path:string,date:string,db_name:string} + */ + public function getLocalBackupEntry(string $relativePath): array + { + return $this->resolveLocalBackupEntry($relativePath); + } + + public function dbLockPath(string $dbName): string + { + $safe = preg_replace('/[^A-Za-z0-9_.-]+/', '_', $dbName) ?? 'db'; + return $this->lockDir . '/' . $safe . '.lock'; + } + + private function assertDatabaseBelongsToUser(string $dbName): void + { + if (strpos($dbName, $this->daUser->prefix()) !== 0) { + throw new RuntimeException('Baza danych nie należy do konta DirectAdmin: ' . $dbName); + } + } + + /** + * @return array{path:string,date:string,db_name:string} + */ + private function resolveLocalBackupEntry(string $relativePath): array + { + $relativePath = trim(str_replace('\\', '/', $relativePath)); + if ($relativePath === '') { + throw new RuntimeException('Nie wybrano pliku backupu z lokalnego katalogu.'); + } + if (strpos($relativePath, '..') !== false || str_starts_with($relativePath, '/')) { + throw new RuntimeException('Nieprawidłowa ścieżka backupu.'); + } + + $root = $this->scriptBackupRoot(); + $fullPath = $root . '/' . ltrim($relativePath, '/'); + if (!is_file($fullPath) || !is_readable($fullPath)) { + throw new RuntimeException('Nie można odczytać wskazanego pliku backupu.'); + } + if (!$this->isAllowedSqlFile($fullPath)) { + throw new RuntimeException('Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.'); + } + + $realRoot = @realpath($root); + $realPath = @realpath($fullPath); + if ($realRoot === false || $realPath === false || !str_starts_with($realPath, rtrim($realRoot, '/') . '/')) { + throw new RuntimeException('Plik backupu znajduje się poza dozwolonym katalogiem.'); + } + + $dateDir = basename(dirname($realPath)); + if ($dateDir === '' || $dateDir === '.' || $dateDir === '..') { + $dateDir = date('Y-m-d'); + } + + $dbName = $this->extractSourceDbFromBackupFile(basename($realPath)); + if ($dbName === '') { + throw new RuntimeException('Nie można ustalić docelowej bazy dla backupu lokalnego.'); + } + + return ['path' => $realPath, 'date' => $dateDir, 'db_name' => $dbName]; + } + + public function detectSourceDatabaseFromBackupName(string $pathOrFile): string + { + $candidate = $this->extractSourceDbFromBackupFile(basename($pathOrFile)); + if ($candidate === '') { + return ''; + } + if (strpos($candidate, $this->daUser->prefix()) !== 0) { + return ''; + } + return $candidate; + } + + public function isAllowedSqlFile(string $path): bool + { + return (bool)preg_match('/\.(?:sql\.gz|sql|gz)$/i', basename($path)); + } + + private function extractSourceDbFromBackupFile(string $file): string + { + $name = preg_replace('/\.(?:sql\.gz|sql|gz)$/i', '', $file) ?? $file; + if ($name === '') { + return ''; + } + + $parts = explode('__', $name, 2); + $candidate = strtolower(trim($parts[0])); + if ($candidate === '' || !preg_match('/^[a-z0-9_]+$/', $candidate)) { + return ''; + } + + return $candidate; + } + + private function buildStagedUploadPath(string $originalName, string $prefix): string + { + $safeBase = preg_replace('/[^A-Za-z0-9._-]+/', '_', basename($originalName)) ?? 'restore.sql'; + $targetDir = $this->uploadsDir . '/' . $this->daUser->username(); + if (!is_dir($targetDir) && !@mkdir($targetDir, 0700, true) && !is_dir($targetDir)) { + throw new RuntimeException('Nie można utworzyć katalogu upload: ' . $targetDir); + } + @chmod($targetDir, 0700); + + $token = date('YmdHis') . '-' . bin2hex(random_bytes(4)); + return $targetDir . '/' . $prefix . '-' . $token . '_' . $safeBase; + } + + private function buildJobId(string $type): string + { + return $type . '-' . date('YmdHis') . '-' . bin2hex(random_bytes(4)); + } + + private function withQueueLock(callable $callback) + { + $lockDir = $this->dataDir . '/jobs/queue.lock'; + $tries = 0; + while (!@mkdir($lockDir, 0700)) { + $tries++; + if (!is_dir($lockDir)) { + continue; + } + + $mtime = (int)@filemtime($lockDir); + if ($mtime > 0 && (time() - $mtime) > 120) { + @rmdir($lockDir); + } + + if ($tries >= 40) { + throw new RuntimeException('Nie można uzyskać blokady kolejki zadań. Spróbuj ponownie.'); + } + usleep(100000); + } + + try { + return $callback(); + } finally { + @rmdir($lockDir); + } + } + + private function acquireDbLock(string $dbName, string $jobType, string $jobId, string $lockPath): void + { + $handle = @fopen($lockPath, 'x'); + if ($handle === false) { + $existing = is_file($lockPath) ? trim((string)@file_get_contents($lockPath)) : ''; + $msg = 'Na bazie danych trwa już operacja. Zaczekaj na zakończenie poprzedniego zadania.'; + if ($existing !== '') { + $msg .= ' [' . $existing . ']'; + } + throw new RuntimeException($msg); + } + + $content = implode( + "\n", + [ + 'DB_NAME=' . $dbName, + 'JOB_TYPE=' . $jobType, + 'JOB_ID=' . $jobId, + 'DA_USER=' . $this->daUser->username(), + 'CREATED_AT=' . date('c'), + ] + ) . "\n"; + + fwrite($handle, $content); + fclose($handle); + @chmod($lockPath, 0600); + } + + private function releaseDbLock(string $lockPath): void + { + if (is_file($lockPath)) { + @unlink($lockPath); + } + } + + /** + * @param array $payload + */ + private function writeJobFile(string $path, array $payload): void + { + $lines = []; + foreach ($payload as $key => $value) { + $lines[] = $key . '=' . $this->envQuote($value); + } + $content = implode("\n", $lines) . "\n"; + + if (@file_put_contents($path, $content, LOCK_EX) === false) { + throw new RuntimeException('Nie udało się zapisać pliku zadania: ' . $path); + } + @chmod($path, 0600); + } + + private function envQuote(string $value): string + { + return "'" . str_replace("'", "'\\''", $value) . "'"; + } + + private function triggerWorker(): void + { + $script = $this->pluginRoot . '/scripts/worker.sh'; + if (!is_file($script)) { + return; + } + + $euid = null; + if (function_exists('posix_geteuid')) { + $euid = @posix_geteuid(); + } else { + $raw = @shell_exec('id -u 2>/dev/null'); + if (is_string($raw) && preg_match('/^[0-9]+$/', trim($raw))) { + $euid = (int)trim($raw); + } + } + + // Worker powinien wykonywać zadania z crona jako root, aby mieć dostęp + // do katalogów backupu i konfiguracji systemowej. + if ($euid !== null && $euid !== 0) { + return; + } + + $cmd = '/bin/bash ' . escapeshellarg($script) . ' >/dev/null 2>&1 &'; + @exec($cmd); + } + + /** + * @param array $target + */ + private function collectJobFiles(string $dir, string $status, array &$target): void + { + if (!is_dir($dir)) { + return; + } + + $files = glob($dir . '/*.env'); + if ($files === false) { + return; + } + + foreach ($files as $path) { + $base = basename($path); + $jobId = substr($base, 0, -4); + $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; + } + } + + /** + * @param array $target + */ + private function collectDoneJobFiles(string $dir, array &$target): void + { + if (!is_dir($dir)) { + return; + } + + $patterns = ['*.ok' => 'done_ok', '*.fail' => 'done_fail', '*.cancel' => 'done_cancel']; + foreach ($patterns as $pattern => $status) { + $files = glob($dir . '/' . $pattern); + if ($files === false) { + continue; + } + foreach ($files as $path) { + $base = basename($path); + $jobId = preg_replace('/\.(ok|fail|cancel)$/', '', $base) ?? $base; + $target[] = ['path' => $path, 'status' => $status, 'job_id' => $jobId]; + } + } + } + + /** + * @return array + */ + private function parseJobFile(string $path): array + { + $out = []; + if (!is_readable($path)) { + return $out; + } + + $lines = @file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $out; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + if (!preg_match('/^([A-Z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $key = $m[1]; + $value = trim((string)$m[2]); + if (strlen($value) >= 2 && $value[0] === '\'' && substr($value, -1) === '\'') { + $value = substr($value, 1, -1); + $value = str_replace("'\\''", "'", $value); + } elseif (strlen($value) >= 2 && $value[0] === '"' && substr($value, -1) === '"') { + $value = stripcslashes(substr($value, 1, -1)); + } + $out[$key] = $value; + } + + return $out; + } + + private function humanFileSize(int $bytes): string + { + if ($bytes <= 0) { + return '0 B'; + } + + $units = ['B', 'KB', 'MB', 'GB', 'TB']; + $power = (int)floor(log($bytes, 1024)); + $power = min($power, count($units) - 1); + $value = $bytes / (1024 ** $power); + + return number_format($value, $power === 0 ? 0 : 2, '.', ' ') . ' ' . $units[$power]; + } + + private function pathInDir(string $path, string $dir): bool + { + $realPath = @realpath($path); + $realDir = @realpath($dir); + if ($realPath === false || $realDir === false) { + return false; + } + + $realDir = rtrim($realDir, '/'); + if ($realPath === $realDir) { + return true; + } + + return str_starts_with($realPath, $realDir . '/'); + } + + /** + * @param array $job + */ + private function resolveBackupTargetPathFromJob(array $job, string $jobId): string + { + $path = trim((string)($job['target_file'] ?? '')); + if ($path === '') { + $fallback = $this->inferJobMetaFromLog($jobId); + $path = $fallback !== null ? trim((string)($fallback['TARGET_FILE'] ?? '')) : ''; + } + + return $path; + } + + /** + * @return array|null + */ + private function inferJobMetaFromLog(string $jobId): ?array + { + $jobId = trim($jobId); + if ($jobId === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $jobId)) { + return null; + } + + $logPath = $this->logsDir . '/' . $jobId . '.log'; + if (!is_file($logPath) || !is_readable($logPath)) { + return null; + } + + $lines = @file($logPath, FILE_IGNORE_NEW_LINES); + if ($lines === false || empty($lines)) { + return null; + } + + $type = 'unknown'; + $dbName = ''; + $sourceFile = ''; + $targetFile = ''; + $startTs = 0; + $restoreMode = ''; + $sourceDbName = ''; + + foreach ($lines as $lineRaw) { + $line = trim((string)$lineRaw); + if ($line === '') { + continue; + } + + if ($startTs === 0 && preg_match('/^\[([0-9:-]{19})\]/', $line, $mTs)) { + $parsed = strtotime($mTs[1]); + if ($parsed !== false) { + $startTs = (int)$parsed; + } + } + + if (stripos($line, 'Start backup job') !== false) { + $type = 'backup'; + } elseif (stripos($line, 'Start restore job') !== false) { + $type = 'restore'; + } + + if ($dbName === '' && preg_match('/Tworzenie backupu bazy\s+([a-z0-9_]+)/iu', $line, $mDb)) { + $dbName = strtolower($mDb[1]); + } + if ($dbName === '' && preg_match('/Przywracanie bazy\s+([a-z0-9_]+)/iu', $line, $mDb2)) { + $dbName = strtolower($mDb2[1]); + } + + if (preg_match('/Sanityzacja dumpa:\s*([a-z0-9_]+)\s*->\s*([a-z0-9_]+)/iu', $line, $mSan)) { + $sourceDbName = strtolower($mSan[1]); + $restoreMode = (strtolower($mSan[1]) === strtolower($mSan[2])) ? 'overwrite' : 'new_db'; + } + + if ($targetFile === '' && preg_match('/Backup zapisany:\s+(.+)$/u', $line, $mTarget)) { + $targetFile = trim($mTarget[1]); + } + if ($sourceFile === '' && preg_match('/Przywracanie bazy\s+[a-z0-9_]+\s+z pliku\s+(.+)$/iu', $line, $mSource)) { + $sourceFile = trim($mSource[1]); + } + } + + if ($type === 'unknown') { + if (str_starts_with($jobId, 'backup-') || str_starts_with($jobId, 'backup-backup-')) { + $type = 'backup'; + } elseif (str_starts_with($jobId, 'restore-') || str_starts_with($jobId, 'restore-restore-')) { + $type = 'restore'; + } + } + + if ($dbName === '' || strpos($dbName, $this->daUser->prefix()) !== 0) { + return null; + } + + return [ + 'DA_USER' => $this->daUser->username(), + 'JOB_TYPE' => $type, + 'DB_NAME' => $dbName, + 'SOURCE_FILE' => $sourceFile, + 'TARGET_FILE' => $targetFile, + 'START_TS' => $startTs > 0 ? (string)$startTs : '', + 'RESTORE_MODE' => $restoreMode, + 'SOURCE_DB_NAME' => $sourceDbName, + ]; + } +} diff --git a/exec/lib/CsrfGuard.php b/exec/lib/CsrfGuard.php new file mode 100644 index 0000000..8cff56b --- /dev/null +++ b/exec/lib/CsrfGuard.php @@ -0,0 +1,70 @@ +secret = $secret; + $this->username = $username; + $this->sessionId = $sessionId !== '' ? $sessionId : 'no_session'; + } + + /** + * @return array{ts:int,token:string} + */ + public function issue(string $intent): array + { + $ts = time(); + $sid = $this->sessionId; + return [ + 'ts' => $ts, + 'sid' => $sid, + 'token' => $this->buildToken($intent, $ts, $sid), + ]; + } + + public function validate(string $intent, string $timestamp, string $token, int $ttl = 3600, string $sessionHint = ''): bool + { + if (!preg_match('/^[0-9]{1,20}$/', $timestamp)) { + return false; + } + + $ts = (int)$timestamp; + $now = time(); + if ($ts <= 0 || $ts > ($now + 30) || ($now - $ts) > $ttl) { + return false; + } + + $token = trim($token); + $sids = []; + + $hint = trim($sessionHint); + if ($hint !== '') { + $sids[] = $hint; + } + $sids[] = $this->sessionId; + $sids[] = 'no_session'; + $sids[] = ''; + $sids = array_values(array_unique($sids)); + + foreach ($sids as $sid) { + $expected = $this->buildToken($intent, $ts, $sid); + if (hash_equals($expected, $token)) { + return true; + } + } + + return false; + } + + private function buildToken(string $intent, int $timestamp, string $sid): string + { + $payload = implode('|', [$this->username, $sid !== '' ? $sid : 'no_session', $intent, (string)$timestamp]); + return hash_hmac('sha256', $payload, $this->secret); + } +} diff --git a/exec/lib/DirectAdminUser.php b/exec/lib/DirectAdminUser.php new file mode 100644 index 0000000..49c63c8 --- /dev/null +++ b/exec/lib/DirectAdminUser.php @@ -0,0 +1,302 @@ + */ + private array $userConf; + + /** @var array */ + private array $packageConf; + + private Settings $settings; + + private function __construct(string $username, array $userConf, array $packageConf, Settings $settings) + { + $this->username = $username; + $this->prefix = $username . '_'; + $this->userConf = $userConf; + $this->packageConf = $packageConf; + $this->settings = $settings; + } + + public static function fromEnvironment(Settings $settings): self + { + $username = Http::server('USER'); + if ($username === '') { + $username = Http::server('USERNAME'); + } + + if ($username === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $username)) { + throw new RuntimeException('Nie można ustalić użytkownika DirectAdmin.'); + } + + $userConfPath = '/usr/local/directadmin/data/users/' . $username . '/user.conf'; + $userConf = self::loadSimpleConf($userConfPath); + $packageConf = self::loadPackageConf($username, $userConf); + + return new self($username, $userConf, $packageConf, $settings); + } + + public function username(): string + { + return $this->username; + } + + public function prefix(): string + { + return $this->prefix; + } + + public function language(): string + { + $lang = strtolower(trim((string)($this->userConf['language'] ?? $this->userConf['lang'] ?? 'en'))); + if ($lang === '' || strpos($lang, 'en') === 0) { + return 'en'; + } + if (strpos($lang, 'pl') === 0) { + return 'pl'; + } + return 'en'; + } + + public function skin(): string + { + $skin = strtolower(trim((string)($this->userConf['skin'] ?? 'enhanced'))); + if (strpos($skin, 'evolution') !== false) { + return 'evolution'; + } + if (strpos($skin, 'enhanced') !== false) { + return 'enhanced'; + } + return 'enhanced'; + } + + public function hasPluginAccess(): bool + { + if ($this->settings->alwaysEnabled()) { + return true; + } + + return $this->isPluginEnabledByCustomItem() && $this->isDatabaseQuotaEnabled() && $this->isPluginAllowedByPluginRules(); + } + + public function pluginAccessErrorMessage(): string + { + if (!$this->isPluginEnabledByCustomItem()) { + return 'Plugin MySQL jest wyłączony dla tego konta lub pakietu.'; + } + + if (!$this->isDatabaseQuotaEnabled()) { + return 'Plugin MySQL jest wyłączony: limit baz danych MySQL ustawiono na 0 i nie zaznaczono opcji Bez Ograniczeń.'; + } + + if (!$this->isPluginAllowedByPluginRules()) { + return 'Plugin MySQL jest zablokowany przez reguły plugins_allow/plugins_deny.'; + } + + return 'Brak dostępu do pluginu MySQL.'; + } + + public function maxDatabases(): int + { + if ($this->settings->alwaysEnabled()) { + return 0; + } + + if ($this->isUnlimitedDatabasesByCustomItem()) { + return 0; + } + + $rawValue = $this->rawDatabasesLimitValue(); + if ($rawValue === null || trim($rawValue) === '') { + $default = $this->settings->defaultDatabasesLimit(); + return $default < 0 ? 0 : $default; + } + + $normalized = strtolower(trim($rawValue)); + if ($normalized === 'unlimited') { + return 0; + } + + if (!preg_match('/^[0-9]+$/', $normalized)) { + $default = $this->settings->defaultDatabasesLimit(); + return $default < 0 ? 0 : $default; + } + + $limit = (int)$normalized; + return $limit < 0 ? 0 : $limit; + } + + private function isPluginEnabledByCustomItem(): bool + { + $raw = $this->readScopedValue('mysql_enabled'); + if ($raw === null) { + $raw = $this->readScopedValue('da_mysql_enabled'); + } + if ($raw === null) { + return false; + } + return Settings::toBool($raw, false); + } + + private function isUnlimitedDatabasesByCustomItem(): bool + { + $unlimitedFlags = [ + $this->readScopedValue('umysql'), + $this->readScopedValue('umysql_max_databases'), + $this->readScopedValue('mysql_unlimited'), + ]; + + foreach ($unlimitedFlags as $raw) { + if ($raw !== null && Settings::toBool($raw, false)) { + return true; + } + } + + $rawLimit = $this->rawDatabasesLimitValue(); + return $rawLimit !== null && strtolower(trim($rawLimit)) === 'unlimited'; + } + + private function isDatabaseQuotaEnabled(): bool + { + if ($this->isUnlimitedDatabasesByCustomItem()) { + return true; + } + + return $this->maxDatabases() > 0; + } + + private function rawDatabasesLimitValue(): ?string + { + $raw = $this->readScopedValue('mysql_max_databases'); + if ($raw === null || trim($raw) === '') { + $raw = $this->readScopedValue('mysql'); + } + return $raw; + } + + private function isPluginAllowedByPluginRules(): bool + { + $pluginId = 'alt-mysql'; + + foreach ([$this->packageConf, $this->userConf] as $conf) { + if (!empty($conf['plugins_allow'])) { + $allow = self::parsePluginsList($conf['plugins_allow']); + return in_array($pluginId, $allow, true); + } + } + + foreach ([$this->packageConf, $this->userConf] as $conf) { + if (!empty($conf['plugins_deny'])) { + $deny = self::parsePluginsList($conf['plugins_deny']); + return !in_array($pluginId, $deny, true); + } + } + + return true; + } + + private function readScopedValue(string $key): ?string + { + if (array_key_exists($key, $this->userConf)) { + return $this->userConf[$key]; + } + if (array_key_exists($key, $this->packageConf)) { + return $this->packageConf[$key]; + } + return null; + } + + /** + * @return array + */ + private static function loadSimpleConf(string $path): array + { + $data = []; + if (!is_readable($path)) { + return $data; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $data; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (!preg_match('/^([A-Za-z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $data[strtolower($m[1])] = trim((string)$m[2]); + } + + return $data; + } + + /** + * @param array $userConf + * @return array + */ + private static function loadPackageConf(string $username, array $userConf): array + { + $package = trim((string)($userConf['package'] ?? $userConf['user_package'] ?? '')); + if ($package === '' || !preg_match('/^[A-Za-z0-9._-]+$/', $package)) { + return []; + } + + $candidates = []; + $owners = []; + foreach (['creator', 'owner', 'reseller', 'username'] as $key) { + if (!empty($userConf[$key]) && preg_match('/^[A-Za-z0-9._-]+$/', $userConf[$key])) { + $owners[] = $userConf[$key]; + } + } + $owners[] = $username; + $owners[] = 'admin'; + $owners = array_values(array_unique($owners)); + + foreach ($owners as $owner) { + $candidates[] = '/usr/local/directadmin/data/users/' . $owner . '/packages/' . $package; + $candidates[] = '/usr/local/directadmin/data/users/' . $owner . '/packages/' . $package . '.conf'; + } + + foreach ($candidates as $path) { + $conf = self::loadSimpleConf($path); + if (!empty($conf)) { + return $conf; + } + } + + return []; + } + + /** + * @return string[] + */ + private static function parsePluginsList(string $value): array + { + $value = trim($value); + if ($value === '') { + return []; + } + + $items = []; + foreach (explode(':', strtolower($value)) as $item) { + $item = trim($item); + if ($item !== '') { + $items[] = $item; + } + } + + return array_values(array_unique($items)); + } +} diff --git a/exec/lib/FilePicker.php b/exec/lib/FilePicker.php new file mode 100644 index 0000000..b01ba56 --- /dev/null +++ b/exec/lib/FilePicker.php @@ -0,0 +1,536 @@ +t('Wybierz plik'); + $close = $ctx->t('Zamknij'); + $up = $ctx->t('W górę'); + $newDir = $ctx->t('Nowy katalog'); + $create = $ctx->t('Utwórz'); + $cancel = $ctx->t('Anuluj'); + $choose = $ctx->t('Wybierz'); + + $html = ''; + $html .= ''; + + return $html; + } + + public static function handleDirList(AppContext $ctx, BackupQueueService $queue, string $rootPath): bool + { + $action = strtolower($ctx->queryString('action')); + if ($action !== 'dir_list') { + return false; + } + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + $base = self::normalizeBase($rootPath); + $mode = strtolower($ctx->queryString('mode', 'dir')) === 'file' ? 'file' : 'dir'; + $requested = trim($ctx->queryString('path', $base)); + if ($requested === '') { + $requested = $base; + } + if ($requested !== '' && $requested[0] !== '/') { + $requested = '/' . $requested; + } + + $selected = ''; + if ($mode === 'file' && $requested !== '') { + $realReq = @realpath($requested); + if ($realReq !== false && is_file($realReq)) { + $selected = $realReq; + $requested = dirname($realReq); + } + } + + [$resolved, $base, $ok] = self::resolvePickerPath($requested, $base); + if (!$ok) { + self::pickerOutput(['ok' => false, 'error' => 'Nieprawidłowa ścieżka.']); + exit; + } + + if ($base !== '/' && $selected !== '') { + if (strpos($selected, $base . '/') !== 0 && $selected !== $base) { + $selected = ''; + } + } + + $dirItems = []; + $fileItems = []; + $entries = @scandir($resolved); + if (is_array($entries)) { + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $full = $resolved . '/' . $entry; + if (is_dir($full)) { + $dirItems[] = $entry; + } elseif ($mode === 'file' && is_file($full) && $queue->isAllowedSqlFile($full)) { + $fileItems[] = $entry; + } + } + } + + natcasesort($dirItems); + natcasesort($fileItems); + $items = []; + foreach ($dirItems as $name) { + $items[] = ['name' => $name, 'type' => 'dir']; + } + foreach ($fileItems as $name) { + $items[] = ['name' => $name, 'type' => 'file']; + } + + self::pickerOutput([ + 'ok' => true, + 'path' => $resolved, + 'base' => $base, + 'items' => $items, + 'selected' => $selected, + ]); + exit; + } + + public static function handleDirCreate(AppContext $ctx, string $rootPath, string $daUser): bool + { + $action = strtolower($ctx->queryString('action')); + if ($action !== 'dir_create') { + return false; + } + + while (ob_get_level() > 0) { + @ob_end_clean(); + } + + $base = self::normalizeBase($rootPath); + $parentReq = trim($ctx->queryString('path', $base)); + if ($parentReq !== '' && $parentReq[0] !== '/') { + $parentReq = '/' . $parentReq; + } + [$parent, $base, $ok] = self::resolvePickerPath($parentReq, $base); + $name = trim($ctx->queryString('name')); + if ($name === '' || $name === '.' || $name === '..' || strpos($name, '/') !== false || strpos($name, '\\') !== false) { + self::pickerOutput(['ok' => false, 'error' => 'invalid_name']); + exit; + } + if (!$ok || !is_dir($parent)) { + self::pickerOutput(['ok' => false, 'error' => 'invalid_parent']); + exit; + } + + $newPath = rtrim($parent, '/') . '/' . $name; + if ($base !== '/' && strpos($newPath, $base . '/') !== 0 && $newPath !== $base) { + self::pickerOutput(['ok' => false, 'error' => 'outside_base']); + exit; + } + if (file_exists($newPath)) { + self::pickerOutput(['ok' => false, 'error' => 'exists']); + exit; + } + if (!@mkdir($newPath, 0700, true)) { + self::pickerOutput(['ok' => false, 'error' => 'mkdir_failed']); + exit; + } + @chown($newPath, $daUser); + @chgrp($newPath, $daUser); + self::pickerOutput(['ok' => true, 'path' => $parent, 'base' => $base, 'created' => $newPath]); + exit; + } + + /** + * @param array $payload + */ + private static function pickerOutput(array $payload): void + { + $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); + if ($json === false) { + $json = json_encode(['ok' => false, 'error' => 'json_encode_failed'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + $marker = '__DA_MYSQL_PICKER_JSON__'; + header('Content-Type: text/plain; charset=UTF-8'); + echo $marker . "\n" . $json . "\n" . $marker; + } + + private static function normalizeBase(string $base): string + { + $base = trim($base); + if ($base === '') { + return '/'; + } + if ($base[0] !== '/') { + $base = '/' . $base; + } + if ($base !== '/' && substr($base, -1) === '/') { + $base = rtrim($base, '/'); + } + return $base === '' ? '/' : $base; + } + + /** + * @return array{0:string,1:string,2:bool} + */ + private static function resolvePickerPath(string $requested, string $base): array + { + $base = self::normalizeBase($base); + $candidate = trim($requested); + if ($candidate === '') { + $candidate = $base; + } + if ($candidate[0] !== '/') { + $candidate = '/' . $candidate; + } + $resolved = @realpath($candidate); + if ($resolved === false || !is_dir($resolved)) { + $resolved = @realpath($base); + } + if ($resolved === false || !is_dir($resolved)) { + return [$base, $base, false]; + } + if ($base !== '/' && strpos($resolved, $base . '/') !== 0 && $resolved !== $base) { + $resolved = @realpath($base); + } + if ($resolved === false || !is_dir($resolved)) { + return [$base, $base, false]; + } + return [$resolved, $base, true]; + } +} diff --git a/exec/lib/Http.php b/exec/lib/Http.php new file mode 100644 index 0000000..386fcdd --- /dev/null +++ b/exec/lib/Http.php @@ -0,0 +1,128 @@ + */ + private array $map; + + /** + * @param array $map + */ + private function __construct(array $map) + { + $this->map = $map; + } + + public static function load(string $code): self + { + $code = strtolower(trim($code)); + if ($code !== 'pl' && $code !== 'en') { + $code = 'en'; + } + + $path = PLUGIN_ROOT . '/lang/' . $code . '.php'; + if (!is_file($path)) { + $path = PLUGIN_ROOT . '/lang/en.php'; + } + + $map = []; + if (is_file($path)) { + $data = require $path; + if (is_array($data)) { + $map = $data; + } + } + + return new self($map); + } + + /** + * @param array $vars + */ + public function t(string $key, array $vars = []): string + { + $text = $this->map[$key] ?? $key; + foreach ($vars as $var => $value) { + $text = str_replace('{' . $var . '}', (string)$value, $text); + } + return $text; + } + + public function translateHtml(string $html): string + { + if ($html === '' || empty($this->map)) { + return $html; + } + + $keys = array_keys($this->map); + usort($keys, static function (string $a, string $b): int { + return strlen($b) <=> strlen($a); + }); + + $values = []; + foreach ($keys as $key) { + $values[] = $this->map[$key]; + } + + return str_replace($keys, $values, $html); + } + + /** + * @return string[] + */ + public function keys(): array + { + return array_keys($this->map); + } +} diff --git a/exec/lib/LocalizedException.php b/exec/lib/LocalizedException.php new file mode 100644 index 0000000..ad68eaf --- /dev/null +++ b/exec/lib/LocalizedException.php @@ -0,0 +1,30 @@ + */ + private array $vars; + + /** + * @param array $vars + */ + public function __construct(string $key, array $vars = [], int $code = 0, ?Throwable $previous = null) + { + parent::__construct($key, $code, $previous); + $this->vars = $vars; + } + + public function key(): string + { + return $this->getMessage(); + } + + /** + * @return array + */ + public function vars(): array + { + return $this->vars; + } +} diff --git a/exec/lib/MySQLService.php b/exec/lib/MySQLService.php new file mode 100644 index 0000000..e723cd1 --- /dev/null +++ b/exec/lib/MySQLService.php @@ -0,0 +1,1165 @@ + */ + 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) . '@\'%\' 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, (string)$expiresAt] + ); + } + + public function grantTemporaryAdminerAccess(string $dbName, string $role): void + { + $this->query( + 'GRANT ALL PRIVILEGES ON ' . $this->quoteIdentifier($dbName) . '.* TO ' . + $this->quoteLiteral($role) . '@\'%\'' + ); + } + + public function dropTemporaryRole(string $role): void + { + $this->tryQuery('DROP USER IF EXISTS ' . $this->quoteLiteral($role) . '@\'%\''); + $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) { + throw new RuntimeException('Nie udało się połączyć z MySQL.'); + } + + $conn->set_charset('utf8mb4'); + $this->connections[$targetDb] = $conn; + return $conn; + } +} diff --git a/exec/lib/NamePolicy.php b/exec/lib/NamePolicy.php new file mode 100644 index 0000000..158304d --- /dev/null +++ b/exec/lib/NamePolicy.php @@ -0,0 +1,182 @@ + + */ + public const PRIVILEGE_LABELS = [ + 'ALL' => 'Wszystkie uprawnienia', + 'SELECT' => 'SELECT', + 'INSERT' => 'INSERT', + 'UPDATE' => 'UPDATE', + 'DELETE' => 'DELETE', + 'CREATE' => 'CREATE', + 'ALTER' => 'ALTER', + 'DROP' => 'DROP', + 'INDEX' => 'INDEX', + 'REFERENCES' => 'REFERENCES', + 'CREATE_VIEW' => 'CREATE VIEW', + 'SHOW_VIEW' => 'SHOW VIEW', + 'TRIGGER' => 'TRIGGER', + 'EXECUTE' => 'EXECUTE', + 'EVENT' => 'EVENT', + 'LOCK_TABLES' => 'LOCK TABLES', + ]; + + public static function defaultPrivileges(): array + { + return ['ALL']; + } + + public static function normalizeDatabaseName(string $input, string $prefix): string + { + return self::normalizeIdentifier($input, $prefix, 'Nazwa bazy'); + } + + public static function normalizeRoleName(string $input, string $prefix): string + { + return self::normalizeIdentifier($input, $prefix, 'Nazwa użytkownika'); + } + + public static function assertBelongsToUser(string $name, string $prefix, string $entityLabel = 'Obiekt'): void + { + if (strpos($name, $prefix) !== 0) { + throw new InvalidArgumentException($entityLabel . ' musi zaczynać się od prefiksu: ' . $prefix); + } + } + + public static function normalizeIdentifier(string $input, string $prefix, string $label): string + { + $value = strtolower(trim($input)); + if ($value === '') { + throw new InvalidArgumentException($label . ' jest wymagana.'); + } + + if (!preg_match('/^[a-z0-9_]+$/', $value)) { + throw new InvalidArgumentException($label . ' może zawierać wyłącznie znaki a-z, 0-9 oraz _.'); + } + + if (strpos($value, $prefix) !== 0) { + $value = $prefix . $value; + } + + if (strlen($value) > self::MAX_IDENTIFIER_LEN) { + throw new InvalidArgumentException($label . ' przekracza limit 64 znaków MySQL.'); + } + + if ($value === $prefix) { + throw new InvalidArgumentException($label . ' nie może być równa samemu prefiksowi.'); + } + + return $value; + } + + /** + * @return string[] + */ + public static function parseHosts(string $raw): array + { + $raw = str_replace(["\r\n", "\r", ';'], ["\n", "\n", "\n"], $raw); + $raw = str_replace(',', "\n", $raw); + $parts = explode("\n", $raw); + + $hosts = []; + foreach ($parts as $part) { + $part = trim($part); + if ($part === '') { + continue; + } + $hosts[] = $part; + } + + return array_values(array_unique($hosts)); + } + + public static function normalizeHost(string $input, bool $allowWildcard): string + { + $host = strtolower(trim($input)); + if ($host === '') { + throw new InvalidArgumentException('Pusty host.'); + } + + if (in_array($host, ['localhost', '127.0.0.1', '::1', '%'], true)) { + return $host; + } + + if (self::isStrictIpv4($host)) { + return $host; + } + + if ($allowWildcard && preg_match('/^[A-Za-z0-9._%-]+$/', $host)) { + return $host; + } + + throw new InvalidArgumentException('Nieobsługiwany format hosta: ' . $input); + } + + public static function normalizeRemoteIpv4Host(string $input): string + { + $host = trim($input); + if ($host === '') { + throw new InvalidArgumentException('Adres IPv4 jest wymagany.'); + } + + if (!self::isStrictIpv4($host)) { + throw new InvalidArgumentException('Dozwolone są tylko pojedyncze adresy IPv4, np. 203.0.113.10.'); + } + + return $host; + } + + public static function normalizeHostComment(string $input): string + { + $comment = trim(preg_replace('/[\r\n\t]+/', ' ', $input) ?? ''); + $comment = preg_replace('/\s+/', ' ', $comment) ?? ''; + $comment = trim($comment); + + if (strlen($comment) > self::MAX_HOST_COMMENT_LEN) { + throw new InvalidArgumentException('Komentarz hosta przekracza limit ' . self::MAX_HOST_COMMENT_LEN . ' znaków.'); + } + + return $comment; + } + + /** + * @param array $input + * @return string[] + */ + public static function sanitizePrivileges(array $input): array + { + $allowed = array_keys(self::PRIVILEGE_LABELS); + $result = []; + + foreach ($input as $priv) { + $priv = strtoupper(trim((string)$priv)); + if ($priv === '' || !in_array($priv, $allowed, true)) { + continue; + } + $result[] = $priv; + } + + $result = array_values(array_unique($result)); + if (in_array('ALL', $result, true)) { + return ['ALL']; + } + + return $result; + } + + private static function isStrictIpv4(string $value): bool + { + if (!preg_match('/^(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])){3}$/', $value)) { + return false; + } + + return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false; + } +} diff --git a/exec/lib/PhpMyAdminRuntimeConfig.php b/exec/lib/PhpMyAdminRuntimeConfig.php new file mode 100644 index 0000000..5576c64 --- /dev/null +++ b/exec/lib/PhpMyAdminRuntimeConfig.php @@ -0,0 +1,55 @@ +adminerSsoDir(), '/'); + if ($runtimeDir === '') { + return; + } + + if (!is_dir($runtimeDir) && !@mkdir($runtimeDir, 0755, true) && !is_dir($runtimeDir)) { + self::log($pluginErrorLog, 'Cannot create phpMyAdmin runtime dir: ' . $runtimeDir); + return; + } + + $payload = [ + 'version' => 1, + 'phpmyadmin_url_path' => $settings->adminerUrlPath(), + 'phpmyadmin_public_base_url' => $settings->adminerPublicBaseUrl(), + 'ticket_dir' => $runtimeDir . '/tickets', + 'host' => 'dynamic:' . basename($settings->mysqlCredentialsFile()), + 'credentials_file' => $settings->mysqlCredentialsFile(), + 'client_bin_dir' => $settings->mysqlClientBinDir(), + 'updated_at' => time(), + ]; + + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); + if (!is_string($encoded) || $encoded === '') { + self::log($pluginErrorLog, 'Failed to encode phpMyAdmin runtime config JSON.'); + return; + } + + $targetPath = $runtimeDir . '/' . self::FILE_NAME; + if (@file_put_contents($targetPath, $encoded) === false) { + self::log($pluginErrorLog, 'Failed to write phpMyAdmin runtime config: ' . $targetPath); + return; + } + + @chmod($targetPath, 0644); + } + + private static function log(string $pluginErrorLog, string $message): void + { + if ($pluginErrorLog === '') { + return; + } + + $line = sprintf("[%s] PHPMYADMIN_RUNTIME_CONFIG %s\n", date('c'), $message); + @error_log($line, 3, $pluginErrorLog); + } +} diff --git a/exec/lib/PhpMyAdminSso.php b/exec/lib/PhpMyAdminSso.php new file mode 100644 index 0000000..f9da0f6 --- /dev/null +++ b/exec/lib/PhpMyAdminSso.php @@ -0,0 +1,216 @@ +settings = $settings; + $this->daUser = $daUser; + $this->db = $db; + } + + /** + * @return array{target:string,auth:array} + */ + public function issueLoginPayload(?string $requestedDatabase = null): array + { + if (!$this->settings->enableAdminer()) { + throw new RuntimeException('Integracja phpMyAdmin jest wyłączona.'); + } + + $this->db->cleanupExpiredAdminerRoles(); + + $databaseRows = $this->db->listDatabasesForUser($this->daUser->username(), false); + if (empty($databaseRows)) { + throw new RuntimeException('Użytkownik nie ma żadnej bazy MySQL do otwarcia w phpMyAdmin.'); + } + + $databaseNames = []; + foreach ($databaseRows as $row) { + $name = (string)($row['name'] ?? ''); + if ($name !== '') { + $databaseNames[] = $name; + } + } + $databaseNames = array_values(array_unique($databaseNames)); + + $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; + } + } + if ($targetDatabase === '') { + $targetDatabase = $databaseNames[0]; + } + + $roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120); + $role = $this->generateTemporaryRoleName(); + $password = $this->generateSecret(32); + $expiresAt = time() + $roleTtl; + + $this->db->createTemporaryRole($role, $password, $expiresAt); + try { + foreach ($databaseNames as $dbName) { + $this->db->grantTemporaryAdminerAccess($dbName, $role); + } + } catch (Throwable $e) { + try { + $this->db->dropTemporaryRole($role); + } catch (Throwable $ignored) { + } + throw new RuntimeException('Nie udało się przygotować sesji phpMyAdmin: ' . $e->getMessage(), 0, $e); + } + + $auth = [ + 'user' => $role, + 'password' => $password, + 'host' => $this->db->connectionEndpoint()['host'], + 'port' => $this->db->connectionEndpoint()['port'], + 'db' => $targetDatabase, + ]; + $ticket = $this->writeLoginTicket($auth, $targetDatabase); + + return [ + 'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket), + 'auth' => $auth, + ]; + } + + private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string + { + $query = http_build_query([ + 'ticket' => $ticket, + 'route' => '/database/structure', + 'db' => $database, + ]); + + return $this->phpMyAdminBaseUrl() . '/da_login.php?' . $query; + } + + private function phpMyAdminBaseUrl(): string + { + $path = $this->settings->adminerUrlPath(); + $customBase = $this->settings->adminerPublicBaseUrl(); + if ($customBase !== '') { + return $customBase . $path; + } + + $scheme = 'http'; + $https = strtolower(Http::server('HTTPS')); + if ($https !== '' && $https !== 'off' && $https !== '0') { + $scheme = 'https'; + } elseif (strtolower(Http::server('HTTP_X_FORWARDED_PROTO')) === 'https') { + $scheme = 'https'; + } elseif (strtolower(Http::server('REQUEST_SCHEME')) === 'https') { + $scheme = 'https'; + } + + $host = Http::server('HTTP_HOST'); + if ($host === '') { + $host = Http::server('SERVER_NAME', 'localhost'); + } + $host = preg_replace('/:\d+$/', '', $host) ?? $host; + + return $scheme . '://' . $host . $path; + } + + /** + * @param array $auth + */ + private function writeLoginTicket(array $auth, string $database): string + { + $ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets'; + if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) { + throw new RuntimeException('Nie można utworzyć katalogu ticketów phpMyAdmin: ' . $ticketDir); + } + + $this->cleanupExpiredTickets($ticketDir); + + $token = bin2hex(random_bytes(32)); + $payload = [ + 'version' => 1, + 'created_at' => time(), + 'expires_at' => time() + $this->settings->adminerTicketTtl(), + 'auth' => $auth, + 'route' => '/database/structure', + 'db' => $database, + ]; + $encoded = json_encode($payload, JSON_UNESCAPED_SLASHES); + if (!is_string($encoded) || $encoded === '') { + throw new RuntimeException('Nie udało się zakodować ticketu phpMyAdmin.'); + } + + $path = $ticketDir . '/' . $token . '.json'; + $tmpPath = $path . '.tmp.' . bin2hex(random_bytes(4)); + if (@file_put_contents($tmpPath, $encoded, LOCK_EX) === false) { + throw new RuntimeException('Nie udało się zapisać ticketu phpMyAdmin.'); + } + @chmod($tmpPath, 0644); + if (!@rename($tmpPath, $path)) { + @unlink($tmpPath); + throw new RuntimeException('Nie udało się aktywować ticketu phpMyAdmin.'); + } + + return $token; + } + + private function cleanupExpiredTickets(string $ticketDir): void + { + foreach (glob($ticketDir . '/*.json') ?: [] as $path) { + if (!is_file($path)) { + continue; + } + $raw = @file_get_contents($path); + $decoded = is_string($raw) ? json_decode($raw, true) : null; + $expiresAt = is_array($decoded) ? (int)($decoded['expires_at'] ?? 0) : 0; + if ($expiresAt > 0 && $expiresAt >= time()) { + continue; + } + @unlink($path); + } + } + + 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('da_tmp_phpmyadmin_') - 1 - strlen($suffix); + if ($maxBaseLen < 1) { + $maxBaseLen = 1; + } + $safeBase = substr($base, 0, $maxBaseLen); + $role = 'da_tmp_phpmyadmin_' . $safeBase . '_' . $suffix; + if (!$this->db->roleExists($role)) { + return $role; + } + } + + throw new RuntimeException('Nie udało się wygenerować unikalnego tymczasowego użytkownika dla phpMyAdmin.'); + } + + 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'); + } +} diff --git a/exec/lib/Settings.php b/exec/lib/Settings.php new file mode 100644 index 0000000..b2a0734 --- /dev/null +++ b/exec/lib/Settings.php @@ -0,0 +1,472 @@ + */ + private array $values; + + private function __construct(array $values) + { + $this->values = $values; + } + + public static function load(string $path): self + { + $values = []; + if (is_readable($path)) { + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines !== false) { + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (!preg_match('/^([A-Za-z0-9_]+)=(.*)$/', $line, $m)) { + continue; + } + + $values[$m[1]] = self::parseShValue((string)$m[2]); + } + } + } + + return new self($values); + } + + /** + * @return array{host:string,port:int,database:string,user:string,password:string,socket:string} + */ + public static function loadMysqlCredentials(string $path): array + { + if (!is_readable($path)) { + throw new RuntimeException('Brak pliku z danymi MySQL: ' . $path); + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + throw new RuntimeException('Nie można odczytać pliku: ' . $path); + } + + $kv = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#') { + continue; + } + + if (strpos($line, '=') !== false) { + [$k, $v] = array_map('trim', explode('=', $line, 2)); + if ($k !== '') { + $kv[strtoupper($k)] = self::parseShValue($v); + } + continue; + } + + $parts = explode(':', $line); + if (count($parts) >= 5) { + return [ + 'host' => trim($parts[0]) !== '' ? trim($parts[0]) : 'localhost', + 'port' => (int)(trim($parts[1]) !== '' ? trim($parts[1]) : '3306'), + 'database' => trim($parts[2]) !== '' ? trim($parts[2]) : 'mysql', + 'user' => trim($parts[3]) !== '' ? trim($parts[3]) : 'root', + 'password' => trim(implode(':', array_slice($parts, 4))), + 'socket' => '', + ]; + } + } + + $host = trim((string)($kv['HOST'] ?? $kv['MYSQL_HOST'] ?? '')); + if ($host === '') { + $host = 'localhost'; + } + + $portRaw = trim((string)($kv['PORT'] ?? $kv['MYSQL_PORT'] ?? '3306')); + $port = preg_match('/^[0-9]+$/', $portRaw) ? (int)$portRaw : 3306; + if ($port < 1 || $port > 65535) { + $port = 3306; + } + + $database = trim((string)($kv['DATABASE'] ?? $kv['MYSQL_DATABASE'] ?? $kv['DB'] ?? 'mysql')); + if ($database === '' || $database === '*') { + $database = 'mysql'; + } + + $user = trim((string)($kv['USER'] ?? $kv['MYSQL_USER'] ?? 'root')); + $password = trim((string)($kv['PASSWORD'] ?? $kv['PASSWD'] ?? $kv['MYSQL_PASSWORD'] ?? '')); + $socket = trim((string)($kv['SOCKET'] ?? $kv['MYSQL_SOCKET'] ?? '')); + + if ($user === '') { + throw new RuntimeException('Brak użytkownika MySQL w pliku: ' . $path); + } + + return [ + 'host' => $host, + 'port' => $port, + 'database' => $database, + 'user' => $user, + 'password' => $password, + 'socket' => $socket, + ]; + } + + public function mysqlCredentialsFile(): string + { + $path = trim($this->getString('MYSQL_PLUGIN_CREDENTIALS_FILE', '/usr/local/directadmin/conf/alt-mysql.conf')); + return $path !== '' ? $path : '/usr/local/directadmin/conf/alt-mysql.conf'; + } + + public function mysqlClientBinDir(): string + { + return rtrim(trim($this->getString('MYSQL_PLUGIN_CLIENT_BIN_DIR', '')), '/'); + } + + public function getString(string $key, string $default = ''): string + { + return isset($this->values[$key]) ? trim($this->values[$key]) : $default; + } + + public function getInt(string $key, int $default = 0): int + { + if (!isset($this->values[$key])) { + return $default; + } + + $value = trim($this->values[$key]); + if ($value === '' || !preg_match('/^-?[0-9]+$/', $value)) { + return $default; + } + + return (int)$value; + } + + public function getBool(string $key, bool $default = false): bool + { + if (!isset($this->values[$key])) { + return $default; + } + + return self::toBool($this->values[$key], $default); + } + + public function defaultDatabasesLimit(): int + { + $limit = $this->getInt('MYSQL_PLUGIN_DEFAULT_DB_LIMIT', 5); + return $limit < 0 ? 0 : $limit; + } + + public function alwaysEnabled(): bool + { + return $this->getBool('always_enabled', true); + } + + public function maxHostsPerUser(): int + { + $limit = $this->getInt('MYSQL_PLUGIN_MAX_HOSTS_PER_USER', 30); + return $limit < 1 ? 1 : $limit; + } + + public function allowRemoteHosts(): bool + { + if (array_key_exists('allow_remote_hosts', $this->values)) { + return self::toBool($this->values['allow_remote_hosts'], false); + } + + if (array_key_exists('MYSQL_PLUGIN_ALLOW_REMOTE_HOSTS', $this->values)) { + return self::toBool($this->values['MYSQL_PLUGIN_ALLOW_REMOTE_HOSTS'], false); + } + + return false; + } + + public function modifyServerConfiguration(): bool + { + if ($this->allowRemoteHosts()) { + return true; + } + + if (array_key_exists('modify_server_configuration', $this->values)) { + return self::toBool($this->values['modify_server_configuration'], false); + } + + if (array_key_exists('MYSQL_PLUGIN_MODIFY_SERVER_CONFIGURATION', $this->values)) { + return self::toBool($this->values['MYSQL_PLUGIN_MODIFY_SERVER_CONFIGURATION'], false); + } + + return false; + } + + public function hostSyncScript(): string + { + $default = '/usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh'; + $path = $this->getString('MYSQL_PLUGIN_SUDO_SYNC_HOSTS', $default); + return $path !== '' ? $path : $default; + } + + public function enableBackup(): bool + { + return $this->getBool('ENABLE_BACKUP', true); + } + + public function enableUploadBackup(): bool + { + return $this->getBool('ENABLE_UPLOAD_BACKUP', true); + } + + public function enableAdminer(): bool + { + if (!$this->getBool('ENABLE_PHPMYADMIN', $this->getBool('ENABLE_ADMINER', true))) { + return false; + } + + return $this->isPhpMyAdminInstalled(); + } + + public function phpMyAdminInstallDir(): string + { + $path = trim($this->getString('PHPMYADMIN_INSTALL_DIR', '/var/www/html/alt-mysql-phpmyadmin')); + return $path !== '' ? rtrim($path, '/') : '/var/www/html/alt-mysql-phpmyadmin'; + } + + public function adminerUrlPath(): string + { + $path = trim($this->getString('PHPMYADMIN_URL_PATH', $this->getString('ADMINER_URL_PATH', '/alt-mysql-phpmyadmin'))); + if ($path === '') { + return '/alt-mysql-phpmyadmin'; + } + + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $path = rtrim($path, '/'); + return $path !== '' ? $path : '/alt-mysql-phpmyadmin'; + } + + public function adminerPublicBaseUrl(): string + { + $url = trim($this->getString('PHPMYADMIN_PUBLIC_BASE_URL', $this->getString('ADMINER_PUBLIC_BASE_URL', ''))); + if ($url === '') { + return ''; + } + + $url = rtrim($url, '/'); + if (!preg_match('#^https?://#i', $url)) { + return ''; + } + + return $url; + } + + public function adminerPublic(): bool + { + return $this->getBool('PHPMYADMIN_PUBLIC', $this->getBool('ADMINER_PUBLIC', false)); + } + + public function adminerSsoDir(): string + { + $path = trim($this->getString('PHPMYADMIN_SSO_DIR', $this->getString('ADMINER_SSO_DIR', $this->phpMyAdminInstallDir() . '/runtime'))); + if ($path === '') { + $path = $this->phpMyAdminInstallDir() . '/runtime'; + } + return rtrim($path, '/'); + } + + public function adminerTicketTtl(): int + { + $ttl = $this->getInt('PHPMYADMIN_TICKET_TTL', $this->getInt('ADMINER_TICKET_TTL', 120)); + if ($ttl < 10) { + return 10; + } + if ($ttl > 300) { + return 300; + } + return $ttl; + } + + public function adminerSessionTtl(): int + { + $ttl = $this->getInt('PHPMYADMIN_SESSION_TTL', $this->getInt('ADMINER_SESSION_TTL', 900)); + if ($ttl < 60) { + return 60; + } + if ($ttl > 3600) { + return 3600; + } + return $ttl; + } + + public function adminerRoleTtl(): int + { + $ttl = $this->getInt('PHPMYADMIN_ROLE_TTL', $this->getInt('ADMINER_ROLE_TTL', 1200)); + if ($ttl < 120) { + return 120; + } + if ($ttl > 7200) { + return 7200; + } + return $ttl; + } + + public function phpMyAdminIntegrationWarning(): string + { + if (!$this->getBool('ENABLE_PHPMYADMIN', $this->getBool('ENABLE_ADMINER', true))) { + return ''; + } + + $dir = $this->phpMyAdminInstallDir(); + if (!is_dir($dir)) { + return 'Prywatna kopia phpMyAdmin dla pluginu nie jest zainstalowana. Uruchom scripts/setup/phpmyadmin_private_install.sh jako root.'; + } + + $config = $dir . '/config.inc.php'; + $signon = $dir . '/da_signon.php'; + $signonUrl = $dir . '/da_login.php'; + + if (!is_readable($config)) { + return 'Prywatny phpMyAdmin pluginu jest zainstalowany, ale nie można odczytać config.inc.php.'; + } + + if (!is_readable($signon)) { + return 'Prywatny phpMyAdmin pluginu jest zainstalowany, ale brakuje da_signon.php. Uruchom scripts/setup/phpmyadmin_repair.sh jako root.'; + } + if (!is_readable($signonUrl)) { + return 'Prywatny phpMyAdmin pluginu jest zainstalowany, ale brakuje da_login.php. Uruchom scripts/setup/phpmyadmin_repair.sh jako root.'; + } + + $contents = file_get_contents($config); + if (!is_string($contents) || strpos($contents, 'DA_MYSQL_PMA_DEFAULT_CONF') === false) { + return 'Prywatny phpMyAdmin pluginu nie ma konfiguracji alt-mysql. Uruchom scripts/setup/phpmyadmin_repair.sh jako root.'; + } + if (strpos($contents, "SignonURL'] = da_mysql_phpmyadmin_signon_url") === false) { + return 'Prywatny phpMyAdmin pluginu nie ma wymaganej konfiguracji SignonURL. Uruchom scripts/setup/phpmyadmin_repair.sh jako root.'; + } + $signonUrlContents = file_get_contents($signonUrl); + if (!is_string($signonUrlContents) || strpos($signonUrlContents, 'da_mysql_login_consume_ticket') === false) { + return 'Prywatny phpMyAdmin pluginu nie obsługuje jednorazowych ticketów SSO. Uruchom scripts/setup/phpmyadmin_repair.sh jako root.'; + } + + return ''; + } + + public function allowRestoreToOtherDatabase(): bool + { + return $this->getBool('ALLOW_RESTORE_TO_OTHER_DATABASE', true); + } + + public function enableTableCount(): bool + { + return $this->getBool('ENABLE_TABLE_COUNT', true); + } + + public function countDatabaseSize(): bool + { + return $this->getBool('COUNT_DATABASE_SIZE', true); + } + + public function compressBackup(): bool + { + return $this->getBool('COMPRESS_BACKUP', true); + } + + public function hitmeBackupLocation(string $daUser = ''): string + { + $path = $this->getString('HITME_BACKUP_LOCATION', '/home/admin/mysql_backup'); + if ($path === '') { + $path = '/home/admin/mysql_backup'; + } + + if ($daUser !== '') { + $path = str_replace(['DA_USER', 'da_user'], $daUser, $path); + } + + return rtrim($path, '/'); + } + + public function userBaseBackupDir(string $daUser = ''): string + { + $path = $this->getString('USER_BASE_BACKUP_DIR', '/home/DA_USER/mysql_backup'); + if ($path === '') { + $path = '/home/DA_USER/mysql_backup'; + } + + if ($daUser !== '') { + $path = str_replace(['DA_USER', 'da_user'], $daUser, $path); + } + + return rtrim($path, '/'); + } + + public function userBackupDateFormat(): string + { + $format = $this->getString('USER_BACKUP_DATE_FORMAT', 'd.m.Y-H.i'); + return $format !== '' ? $format : 'd.m.Y-H.i'; + } + + public function loadOrCreateSecret(string $path): string + { + if (is_readable($path)) { + $value = trim((string)file_get_contents($path)); + if ($value !== '') { + return $value; + } + } + + $secret = bin2hex(random_bytes(32)); + $dir = dirname($path); + if (!is_dir($dir)) { + @mkdir($dir, 0700, true); + } + + if (@file_put_contents($path, $secret) !== false) { + @chmod($path, 0600); + return $secret; + } + + return hash('sha256', __FILE__ . '|' . php_uname('n') . '|' . $path . '|alt-mysql'); + } + + public static function toBool(string $value, bool $default = false): bool + { + $v = strtolower(trim($value)); + if ($v === '') { + return $default; + } + + if (in_array($v, ['1', 'true', 'yes', 'y', 'on', 'checked'], true)) { + return true; + } + + if (in_array($v, ['0', 'false', 'no', 'n', 'off', 'unchecked'], true)) { + return false; + } + + return $default; + } + + private function isPhpMyAdminInstalled(): bool + { + $dir = $this->phpMyAdminInstallDir(); + return is_dir($dir) && is_readable($dir . '/index.php') && is_readable($dir . '/config.inc.php'); + } + + private static function parseShValue(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + if ($value[0] === '\'' && substr($value, -1) === '\'') { + return substr($value, 1, -1); + } + + if ($value[0] === '"' && substr($value, -1) === '"') { + return stripcslashes(substr($value, 1, -1)); + } + + $value = preg_replace('/\s+#.*$/', '', $value); + return trim((string)$value); + } +} diff --git a/hooks/user_img.html b/hooks/user_img.html new file mode 100644 index 0000000..1ccde3e --- /dev/null +++ b/hooks/user_img.html @@ -0,0 +1 @@ +MySQL diff --git a/hooks/user_txt.html b/hooks/user_txt.html new file mode 100644 index 0000000..436f696 --- /dev/null +++ b/hooks/user_txt.html @@ -0,0 +1 @@ +Bazy danych MySQL diff --git a/images/user_icon.svg b/images/user_icon.svg new file mode 100644 index 0000000..b43b7c0 --- /dev/null +++ b/images/user_icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/lang/en.php b/lang/en.php new file mode 100644 index 0000000..00836fb --- /dev/null +++ b/lang/en.php @@ -0,0 +1,462 @@ + 'Dashboard', + 'Bazy danych' => 'Databases', + 'Wersja serwera MySQL' => 'MySQL server version', + 'Powrót' => 'Back', + 'Błąd' => 'Error', + 'Dostęp zablokowany' => 'Access blocked', + 'Włącz plugin przez custom package items: mysql_enabled oraz ustaw limity.' => 'Enable the plugin via custom package items: mysql_enabled and set limits.', + 'Plugin MySQL jest wyłączony dla tego konta lub pakietu.' => 'MySQL plugin is disabled for this account or package.', + 'Plugin MySQL jest wyłączony: limit baz danych MySQL ustawiono na 0 i nie zaznaczono opcji Bez Ograniczeń.' + => 'MySQL plugin is disabled: MySQL databases limit is set to 0 and Unlimited option is not enabled.', + 'Plugin MySQL jest zablokowany przez reguły plugins_allow/plugins_deny.' => 'MySQL plugin is blocked by plugins_allow/plugins_deny rules.', + 'Brak dostępu do pluginu MySQL.' => 'No access to MySQL plugin.', + 'Bazy danych MySQL nie są dostępne dla Twojego konta - skontaktuj się z pomocą techniczną aby uzyskać pomoc i więcej informacji' + => 'MySQL databases are not available for your account. Contact technical support for help and more information.', + 'Nie można ustalić użytkownika DirectAdmin.' => 'Cannot determine DirectAdmin user.', + 'ZARZĄDZAJ BAZAMI DANYCH' => 'MANAGE DATABASES', + 'ZARZĄDZAJ UŻYTKOWNIKAMI' => 'MANAGE USERS', + 'WGRAJ BACKUP' => 'UPLOAD BACKUP', + 'Użytkownicy baz danych' => 'Database users', + 'Backupy Baz danych' => 'Database backups', + 'PHPMYADMIN' => 'PHPMYADMIN', + 'PHPMYADMIN' => 'PHPMYADMIN', + + 'Zarządzanie bazami danych MySQL' => 'MySQL database management', + 'Lista baz danych' => 'Database list', + 'Utwórz bazę danych' => 'Create database', + 'Zarządzaj bazą danych' => 'Manage database', + 'Zarządzaj użytkownikami' => 'Manage users', + 'Brak baz danych do zarządzania.' => 'No databases to manage.', + 'Zarządzanie hasłami' => 'Password management', + 'Dostęp do baz danych' => 'Database access', + 'Dostęp użytkownika' => 'User access', + 'Przywileje' => 'Privileges', + 'Akcje' => 'Actions', + 'Akcja' => 'Action', + 'Baza' => 'Database', + 'Baza danych' => 'Database', + 'Nazwa bazy danych' => 'Database name', + 'Nazwa użytkownika' => 'Username', + 'Użytkownik' => 'User', + 'Użytkownik MySQL' => 'MySQL user', + 'Rozmiar' => 'Size', + 'Użytkownicy' => 'Users', + 'Liczba Tabel' => 'Table count', + 'Dozwolone hosty' => 'Allowed hosts', + 'wyłączone' => 'disabled', + 'Host' => 'Host', + 'Komentarz' => 'Comment', + 'Komentarz hosta' => 'Host comment', + 'Komentarz hosta (opcjonalnie)' => 'Host comment (optional)', + 'Komentarz do nowego hosta' => 'Comment for new host', + 'Wybierz' => 'Select', + 'Wybierz plik' => 'Choose file', + 'Zamknij' => 'Close', + 'W górę' => 'Up', + 'Nowy katalog' => 'New folder', + 'Utwórz' => 'Create', + 'Anuluj' => 'Cancel', + 'Przełącz' => 'Switch', + + 'Wykonaj Backup' => 'Create backup', + 'Pobierz Backup' => 'Download backup', + 'Usuń Backup' => 'Delete backup', + 'Usuń logi' => 'Delete logs', + 'Usuń bazę' => 'Delete database', + 'Usuń' => 'Delete', + 'Zmień hasło' => 'Change password', + 'Zapisz zmiany' => 'Save changes', + 'Zapisz hosty' => 'Save hosts', + 'Przywróć' => 'Restore', + 'Pokaż' => 'Show', + 'Pokaż logi' => 'Show logs', + 'Pobierz plik backupu' => 'Download backup file', + 'Pobieranie...' => 'Downloading...', + 'Nieudana odpowiedź serwera ({code})' => 'Server returned an error ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})' => 'Server returned HTML instead of file (URL: {url})', + 'Serwer zwrócił pusty plik.' => 'Server returned an empty file.', + 'Nie można pobrać pliku backupu.' => 'Cannot download backup file.', + 'Wysyłanie pliku...' => 'Uploading file...', + 'Nie udało się wysłać pliku backupu.' => 'Failed to upload backup file.', + 'Wybierz plik .sql/.gz/.sql.gz lub wskaż ścieżkę pliku na serwerze.' => 'Select a .sql/.gz/.sql.gz file or provide a server file path.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.' => 'Provide a path to a SQL or SQL.GZ file.', + 'Wybierz plik .sql/.gz/.sql.gz do przywrócenia.' => 'Select a .sql/.gz/.sql.gz file to restore.', + 'Nie udało się przygotować pliku do przywrócenia. Wybierz plik ponownie.' => 'Failed to prepare file for restore. Select the file again.', + + 'Przywróć Backup' => 'Restore Backup', + 'Lokalizacja pliku' => 'File location', + 'Przesyłanie pliku z komputera' => 'Upload file from computer', + 'Wskaż plik na koncie użytkownika' => 'Select file in user account', + 'Po dodaniu pliku zostanie on przesłany na serwer i dodany do kolejki przywracania.' => 'After selecting a file, it will be uploaded to the server and queued for restore.', + 'Dozwolone są tylko ścieżki z katalogu /home/{user}.' => 'Only paths from /home/{user} are allowed.', + 'Przeciągnij plik backupu tutaj' => 'Drag and drop the backup file here', + 'lub kliknij, aby wybrać plik' => 'or click to choose a file', + 'Plik' => 'File', + 'Rozmiar' => 'Size', + 'Usuń plik' => 'Remove file', + 'Backupy HITME.PL' => 'HITME.PL backups', + 'Backupy użytkownika' => 'User backups', + 'Backupy lokalne' => 'Local backups', + 'Backupy z pliku' => 'Backups from file', + 'Źródło: {source} (backupy z mysql_backup.sh)' => 'Source: {source} (mysql_backup.sh backups)', + 'Data:' => 'Date:', + 'Data i godzina' => 'Date and time', + 'Baza źródłowa' => 'Source database', + 'Plik' => 'File', + 'Baza docelowa' => 'Target database', + 'Kolejka zadań i logi' => 'Jobs queue and logs', + 'ID zadania' => 'Job ID', + 'Typ' => 'Type', + 'Rodzaj operacji' => 'Operation type', + 'Typ przywracania' => 'Restore type', + 'Tworzenie kopii zapasowej' => 'Backup creation', + 'Przywracanie kopii zapasowej' => 'Backup restore', + 'Przywracanie z nadpisaniem' => 'Restore with overwrite', + 'Przywracanie do nowej bazy' => 'Restore to new database', + 'Nie dotyczy' => 'Not applicable', + 'Status' => 'Status', + 'Start' => 'Start', + 'Aktualizacja' => 'Updated', + 'Podgląd logu' => 'View log', + 'Log zadania: {id}' => 'Job log: {id}', + 'Log zdarzenia' => 'Event log', + 'Data rozpoczęcia' => 'Start date', + 'Nazwa bazy' => 'Database name', + 'Akcja' => 'Action', + 'Log zadania' => 'Job log', + 'Kopiuj' => 'Copy', + 'Skopiowano' => 'Copied', + 'Oczekuje' => 'Pending', + 'W toku' => 'In progress', + 'Zakończone OK' => 'Completed OK', + 'Błąd' => 'Error', + 'Anulowane' => 'Canceled', + + 'Szczegóły wykonywania backupu' => 'Backup details', + 'Szczegóły zadania backupu' => 'Backup job details', + 'Na tej bazie trwa już operacja backup/restore' => 'A backup/restore operation is already running for this database', + 'Akcja:' => 'Action:', + 'Status:' => 'Status:', + 'Czas rozpoczęcia:' => 'Start time:', + 'Ostatnia aktualizacja:' => 'Last update:', + 'Logi zadania' => 'Job logs', + 'Log jest niedostępny dla tego zadania.' => 'Log is not available for this job.', + 'Szczegóły' => 'Details', + + 'Brak plików .sql/.gz' => 'No .sql/.gz files', + 'Brak katalogów' => 'No folders', + 'Nie udało się wczytać katalogów.' => 'Failed to load directories.', + 'Podaj nazwę katalogu.' => 'Enter folder name.', + 'Nie udało się utworzyć katalogu.' => 'Failed to create folder.', + + 'Obsługa przywracania backupów jest wyłączona (`ENABLE_UPLOAD_BACKUP=false`).' => 'Backup restore is disabled (`ENABLE_UPLOAD_BACKUP=false`).', + 'Dodano zadanie przywracania backupu lokalnego (ID: {id}).' => 'Local backup restore queued (ID: {id}).', + 'Dodano zadanie przywracania backupu z pliku (ID: {id}).' => 'File restore queued (ID: {id}).', + 'Brak backupów lokalnych dla wybranej daty.' => 'No local backups for the selected date.', + 'Brak baz danych do wyboru.' => 'No databases available for selection.', + 'Wybierz bazę docelową przy wybranym backupie.' => 'Select the target database for the chosen backup.', + 'Tryb przywracania' => 'Restore mode', + 'Nadpisanie istniejącej bazy danych (obecne działanie)' => 'Overwrite existing database (current behavior)', + 'Przywrócenie wskazanej bazy do nowej bazy danych' => 'Restore to a new database', + 'Nazwa docelowej bazy danych' => 'Target database name', + 'Wskaż docelową bazę danych.' => 'Provide a target database.', + 'Docelowa baza danych musi być inna niż źródłowa baza z backupu.' => 'Target database must be different from the source backup database.', + 'Wskazana docelowa baza danych nie istnieje. Utwórz ją ręcznie w DirectAdmin.' => 'The target database does not exist. Create it manually in DirectAdmin.', + 'Docelowa baza danych nie jest pusta. Usuń dane przed kontynuowaniem.' => 'Target database is not empty. Remove data before continuing.', + 'Nie można ustalić bazy z nazwy pliku.' => 'Cannot determine database name from the file name.', + 'Nie można ustalić docelowej bazy dla backupu lokalnego.' => 'Cannot determine target database for local backup.', + 'Wskaż plik `.sql`, `.gz` lub `.sql.gz` z komputera albo podaj ścieżkę pliku na serwerze (w katalogu `/home/{user}`).' + => 'Select a `.sql`, `.gz` or `.sql.gz` file from your computer or provide a server path (in `/home/{user}`).', + 'Plik backupu (.sql, .gz lub .sql.gz)' => 'Backup file (.sql, .gz or .sql.gz)', + 'Ścieżka pliku SQL / SQL.GZ (opcjonalnie)' => 'SQL/SQL.GZ file path (optional)', + 'Ścieżka pliku SQL / SQL.GZ' => 'SQL/SQL.GZ file path', + 'Dodaj przywracanie do kolejki' => 'Queue restore job', + 'Brak zadań backup/restore do wyświetlenia.' => 'No backup/restore jobs to display.', + 'Brak backupów HITME.PL dla wybranej daty.' => 'No HITME.PL backups for the selected date.', + 'Brak backupów użytkownika dla wybranej daty.' => 'No user backups for the selected date.', + 'Kalendarz backupów' => 'Backup calendar', + 'Wybierz aktywną datę w kalendarzu.' => 'Select an active date in the calendar.', + 'Wybierz godzinę backupu' => 'Select backup time', + 'Usunięto log zadania backupu.' => 'Backup job log deleted.', + 'Usunięto plik backupu: {path}' => 'Backup file deleted: {path}', + 'Czy na pewno chcesz usunąć backup bazy danych {db} z dn. {date}?' + => 'Are you sure you want to delete the backup of database {db} from {date}?', + 'Poprzedni miesiąc' => 'Previous month', + 'Następny miesiąc' => 'Next month', + 'Potwierdzenie operacji' => 'Confirm operation', + 'Tak, kontynuuj' => 'Yes, continue', + 'Nie' => 'No', + 'nadpisując obecne dane' => 'overwriting current data', + 'Czy na pewno chcesz przywrócić kopię zapasową bazy danych {source} z dn. {date} {overwrite} znajdujące się w bazie danych {target}?' + => 'Are you sure you want to restore the backup of database {source} from {date}, {overwrite}, in database {target}?', + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z dn. {date} do innej bazy danych o nazwie {target}?' + => 'Are you sure you want to restore data from the backup of database {source} from {date} into another database named {target}?', + 'Czy na pewno chcesz przywrócić dane z kopii zapasowej bazy danych {source} z pliku {file} do innej bazy danych o nazwie {target}?' + => 'Are you sure you want to restore data from the backup of database {source} from file {file} into another database named {target}?', + 'Uwaga: dane znajdujące się obecnie w bazie danych {target} zostaną usunięte i nadpisane danymi z kopii zapasowej dla bazy {source}.' + => 'Warning: the data currently in database {target} will be deleted and overwritten with the backup data for database {source}.', + 'Czy na pewno chcesz przywrócić plik kopii zapasowej {file} do bazy danych {target}?' + => 'Are you sure you want to restore backup file {file} to database {target}?', + 'Uwaga: jeśli baza danych {target} zawiera dane, zostaną one nadpisane.' + => 'Warning: if database {target} already contains data, it will be overwritten.', + 'Zadanie przywrócenia kopii zapasowej {file} do bazy danych {db} zostało dodane do kolejki' + => 'Restore job for backup file {file} to database {db} has been added to the queue.', + + 'Szczegółowe informacje o bazie danych.' => 'Detailed database information.', + 'Szczegółowe informacje o użytkowniku.' => 'Detailed user information.', + 'Domyślne kodowanie znaków' => 'Default character encoding', + 'Domyślne porównywanie znaków' => 'Default collation', + 'Widoki' => 'Views', + 'Wyzwalacze' => 'Triggers', + 'Rutyny i procedury' => 'Routines and procedures', + + 'Lista użytkowników, którzy mają dostęp do tej bazy danych wraz z podsumowaniem uprawnień.' + => 'List of users with access to this database and their privileges summary.', + 'Przyznaj dostęp kolejnemu użytkownikowi' => 'Grant access to another user', + '+ Przyznaj pełny dostęp' => '+ Grant full access', + 'Przywileje użytkownika:' => 'User privileges:', + 'Brak użytkowników z dostępem do bazy.' => 'No users with access to this database.', + + 'Baza:' => 'Database:', + 'Użytkownik:' => 'User:', + 'Nazwa użytkownika:' => 'Username:', + 'Nowe hasło' => 'New password', + 'Wpisz nowe hasło' => 'Enter new password', + 'Generuj hasło' => 'Generate password', + 'Pokaż lub ukryj hasło' => 'Show or hide password', + 'Dostęp do baz danych' => 'Database access', + 'Przyznaj dostęp do kolejnej bazy danych' => 'Grant access to another database', + 'Przyznaj pełny dostęp' => 'Grant full access', + 'Dodaj użytkownika' => 'Add user', + 'Utwórz nowego użytkownika MySQL' => 'Create a new MySQL user', + 'Utwórz użytkownika' => 'Create user', + 'Przypisz do baz danych (opcjonalnie)' => 'Assign to databases (optional)', + 'Możesz pozostawić użytkownika bez przypisania do baz i nadać dostęp później.' + => 'You can leave the user without database assignments and grant access later.', + 'Brak użytkowników MySQL. Dodaj pierwszego użytkownika.' + => 'No MySQL users. Add the first user.', + 'Aby zarządzać użytkownikami najpierw utwórz użytkownika MySQL.' + => 'To manage users, first create a MySQL user.', + 'Dopuszczone hosty' => 'Allowed hosts', + 'Zezwalaj na dostęp z' => 'Allow access from', + 'Dodaj IPv4' => 'Add IPv4', + 'Dodatkowy host IPv4' => 'Additional IPv4 host', + 'Dodatkowy host IPv4 (opcjonalnie)' => 'Additional IPv4 host (optional)', + 'Komentarz hosta (opcjonalnie)' => 'Host comment (optional)', + 'Komentarz' => 'Comment', + '+ Dodaj host' => '+ Add host', + 'host stały' => 'fixed host', + 'Brak dozwolonych hostów.' => 'No allowed hosts.', + 'Brak przypisanych baz danych.' => 'No assigned databases.', + + 'Uprawnienia' => 'Privileges', + 'Uprawnienia początkowe' => 'Initial privileges', + 'Uprawnienia dla przypisywanych baz' => 'Privileges for assigned databases', + 'Domyślny dostęp' => 'Default access', + '`localhost` jest dodawany automatycznie.' => '`localhost` is added automatically.', + 'Brak baz do przypisania.' => 'No databases to assign.', + 'Hosty zdalne są wyłączone w konfiguracji pluginu (`allow_remote_hosts=0`).' => 'Remote hosts are disabled in plugin configuration (`allow_remote_hosts=0`).', + 'Brak przypisań' => 'No assignments', + + 'Tryb prosty tworzy użytkownika o tej samej nazwie co baza i automatycznie generuje hasło.' + => 'Simple mode creates a user with the same name as the database and generates a password automatically.', + 'Użytkownik o tej samej nazwie i bezpiecznym haśle zostanie wygenerowany automatycznie.' + => 'A user with the same name and a secure password will be generated automatically.', + 'Tryb zaawansowany' => 'Advanced mode', + 'Nowy użytkownik' => 'New user', + 'Istniejący użytkownik' => 'Existing user', + 'Istniejący użytkownik' => 'Existing user', + 'Nazwa użytkownika MySQL' => 'MySQL username', + 'Hasło użytkownika' => 'User password', + 'W trybie zaawansowanym hasło jest wymagane.' => 'Password is required in advanced mode.', + 'UTWÓRZ' => 'CREATE', + + 'Dane dostępowe do bazy danych' => 'Database access credentials', + 'Dane dostępowe użytkownika MySQL' => 'MySQL user access credentials', + 'Nazwa hosta:' => 'Host name:', + 'Baza danych:' => 'Database:', + 'Nazwa użytkownika:' => 'Username:', + 'Hasło:' => 'Password:', + 'Przypisane bazy danych:' => 'Assigned databases:', + + 'Wykorzystano limit baz danych (' => 'Database limit reached (', + 'Bez ograniczeń' => 'Unlimited', + 'Nie znaleziono baz danych...' => 'No databases found...', + 'Ilość baz danych' => 'Database count', + 'Lista wszystkich baz danych wraz z liczbą użytkowników' => 'List of all databases with user count', + ' i rozmiarem bazy' => ' and database size', + ' oraz liczbą tabel' => ' and table count', + 'Backupy wykonywane przez użytkownika zapisywane są w: ' => 'User backups are stored in: ', + + 'Czy na pewno chcesz usunąć backup bazy danych - ' => 'Are you sure you want to delete the database backup - ', + 'Czy na pewno chcesz usunąć użytkownika ' => 'Are you sure you want to delete user ', + 'Potwierdź usunięcie backupu' => 'Confirm backup deletion', + 'Czy na pewno chcesz usunąć następującą bazę danych - ' => 'Are you sure you want to delete the following database - ', + 'Do bazy danych ' => 'For database ', + 'przypisani są użytkownicy, z poniższej listy wybierz użytkowników, których chcesz usunąć.' + => 'there are users assigned. Select which users you want to delete from the list below.', + 'Wybierz' => 'Select', + 'Przypisane Bazy danych' => 'Assigned databases', + 'Do wskazanej bazy nie są przypisani użytkownicy MySQL.' => 'No MySQL users are assigned to the selected database.', + 'Potwierdź usunięcie bazy' => 'Confirm database deletion', + 'Uwaga: wskazany użytkownik jest przypisany do wielu baz danych i jego usunięcie spowoduje brak możliwości dostępu do innych baz danych przy użyciu danych logowania użytkownika ' + => 'Warning: the selected user is assigned to multiple databases and deleting this user will remove access to other databases using the credentials of user ', + 'Uwaga użytkownik ' => 'Warning: user ', + ' jest przypisany do następującej bazy danych ' => ' is assigned to the following database ', + ' jest przypisany do następujących baz danych:' => ' is assigned to the following databases:', + + 'Usuwanie Użytkowników' => 'Delete users', + 'Brak użytkowników do usunięcia.' => 'No users to delete.', + 'Przypisane bazy' => 'Assigned databases', + 'Bazy, których jest właścicielem' => 'Databases owned', + 'Usuń zaznaczonych użytkowników' => 'Delete selected users', + 'Usuń użytkownika' => 'Delete user', + 'Potwierdzenie Usuwania Użytkowników' => 'Confirm user deletion', + 'Brak poprawnych użytkowników do usunięcia.' => 'No valid users to delete.', + 'Potwierdź usunięcie zaznaczonych użytkowników MySQL. Operacja jest nieodwracalna.' + => 'Confirm deletion of selected MySQL users. This action is irreversible.', + 'Potwierdź usunięcie' => 'Confirm deletion', + 'Potwierdź usunięcie użytkownika' => 'Confirm user deletion', + + 'Usuwanie Baz Danych' => 'Delete databases', + 'Brak baz do usunięcia.' => 'No databases to delete.', + 'Przypisani użytkownicy' => 'Assigned users', + 'Usuń zaznaczone bazy' => 'Delete selected databases', + 'Potwierdzenie Usuwania Baz Danych' => 'Confirm database deletion', + 'Brak poprawnych baz do usunięcia.' => 'No valid databases to delete.', + 'Potwierdź usunięcie baz danych. Operacja jest nieodwracalna.' => 'Confirm deletion of databases. This action is irreversible.', + 'Usuń także użytkowników przypisanych do usuwanych baz (jeżeli po usunięciu baz nie mają już innych przypisań)' + => 'Also delete users assigned to deleted databases (if they have no other assignments after deletion)', + + 'Przypisywanie Bazy do Użytkownika' => 'Assign database to user', + '-- wybierz bazę --' => '-- select database --', + '-- wybierz użytkownika --' => '-- select user --', + '-- wybierz nowego użytkownika --' => '-- select new user --', + '-- wybierz istniejącego użytkownika --' => '-- select existing user --', + 'Przypisz bazę' => 'Assign database', + + 'Hosty Zdalne Użytkownika' => 'User remote hosts', + 'Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).' + => 'Remote host management is disabled (`allow_remote_hosts=0`).', + 'Informacja' => 'Information', + 'Dozwolone są wyłącznie pojedyncze adresy IPv4, bez CIDR i bez nazw hostów.' + => 'Only single IPv4 addresses are allowed, without CIDR and without hostnames.', + 'Aktualne hosty' => 'Current hosts', + 'Brak zapisanych hostów.' => 'No hosts saved.', + 'usuń' => 'remove', + + 'Nieprawidłowy lub wygasły token CSRF.' => 'Invalid or expired CSRF token.', + 'Wskaż ścieżkę do pliku SQL lub SQL.GZ.' => 'Provide a path to SQL or SQL.GZ file.', + 'Nie można odczytać pliku: ' => 'Cannot read file: ', + 'Ścieżka pliku musi znajdować się w katalogu /home/' => 'File path must be under /home/', + 'Dozwolone są wyłącznie pliki .sql, .gz lub .sql.gz.' => 'Only .sql, .gz or .sql.gz files are allowed.', + 'Nie można utworzyć katalogu: ' => 'Cannot create directory: ', + 'Nie można utworzyć katalogu upload: ' => 'Cannot create upload directory: ', + 'Nie udało się skopiować pliku do kolejki restore.' => 'Failed to copy file to restore queue.', + 'Nie udało się zapisać przesłanego pliku do kolejki restore.' => 'Failed to save uploaded file to restore queue.', + 'Nie udało się przesłać pliku backupu (kod błędu: ' => 'Upload failed (error code: ', + 'Nie można odczytać przesłanego pliku backupu.' => 'Cannot read uploaded backup file.', + 'Wybierz plik .sql, .gz lub .sql.gz do przywrócenia.' => 'Select a .sql, .gz or .sql.gz file to restore.', + 'Tryb przesyłania został zaktualizowany. Odśwież stronę i spróbuj ponownie.' => 'Upload mode has been updated. Refresh the page and try again.', + 'Nie znaleziono zadania lub brak dostępu.' => 'Job not found or access denied.', + 'Nieprawidłowy identyfikator zadania.' => 'Invalid job ID.', + 'Nie znaleziono zadania.' => 'Job not found.', + 'Nie znaleziono logu.' => 'Log not found.', + 'Nie udało się połączyć z MySQL.' => 'Failed to connect to MySQL.', + 'Nie znaleziono bazy danych: ' => 'Database not found: ', + 'Brak uprawnień do nadania.' => 'No privileges to grant.', + 'Nieprawidłowy identyfikator MySQL: ' => 'Invalid MySQL identifier: ', + 'Identyfikator MySQL przekracza 63 znaki: ' => 'MySQL identifier exceeds 63 characters: ', + 'Nie udało się bezpiecznie zacytować wartości SQL.' => 'Failed to safely quote SQL value.', + 'Niepoprawny format pliku danych MySQL: ' => 'Invalid MySQL credentials file format: ', + 'Brak pliku z danymi MySQL: ' => 'Missing MySQL credentials file: ', + + 'Brak identyfikatora zadania backupu.' => 'Missing backup job ID.', + 'Plik backupu nie istnieje na serwerze.' => 'Backup file does not exist on the server.', + 'Brak uprawnień odczytu pliku backupu.' => 'No read permission for backup file.', + 'Nie można pobrać backupu (nagłówki zostały już wysłane).' => 'Cannot download backup (headers already sent).', + 'Nie można odczytać pliku backupu do pobrania.' => 'Cannot read backup file for download.', + 'Tworzenie backupu jest wyłączone w konfiguracji pluginu.' => 'Backup creation is disabled in plugin configuration.', + 'Wskazana baza danych nie należy do konta: ' => 'Selected database does not belong to this account: ', + 'Osiągnięto limit baz danych dla konta (' => 'Database limit reached (', + 'Baza danych już istnieje: ' => 'Database already exists: ', + 'Wybrany użytkownik MySQL nie istnieje: ' => 'Selected MySQL user does not exist: ', + 'Użytkownik MySQL już istnieje: ' => 'MySQL user already exists: ', + 'Hasło użytkownika jest wymagane w trybie zaawansowanym.' => 'Password is required in advanced mode.', + 'Hasło użytkownika musi mieć co najmniej 12 znaków.' => 'Password must be at least 12 characters.', + 'Nowe hasło musi mieć minimum 12 znaków.' => 'New password must be at least 12 characters.', + 'Przekroczono limit hostów dla użytkownika MySQL.' => 'Host limit exceeded for MySQL user.', + 'Host lokalny nie może zostać usunięty.' => 'Local host cannot be removed.', + 'Baza i uprawnienia zapisane, ale synchronizacja hostów MySQL nie powiodła się: ' + => 'Database and privileges saved, but MySQL host synchronization failed: ', + 'Baza danych została utworzona.' => 'Database has been created.', + 'Kopia zapasowa bazy danych ' => 'Backup of database ', + ' została dodana do kolejki' => ' has been added to the queue', + 'Nie wskazano bazy danych do usunięcia.' => 'No database selected for deletion.', + 'Usuwanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' + => 'Deletion completed, but MySQL host synchronization failed: ', + 'Usunięto bazę danych ' => 'Deleted database ', + ' i użytkownika ' => ' and user ', + ' i następujących użytkowników przypisanych do bazy danych ' + => ' and the following users assigned to database ', + 'Usunięto bazę danych: ' => 'Database deleted: ', + 'Usunięto użytkowników: ' => 'Users deleted: ', + 'Nie udało się usunąć części użytkowników: ' => 'Failed to delete some users: ', + 'Nie można odczytać statusów zadań backup/restore: ' => 'Cannot read backup/restore job statuses: ', + 'Wskazane zadanie nie jest zadaniem backupu.' => 'Selected job is not a backup job.', + 'Backup nie został zakończony powodzeniem.' => 'Backup did not complete successfully.', + 'nieznana baza' => 'unknown database', + 'Usunięto plik backupu: ' => 'Deleted backup file: ', + 'Nie można pobrać backupu: ' => 'Cannot download backup: ', + 'Brak dostępu do pluginu.' => 'No access to plugin.', + 'Brak parametru job.' => 'Missing job parameter.', + 'Plik backupu nie istnieje lub brak odczytu.' => 'Backup file does not exist or is not readable.', + 'Nagłówki zostały już wysłane.' => 'Headers have already been sent.', + + 'Baza nie należy do konta DA: ' => 'Database does not belong to the DirectAdmin account: ', + 'Baza nie należy do bieżącego użytkownika DA: ' => 'Database does not belong to current DirectAdmin user: ', + 'Użytkownik MySQL nie należy do konta DA: ' => 'MySQL user does not belong to the DirectAdmin account: ', + 'Użytkownik MySQL nie należy do bieżącego konta DA: ' => 'MySQL user does not belong to current DirectAdmin account: ', + 'Nieprawidłowy użytkownik MySQL: ' => 'Invalid MySQL user: ', + 'Wybierz przynajmniej jedno uprawnienie.' => 'Select at least one privilege.', + 'Odebrano dostęp użytkownikowi ' => 'Revoked access for user ', + 'Przyznano dostęp użytkownikowi ' => 'Granted access to user ', + 'Zaktualizowano uprawnienia użytkownika ' => 'Updated privileges for user ', + 'Przypisanie wykonane, ale synchronizacja hostów MySQL nie powiodła się: ' + => 'Assignment completed, but MySQL host synchronization failed: ', + 'Przypisano bazę ' => 'Assigned database ', + + 'Utworzono użytkownika ' => 'Created user ', + ' bez przypisanych baz danych.' => ' without assigned databases.', + 'Usunięto użytkownika ' => 'Deleted user ', + 'Nie można usunąć użytkownika, ponieważ jest właścicielem baz: ' + => 'Cannot delete user because the user owns databases: ', + 'Użytkownik został utworzony, ale synchronizacja hostów MySQL nie powiodła się: ' + => 'User was created, but MySQL host synchronization failed: ', + 'Brak użytkowników MySQL do zarządzania.' => 'No MySQL users to manage.', + + 'Przyznano dostęp do bazy ' => 'Granted access to database ', + 'Odebrano dostęp do bazy ' => 'Revoked access to database ', + 'Dodano host ' => 'Added host ', + 'Usunięto host ' => 'Removed host ', + 'Host dodany, ale synchronizacja hostów MySQL nie powiodła się: ' => 'Host added, but MySQL host synchronization failed: ', + 'Host usunięty, ale synchronizacja hostów MySQL nie powiodła się: ' => 'Host removed, but MySQL host synchronization failed: ', + + 'Nie wybrano użytkowników do usunięcia.' => 'No users selected for deletion.', + 'Nie usunięto: ' => 'Not deleted: ', + + 'Nie wybrano baz danych do usunięcia.' => 'No databases selected for deletion.', + 'Pominięto bazę spoza konta: ' => 'Skipped database outside account: ', + 'Operacja wykonana, ale synchronizacja hostów MySQL nie powiodła się: ' + => 'Operation completed, but MySQL host synchronization failed: ', + 'Usunięto bazy: ' => 'Databases deleted: ', + 'Usunięto użytkowników powiązanych z usuniętymi bazami: ' + => 'Users linked to deleted databases were removed: ', + 'Użytkownicy pozostawieni: ' => 'Users kept: ', + + 'Zarządzanie hostami zdalnymi jest wyłączone (`allow_remote_hosts=0`).' => 'Remote host management is disabled (`allow_remote_hosts=0`).', + 'Hosty zapisane, ale synchronizacja hostów MySQL nie powiodła się: ' => 'Hosts saved, but MySQL host synchronization failed: ', + 'Zaktualizowano hosty dostępu dla użytkownika ' => 'Updated access hosts for user ', +]; diff --git a/lang/pl.php b/lang/pl.php new file mode 100644 index 0000000..f5be84d --- /dev/null +++ b/lang/pl.php @@ -0,0 +1,50 @@ + 'Pulpit', + 'Bazy danych' => 'Bazy danych', + 'Przywróć Backup' => 'Przywróć Backup', + 'Przeciągnij plik backupu tutaj' => 'Przeciągnij plik backupu tutaj', + 'lub kliknij, aby wybrać plik' => 'lub kliknij, aby wybrać plik', + 'Plik' => 'Plik', + 'Rozmiar' => 'Rozmiar', + 'Usuń plik' => 'Usuń plik', + 'Wersja serwera MySQL' => 'Wersja serwera MySQL', + 'Powrót' => 'Powrót', + 'Błąd' => 'Błąd', + 'ZARZĄDZAJ BAZAMI DANYCH' => 'ZARZĄDZAJ BAZAMI DANYCH', + 'ZARZĄDZAJ UŻYTKOWNIKAMI' => 'ZARZĄDZAJ UŻYTKOWNIKAMI', + 'WGRAJ BACKUP' => 'WGRAJ BACKUP', + 'PHPMYADMIN' => 'PHPMYADMIN', + 'Pobieranie...' => 'Pobieranie...', + 'Nieudana odpowiedź serwera ({code})' => 'Nieudana odpowiedź serwera ({code})', + 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})' => 'Nieudana odpowiedź serwera (HTML zamiast pliku, URL: {url})', + 'Serwer zwrócił pusty plik.' => 'Serwer zwrócił pusty plik.', + 'Nie można pobrać pliku backupu.' => 'Nie można pobrać pliku backupu.', + 'Pobierz plik backupu' => 'Pobierz plik backupu', + 'Brak plików .sql/.gz' => 'Brak plików .sql/.gz', + 'Brak katalogów' => 'Brak katalogów', + 'Nie udało się wczytać katalogów.' => 'Nie udało się wczytać katalogów.', + 'Podaj nazwę katalogu.' => 'Podaj nazwę katalogu.', + 'Nie udało się utworzyć katalogu.' => 'Nie udało się utworzyć katalogu.', + ]; + } + + if (isset($replacements[$text])) { + return $replacements[$text]; + } + + return $text; +} + +$pl = []; +foreach ($en as $key => $_value) { + $pl[$key] = pl_normalize_text((string)$key); +} + +return $pl; diff --git a/multi-database-plan.md b/multi-database-plan.md new file mode 100644 index 0000000..c9b1667 --- /dev/null +++ b/multi-database-plan.md @@ -0,0 +1,976 @@ +# Multi-Database Installation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Refactor `alt-mysql` so one DirectAdmin plugin can install, discover, secure, operate, back up, restore, and expose multiple MariaDB versions as separate local instances. + +**Architecture:** Introduce an explicit MariaDB instance registry. The existing `/usr/local/directadmin/conf/alt-mariadb.env` becomes the registry/index file, while every installed version gets its own per-instance env file, systemd unit, datadir, socket, pid file, and server config. `/usr/local/directadmin/conf/alt-mysql.conf` and `/usr/local/directadmin/conf/alt-my.cnf` remain shared credential-only files; runtime code appends the selected instance socket/port explicitly for every operation. + +**Tech Stack:** Bash, PHP 8-compatible plugin code, mysqli, DirectAdmin plugin handlers, systemd units, MariaDB binary tarballs, CSF allow-file integration, DirectAdmin backup/restore hooks. + +--- + +## Current State Summary + +The plugin currently assumes one alternative MariaDB instance: + +- `scripts/install_db.sh` writes one `/usr/local/directadmin/conf/alt-mariadb.env`, one `/etc/alt-mariadb.cnf`, one `alt-mariadb` systemd service, one datadir, one socket, one port, and one shared client defaults file that also contains the socket. +- `scripts/setup/mysql_common.sh` resolves a single runtime and every shell integration uses that runtime. +- `exec/bootstrap.php` creates one `MySQLService` from one credential file. +- `exec/lib/MySQLService.php` stores metadata tables inside that one MariaDB instance and all database/user/host operations run against it. +- `exec/handlers/index.php` lists databases from one instance, prints one server version, and creates databases without a version selector. +- `BackupQueueService`, `backup_job.sh`, `restore_job.sh`, DA backup/restore hooks, phpMyAdmin SSO, and CSF host sync all assume that `DB_NAME` is enough to identify the target. + +This must change before multiple versions are safe. Without an instance identifier, a backup, restore, deletion, phpMyAdmin login, or host-rule sync can target the wrong MariaDB instance. + +--- + +## Target Runtime Model + +### Instance Identity + +Use one stable instance id per MariaDB series. The id is the version string accepted by the installer, for example: + +- `10.11` +- `11.4` +- `11.8` + +For shell-safe names, derive a slug by replacing unsupported characters with `_` only where needed: + +- systemd unit: `alt-mariadb-10.11.service` +- config file: `/etc/alt-mariadb-10.11.cnf` +- datadir: `/var/lib/alt-mariadb-10.11` +- socket: `/var/lib/alt-mariadb-10.11/mariadb.sock` +- pid file: `/var/lib/alt-mariadb-10.11/mariadb.pid` +- log dir: `/var/log/alt-mariadb-10.11` +- runtime dir: `/run/alt-mariadb-10.11` +- binary dir: `/usr/local/mariadb-10.11` + +The plan intentionally allows only one active instance per version series. Installing `11.4` twice should fail with a clear message unless the existing `11.4` instance is being repaired. + +### Shared Credentials + +Keep credentials shared across all alternative MariaDB instances: + +`/usr/local/directadmin/conf/alt-mysql.conf` + +```ini +host= +user=da_admin +passwd= +database=mysql +``` + +`/usr/local/directadmin/conf/alt-my.cnf` + +```ini +[client] +user="da_admin" +password="" +``` + +Do not store `port=` or `socket=` in the shared client file. Every call must pass `--socket="$INSTANCE_SOCKET"` or `--protocol=TCP --host=127.0.0.1 --port="$INSTANCE_PORT"` explicitly. This avoids silently connecting to the first installed version when the user selected another version. + +On first install, initialize the shared credential files from the current DirectAdmin native MySQL credentials, as the existing script does. On every later install, detect that `/usr/local/directadmin/conf/alt-mariadb.env` already exists and reuse `/usr/local/directadmin/conf/alt-mysql.conf` and `/usr/local/directadmin/conf/alt-my.cnf`; do not read or overwrite credentials from `/usr/local/directadmin/conf/mysql.conf`. + +The shared password must be applied to: + +- the shared admin user from `alt-mysql.conf` on every installed instance, +- `root@localhost` on every installed instance, +- `root@127.0.0.1` where the account exists or is created. + +### Registry Files + +Keep `/usr/local/directadmin/conf/alt-mariadb.env` as the canonical registry/index file. + +Example: + +```bash +# alt-mysql MariaDB instance registry. +ALT_MARIADB_REGISTRY_VERSION='2' +ALT_MARIADB_DEFAULT_INSTANCE='10.11' +ALT_MARIADB_INSTANCES='10.11 11.4' +ALT_MARIADB_DIR='/usr/local/directadmin/conf/alt-mariadb.d' +ALT_MYSQL_CONF='/usr/local/directadmin/conf/alt-mysql.conf' +ALT_MY_CNF='/usr/local/directadmin/conf/alt-my.cnf' +``` + +Store each instance in `/usr/local/directadmin/conf/alt-mariadb.d/.env`. + +Example: + +```bash +INSTANCE_ID='11.4' +MARIADB_VERSION='11.4' +MARIADB_RELEASE='11.4.8' +SERVICE_NAME='alt-mariadb-11.4' +MARIADB_DIR='/usr/local/mariadb-11.4' +DATADIR='/var/lib/alt-mariadb-11.4' +PORT='3020' +SOCKET='/var/lib/alt-mariadb-11.4/mariadb.sock' +PID_FILE='/var/lib/alt-mariadb-11.4/mariadb.pid' +INSTANCE_CNF='/etc/alt-mariadb-11.4.cnf' +SERVICE_FILE='/etc/systemd/system/alt-mariadb-11.4.service' +LOG_DIR='/var/log/alt-mariadb-11.4' +RUN_DIR='/run/alt-mariadb-11.4' +SYSTEM_USER='mysql' +SYSTEM_GROUP='mysql' +BIND_ADDRESS='127.0.0.1' +ALT_MYSQL_CONF='/usr/local/directadmin/conf/alt-mysql.conf' +ALT_MY_CNF='/usr/local/directadmin/conf/alt-my.cnf' +``` + +Migration rule for existing single-instance installs: + +1. If `alt-mariadb.env` contains `SERVICE_NAME=`, `MARIADB_VERSION=`, `PORT=`, and `SOCKET=`, treat it as legacy format. +2. Create `/usr/local/directadmin/conf/alt-mariadb.d/.env` from those values. +3. Rewrite `alt-mariadb.env` as the registry/index file. +4. Keep a timestamped backup of the legacy file before rewriting it. + +--- + +## Data And Metadata Model + +### Metadata Tables + +Keep metadata tables inside every MariaDB instance. This is the safest model because each MariaDB instance can operate independently and there is no single metadata database that breaks the whole fleet. + +Add `instance_id` to every plugin metadata table whose rows refer to user databases or roles: + +```sql +ALTER TABLE da_plugin_database_owners + ADD COLUMN instance_id VARCHAR(32) NOT NULL DEFAULT ''; + +ALTER TABLE da_plugin_grant_profiles + ADD COLUMN instance_id VARCHAR(32) NOT NULL DEFAULT ''; + +ALTER TABLE da_plugin_access_hosts + ADD COLUMN instance_id VARCHAR(32) NOT NULL DEFAULT ''; + +ALTER TABLE da_plugin_sso_accounts + ADD COLUMN instance_id VARCHAR(32) NOT NULL DEFAULT ''; +``` + +Then migrate primary keys: + +- `da_plugin_database_owners`: primary key becomes `(instance_id, db_name)`. +- `da_plugin_grant_profiles`: primary key becomes `(instance_id, db_name, role_name)`. +- `da_plugin_access_hosts`: primary key becomes `(instance_id, role_name, host_pattern)`. +- `da_plugin_sso_accounts`: primary key becomes `(instance_id, role_name, host_pattern)`. + +For a fresh instance, create the schema with `instance_id` already populated. For a migrated single-instance schema, fill `instance_id` with the instance id currently being loaded. + +### Database Name Uniqueness + +Recommended first implementation: database names remain globally unique across all plugin-managed instances. + +Reasoning: + +- It avoids ambiguous DirectAdmin restore behavior. +- It keeps CMS credential changes simple. +- It avoids accidental `phpMyAdmin`, backup, restore, and delete actions against the wrong version. +- It keeps existing user-facing workflows mostly unchanged. + +Implementation rule: + +- Before creating a database on selected version `X`, query every installed instance and reject the name if it exists anywhere. +- Later, if duplicate names across versions are required, all forms, job files, URLs, payload manifests, and locks already need `instance_id`; this plan prepares that path, but the first production-safe step should enforce global uniqueness. + +--- + +## Installer Refactor + +### Required Behavior + +`scripts/install_db.sh` remains the entry point: + +```bash +./scripts/install_db.sh 11.4 3020 +``` + +It must: + +- require both version and port, +- derive datadir as `/var/lib/alt-mariadb-$version`, +- derive socket as `/var/lib/alt-mariadb-$version/mariadb.sock`, +- derive pid file as `/var/lib/alt-mariadb-$version/mariadb.pid`, +- derive config file as `/etc/alt-mariadb-$version.cnf`, +- derive service name as `alt-mariadb-$version`, +- reuse shared root/admin password from `alt-mysql.conf` if the registry already exists, +- write or update only the selected instance env file, +- update `ALT_MARIADB_INSTANCES` in the registry without removing existing instances, +- run non-interactive secure-install SQL for the selected instance, +- run `scripts/install.sh` after successful install unless `SKIP_PLUGIN_INSTALL_REFRESH=1`. + +### Non-Interactive Secure Installation + +After the instance starts and after shared admin/root credentials are set, run a function equivalent to `mariadb-secure-installation` without prompts. + +The SQL must be version-tolerant: + +```sql +DROP DATABASE IF EXISTS test; +DELETE FROM mysql.db WHERE Db = 'test' OR Db LIKE 'test\\_%'; +FLUSH PRIVILEGES; +``` + +For anonymous users and remote root accounts: + +- Prefer `mysql.global_priv` when it exists. +- Fall back to `mysql.user` when `mysql.global_priv` does not exist. +- Remove anonymous users. +- Remove `root` accounts whose host is not `localhost`, `127.0.0.1`, or `::1`. +- Keep local `root` accounts and set their password to the shared secret. + +The secure SQL function must log the actions but never print the password. + +### Installer Tests + +Add tests before implementation: + +- `tests/install_db_multi_instance_test.sh` + - validates derived paths for `11.4 3020`, + - validates service name `alt-mariadb-11.4`, + - validates no default `/var/lib/mariadb_data`, + - validates registry update functions exist, + - validates secure-install function exists and references anonymous users, test database, and remote root cleanup. + +- `tests/registry_migration_test.sh` + - feeds a legacy `alt-mariadb.env`, + - expects a registry file plus `alt-mariadb.d/.env`, + - expects the original file backup to be created. + +--- + +## PHP Runtime Refactor + +### New Classes + +Create `exec/lib/MariaDbInstance.php`. + +Responsibilities: + +- hold `instanceId`, `version`, `release`, `serviceName`, `basedir`, `datadir`, `port`, `socket`, `pidFile`, `instanceCnf`, `serviceFile`, `logDir`, `runDir`, +- expose `clientCredentials(array $sharedCredentials): array`, +- expose `label(): string` for UI display. + +Create `exec/lib/MariaDbInstanceRegistry.php`. + +Responsibilities: + +- load `/usr/local/directadmin/conf/alt-mariadb.env`, +- migrate legacy single-instance env format, +- load all `/usr/local/directadmin/conf/alt-mariadb.d/*.env`, +- return `all(): array`, +- return `count(): int`, +- return `default(): MariaDbInstance`, +- return `find(string $instanceId): MariaDbInstance`, +- validate user-submitted instance ids. + +Create `exec/lib/MySQLServiceFactory.php`. + +Responsibilities: + +- receive `Settings`, shared credentials, and registry, +- create a `MySQLService` for a selected instance, +- create services for all instances, +- expose fleet-level helpers such as `databaseExistsAnywhere()` and `listDatabasesForUserAcrossInstances()`. + +### Existing Class Changes + +Modify `exec/bootstrap.php`: + +- load shared credentials once from `alt-mysql.conf`, +- load instance registry, +- construct `MySQLServiceFactory`, +- construct default `MySQLService` for old handlers that are still single-instance during transition, +- pass registry and factory into `AppContext`. + +Modify `exec/lib/AppContext.php`: + +- add `public MariaDbInstanceRegistry $instances`, +- add `public MySQLServiceFactory $mysqlFactory`, +- keep `public MySQLService $mysql` as the default instance for backward compatibility during the refactor. + +Modify `exec/lib/MySQLService.php`: + +- accept a `MariaDbInstance` in the constructor, +- use selected instance socket/port in connection credentials, +- add `instanceId(): string`, +- make metadata schema instance-aware, +- scope owner/grant/host/sso metadata queries by `instance_id`. + +### PHP Tests + +Add: + +- `tests/instance_registry_test.php` +- `tests/mysql_service_factory_test.php` +- `tests/multi_instance_metadata_schema_test.php` + +Test cases: + +- registry loads two instances from fixture env files, +- legacy env migrates to registry model, +- invalid instance id is rejected, +- factory builds credentials with shared user/password but per-instance socket/port, +- `ensureMetadataSchema()` creates instance-aware metadata definitions, +- `databaseExistsAnywhere()` checks every instance. + +--- + +## Shell Runtime Refactor + +### `scripts/setup/mysql_common.sh` + +Refactor the shell helper so every script can select an instance. + +New contract: + +```bash +MYSQL_ALT_INSTANCE_ID="${MYSQL_ALT_INSTANCE_ID:-}" +mysql_load_alt_runtime "$MYSQL_ALT_INSTANCE_ID" +``` + +If no instance id is provided: + +- use `ALT_MARIADB_DEFAULT_INSTANCE`, +- if only one instance exists, use that instance, +- if more than one instance exists and no default is configured, fail with a clear message. + +New functions: + +- `mysql_registry_file` +- `mysql_instance_dir` +- `mysql_list_instances` +- `mysql_default_instance` +- `mysql_validate_instance_id` +- `mysql_load_instance_env` +- `mysql_instance_socket "$id"` +- `mysql_instance_port "$id"` +- `mysql_instance_basedir "$id"` +- `mysql_instance_service "$id"` +- `mysql_base_args_for_instance "$id"` + +Keep existing wrappers like `mysql_alt_socket` and `mysql_alt_port`, but make them call the selected runtime. + +### Shell Tests + +Update: + +- `tests/mysql_common_runtime_test.sh` +- `tests/mysql_host_sync_test.sh` +- `tests/backup_queue_service_test.sh` +- `tests/restore_rollback_safety_test.sh` + +Add: + +- `tests/mysql_common_multi_runtime_test.sh` + +Test cases: + +- loading a selected instance returns the selected socket/port, +- loading without instance id uses default when only one instance exists, +- loading without instance id fails when multiple instances exist and no default is set, +- binary detection prefers the selected instance basedir, +- generated mysql arguments include shared credentials plus selected socket. + +--- + +## UI And User Workflow + +### Main Page + +Modify `exec/handlers/index.php`. + +When registry count is `1`: + +- keep the current layout, +- keep showing the single server version. + +When registry count is greater than `1`: + +- do not show a single global MariaDB version in the header, +- add a table column `Wersja MariaDB`, +- show each database row with its instance version, +- include hidden `instance_id` fields in row actions: phpMyAdmin login, backup, manage privileges, delete. + +### Create Database Form + +When registry count is greater than `1`: + +- show a required select field `Wersja MariaDB`, +- options come from the registry, sorted by version, +- default option is `ALT_MARIADB_DEFAULT_INSTANCE`, +- create the database through the selected instance service. + +When registry count is `1`: + +- do not show the selector, +- create in the only/default instance. + +Validation: + +- reject unknown instance id, +- reject creating a database name that already exists in any plugin-managed instance, +- in advanced mode, list existing users only from the selected instance. + +### Manage, Delete, Privileges, phpMyAdmin + +Every handler that references a database must accept `instance_id`: + +- `index.php` +- `modify_privileges.php` +- `assign_database.php` +- `change_password.php` +- `delete_databases.php` +- `delete_users.php` +- `manage_hosts.php` +- `open_phpmyadmin.php` +- `upload_backup.php` + +For GET links, include `instance_id` in the query. For POST forms, include it as hidden input and validate it through `MariaDbInstanceRegistry`. + +### UI Tests + +Extend PHP tests or add handler-focused fixtures: + +- one instance: no version selector, no version column, global version visible, +- two instances: version selector visible, version column visible, global version hidden, +- create database posts selected `instance_id`, +- row actions include selected `instance_id`. + +--- + +## phpMyAdmin Integration + +Current private phpMyAdmin SSO assumes one endpoint. Refactor ticket payloads to include instance routing. + +Ticket payload should include: + +```json +{ + "version": 2, + "instance_id": "11.4", + "db": "user_db", + "auth": { + "host": "localhost", + "port": 3020, + "socket": "/var/lib/alt-mariadb-11.4/mariadb.sock", + "user": "da_tmp_phpmyadmin_x", + "password": "..." + } +} +``` + +The generated phpMyAdmin `da_login.php` must: + +- read the ticket, +- use the ticket socket/port instead of a single default `alt-my.cnf` endpoint, +- still use the shared credential file only for bootstrap/admin operations, +- reject tickets with unknown `instance_id`. + +`PhpMyAdminSso` must: + +- find the requested database and instance, +- create temporary SSO users in that selected instance only, +- write the selected instance id into the ticket. + +Tests: + +- opening phpMyAdmin for a database on `11.4` writes a ticket with `instance_id=11.4`, +- generated signon script uses ticket endpoint, +- unknown instance id fails. + +--- + +## Backup And Restore + +### Queue Jobs + +Every job file must include: + +```bash +INSTANCE_ID='11.4' +DB_NAME='user_db' +``` + +Lock files must include instance id: + +```text +data/lock/11.4__user_db.lock +``` + +This prevents a backup on `10.11/user_db` from blocking or being confused with `11.4/user_db` if duplicate database names are later allowed. Even while global uniqueness is enforced, this makes the queue future-proof. + +### Backup Job + +Modify `BackupQueueService::queueBackupJob()`: + +- accept `instance_id`, +- validate that the database exists in that instance, +- write `INSTANCE_ID` into the job env. + +Modify `scripts/backup_job.sh`: + +- read `INSTANCE_ID`, +- call `mysql_load_alt_runtime "$INSTANCE_ID"`, +- log the selected instance id and version, +- write backup sidecar metadata next to the SQL dump. + +Sidecar example: + +```json +{ + "source": "alt-mysql", + "instance_id": "11.4", + "mariadb_version": "11.4", + "database": "user_db", + "created_at": "2026-06-16T20:00:00+02:00" +} +``` + +### Restore Job + +Modify restore queue methods: + +- accept target `instance_id`, +- validate target database in that instance, +- write `INSTANCE_ID` into job env. + +Modify `scripts/restore_job.sh`: + +- read `INSTANCE_ID`, +- call `mysql_load_alt_runtime "$INSTANCE_ID"`, +- perform rollback backup in the selected instance, +- restore only into the selected instance. + +For external backups without plugin sidecar metadata: + +- the restore form must require the target database, and if more than one instance exists it must also require the target MariaDB version, +- no payload conversion is required, +- restore uses the selected target instance. + +For local backups created by the plugin: + +- default restore target should be the source instance from sidecar metadata, +- allow restoring into another version only when `ALLOW_RESTORE_TO_OTHER_DATABASE=true` and the target database already exists or the workflow explicitly creates it. + +### DirectAdmin Backup/Restore Hooks + +Modify: + +- `scripts/da-integration/alt_mysql_user_backup_compress_pre.sh` +- `scripts/da-integration/alt_mysql_restore_payload.sh` +- `scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh` + +Backup hook: + +- iterate every installed instance, +- dump user databases from each instance, +- write manifest entries with `instance_id`, `mariadb_version`, `socket`, `port`, `database`, `sha256`, and relative file path, +- dump metadata tables per instance. + +Restore hook with plugin payload: + +- read `instance_id` from manifest, +- if that instance exists, restore there, +- if it does not exist, use a configurable fallback: + - default: fail with a clear message, + - optional setting: restore to `ALT_MARIADB_DEFAULT_INSTANCE`. + +Restore hook for external/native backups without plugin payload: + +- continue the staging flow through native DirectAdmin MariaDB, +- migrate staged databases to `ALT_MARIADB_DEFAULT_INSTANCE` by default, +- add setting `DA_ALT_MYSQL_EXTERNAL_RESTORE_INSTANCE=default` where value can be a concrete instance id such as `11.4`, +- preserve database name, user name, and password as currently required. + +Tests: + +- backup hook manifest contains two databases on two versions, +- payload restore uses manifest-selected instance, +- external restore without payload uses configured fallback instance, +- restore fails clearly when manifest references a missing instance and no fallback is enabled. + +--- + +## Remote Hosts And CSF + +`mysql_host_sync.sh` currently syncs one port and one config. Refactor it to iterate all active instances: + +- for every installed instance, update bind-address according to `allow_remote_hosts`, +- restart only services whose config changed, +- write CSF allow rules for every active instance port and every allowed remote host, +- keep using a separate managed file such as `/etc/csf/alt-mysql-plugin.allow`, +- include comments that identify instance id and port. + +Example managed CSF line: + +```text +tcp|in|d=3020|s=203.0.113.10 # alt-mysql 11.4 user_db_user +``` + +Tests: + +- one host and two instances produce two CSF entries, +- disabling remote hosts removes managed entries for every instance, +- config rewrite changes all instance configs from `127.0.0.1` to configured bind address. + +--- + +## Upgrade Workflow + +`scripts/setup/mysql_upgrade.sh` must become instance-aware. + +Required CLI: + +```bash +TARGET_MARIADB_VERSION=11.8 SOURCE_INSTANCE_ID=11.4 ./scripts/setup/mysql_upgrade.sh +``` + +Behavior: + +- load source instance from registry, +- install target binaries to `/usr/local/mariadb-11.8`, +- keep the same datadir only when performing an in-place major upgrade, +- if target version implies new instance id, update registry atomically, +- maintain rollback of binaries and config, +- run `mariadb-upgrade` against the selected socket, +- update per-instance env file and registry only after health checks pass. + +Recommended first implementation: + +- support patch-level upgrades within the same instance id first, +- support cross-series upgrade only as a separate, explicit task after multi-instance installation is stable. + +--- + +## Uninstall And Health Checks + +Modify `scripts/uninstall.sh`: + +- support uninstalling one instance by id, +- refuse to remove shared credential files while any instance remains, +- remove shared credential files only when the last instance is removed and the user explicitly confirms. + +Modify `scripts/setup/alt_mysql_health_check.sh`: + +- with no args, check every registered instance, +- with `INSTANCE_ID=11.4`, check only that instance, +- validate service active, socket exists, shared credential login works, metadata schema exists. + +Add tests: + +- health check iterates two fixtures, +- uninstall of one instance leaves shared credentials and other instance registry entry, +- uninstall of last instance removes registry only when explicit cleanup flag is set. + +--- + +## Implementation Tasks + +### Task 1: Add Registry Fixtures And Failing Tests + +**Files:** + +- Create: `tests/fixtures/legacy-alt-mariadb.env` +- Create: `tests/fixtures/alt-mariadb.env` +- Create: `tests/fixtures/alt-mariadb.d/10.11.env` +- Create: `tests/fixtures/alt-mariadb.d/11.4.env` +- Create: `tests/instance_registry_test.php` +- Create: `tests/mysql_common_multi_runtime_test.sh` + +Steps: + +- [ ] Add fixture env files matching the formats in this plan. +- [ ] Write `instance_registry_test.php` to assert two instances load and legacy migration is detected. +- [ ] Write `mysql_common_multi_runtime_test.sh` to assert selected instance socket/port. +- [ ] Run the tests and confirm they fail because registry classes/functions do not exist. + +Expected red failures: + +- `Class "MariaDbInstanceRegistry" not found` +- `mysql_list_instances: command not found` + +### Task 2: Implement PHP Registry + +**Files:** + +- Create: `exec/lib/MariaDbInstance.php` +- Create: `exec/lib/MariaDbInstanceRegistry.php` +- Modify: `exec/bootstrap.php` +- Modify: `tests/instance_registry_test.php` + +Steps: + +- [ ] Implement strict env parsing with only expected keys. +- [ ] Reject instance ids outside `^[0-9]+\\.[0-9]+$`. +- [ ] Load index file and per-instance env files. +- [ ] Implement legacy single-instance detection and migration helper. +- [ ] Add bootstrap autoload entries. +- [ ] Run `php tests/instance_registry_test.php` and confirm pass. +- [ ] Run `php -l exec/lib/MariaDbInstance.php exec/lib/MariaDbInstanceRegistry.php`. + +### Task 3: Implement Shell Registry Runtime + +**Files:** + +- Modify: `scripts/setup/mysql_common.sh` +- Test: `tests/mysql_common_multi_runtime_test.sh` +- Test: `tests/mysql_common_runtime_test.sh` + +Steps: + +- [ ] Add registry path variables. +- [ ] Add instance list/default/validation functions. +- [ ] Refactor `mysql_load_alt_runtime` to accept an optional instance id. +- [ ] Keep old wrappers for compatibility. +- [ ] Run shell tests and `bash -n scripts/setup/mysql_common.sh`. + +### Task 4: Refactor Installer For Per-Version Instances + +**Files:** + +- Modify: `scripts/install_db.sh` +- Create: `tests/install_db_multi_instance_test.sh` +- Create: `tests/registry_migration_test.sh` + +Steps: + +- [ ] Change derived paths to `/var/lib/alt-mariadb-$version`, `/etc/alt-mariadb-$version.cnf`, and `alt-mariadb-$version`. +- [ ] Add first-install credential initialization. +- [ ] Add later-install credential reuse from existing registry. +- [ ] Add registry writer and per-instance env writer. +- [ ] Add legacy registry migration. +- [ ] Add non-interactive secure-install SQL function. +- [ ] Add tests for path derivation, credential reuse, registry update, and secure SQL markers. +- [ ] Run installer tests and `bash -n scripts/install_db.sh`. + +### Task 5: Make MySQLService Instance-Aware + +**Files:** + +- Modify: `exec/lib/MySQLService.php` +- Create: `exec/lib/MySQLServiceFactory.php` +- Modify: `exec/bootstrap.php` +- Modify: `exec/lib/AppContext.php` +- Create: `tests/mysql_service_factory_test.php` +- Create: `tests/multi_instance_metadata_schema_test.php` + +Steps: + +- [ ] Add `MariaDbInstance` constructor argument to `MySQLService`. +- [ ] Scope metadata schema by `instance_id`. +- [ ] Add factory methods for selected and all instances. +- [ ] Add fleet-level database uniqueness check. +- [ ] Update bootstrap and context. +- [ ] Run PHP tests and `php -l` for touched files. + +### Task 6: Refactor Main UI And Create Database Flow + +**Files:** + +- Modify: `exec/handlers/index.php` +- Modify: `exec/handlers/create_database.php` +- Modify: `user/enhanced.css` +- Modify: `user/evolution.css` +- Add/modify handler tests according to current test style. + +Steps: + +- [ ] Aggregate database rows across all instances. +- [ ] Hide global MariaDB version when more than one instance exists. +- [ ] Add version column only when more than one instance exists. +- [ ] Add version selector to create form only when more than one instance exists. +- [ ] Validate selected instance id on create. +- [ ] Enforce global database-name uniqueness across all instances. +- [ ] Keep one-instance UI unchanged. + +### Task 7: Pass Instance Id Through All Database Actions + +**Files:** + +- Modify: `exec/handlers/modify_privileges.php` +- Modify: `exec/handlers/assign_database.php` +- Modify: `exec/handlers/change_password.php` +- Modify: `exec/handlers/delete_databases.php` +- Modify: `exec/handlers/delete_users.php` +- Modify: `exec/handlers/manage_hosts.php` +- Modify: `exec/handlers/open_phpmyadmin.php` +- Modify: `exec/handlers/upload_backup.php` + +Steps: + +- [ ] Add hidden `instance_id` to every POST form. +- [ ] Add `instance_id` query param to every database action URL. +- [ ] Validate every submitted instance id through registry. +- [ ] Use selected `MySQLService` from factory. +- [ ] Reject actions where the selected database does not exist in the selected instance. + +### Task 8: Refactor phpMyAdmin SSO + +**Files:** + +- Modify: `exec/lib/PhpMyAdminSso.php` +- Modify: `scripts/setup/phpmyadmin_install.sh` +- Modify: `scripts/setup/phpmyadmin_private_install.sh` +- Modify: `scripts/setup/phpmyadmin_health_check.sh` +- Modify: `tests/phpmyadmin_install_test.sh` + +Steps: + +- [ ] Add `instance_id` to SSO ticket payload. +- [ ] Include selected socket/port in ticket auth data. +- [ ] Make `da_login.php` use ticket endpoint, not a singleton socket from shared `alt-my.cnf`. +- [ ] Reject unknown instance ids. +- [ ] Test signon against fixture ticket for `11.4`. + +### Task 9: Refactor Backup And Restore Queue + +**Files:** + +- Modify: `exec/lib/BackupQueueService.php` +- Modify: `scripts/backup_job.sh` +- Modify: `scripts/restore_job.sh` +- Modify: `scripts/worker.sh` +- Modify: `tests/backup_queue_service_test.sh` +- Modify: `tests/restore_rollback_safety_test.sh` + +Steps: + +- [ ] Add `INSTANCE_ID` to job env files. +- [ ] Include `INSTANCE_ID` in lock paths. +- [ ] Load selected runtime in backup and restore jobs. +- [ ] Write backup sidecar metadata with instance id/version. +- [ ] Default local restore to source instance when sidecar exists. +- [ ] Require selected target instance for external uploads when more than one instance exists. + +### Task 10: Refactor DirectAdmin Backup/Restore Hooks + +**Files:** + +- Modify: `scripts/da-integration/alt_mysql_user_backup_compress_pre.sh` +- Modify: `scripts/da-integration/alt_mysql_restore_payload.sh` +- Modify: `scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh` +- Modify: `scripts/setup/da_integration_install.sh` +- Add/modify DA integration tests. + +Steps: + +- [ ] Iterate all installed instances during backup. +- [ ] Add instance metadata to payload manifest. +- [ ] Restore payload entries to their source instance when present. +- [ ] Add setting `DA_ALT_MYSQL_EXTERNAL_RESTORE_INSTANCE`. +- [ ] Restore external backups without payload to configured/default instance. +- [ ] Preserve database name, user name, and password. + +### Task 11: Refactor Remote Host Sync And CSF + +**Files:** + +- Modify: `scripts/setup/mysql_host_sync.sh` +- Modify: `plugin-settings.conf` +- Modify: `tests/mysql_host_sync_test.sh` + +Steps: + +- [ ] Iterate active instances. +- [ ] Rewrite bind-address per instance config. +- [ ] Generate managed CSF allow entries for every instance port and allowed host. +- [ ] Restart only changed services. +- [ ] Test two ports with one remote host. + +### Task 12: Refactor Upgrade, Health Check, And Uninstall + +**Files:** + +- Modify: `scripts/setup/mysql_upgrade.sh` +- Modify: `scripts/setup/alt_mysql_health_check.sh` +- Modify: `scripts/uninstall.sh` +- Modify: `tests/mysql_upgrade_rollback_test.sh` + +Steps: + +- [ ] Add instance selection to upgrade. +- [ ] Keep rollback per selected instance. +- [ ] Run `mariadb-upgrade` through selected socket. +- [ ] Health-check every instance by default. +- [ ] Uninstall one instance without removing shared credentials. + +### Task 13: Packaging And Versioning + +**Files:** + +- Modify: `plugin.conf` +- Modify: `tests/plugin_identity_test.sh` +- Modify: `tests/package_archive_test.sh` + +Steps: + +- [ ] Bump plugin version for the implementation release. +- [ ] Ensure package excludes tests and macOS metadata. +- [ ] Ensure archive contains new PHP classes and no stale single-instance installer names. +- [ ] Run the full validation command. +- [ ] Build `alt-mysql.tar.gz` without the parent directory. + +--- + +## Full Validation Command + +Run after implementation: + +```bash +set -euo pipefail +find scripts -type f -name '*.sh' -print0 | sort -z | xargs -0 -n1 bash -n +find exec -type f -name '*.php' -print0 | sort -z | xargs -0 -n1 php -l +bash tests/plugin_identity_test.sh +php tests/instance_registry_test.php +php tests/mysql_service_factory_test.php +php tests/multi_instance_metadata_schema_test.php +bash tests/mysql_common_runtime_test.sh +bash tests/mysql_common_multi_runtime_test.sh +bash tests/install_db_cli_test.sh +bash tests/install_db_multi_instance_test.sh +bash tests/registry_migration_test.sh +php tests/always_enabled_test.php +bash tests/custom_package_items_test.sh +bash tests/da_integration_install_test.sh +bash tests/custombuild_hooks_install_test.sh +bash tests/roundcube_repair_config_test.sh +bash tests/mysql_host_sync_test.sh +php tests/settings_remote_hosts_test.php +bash tests/phpmyadmin_install_test.sh +bash tests/restore_rollback_safety_test.sh +bash tests/mysql_upgrade_rollback_test.sh +bash tests/backup_queue_service_test.sh +bash tests/package_archive_test.sh +``` + +--- + +## Rollout Strategy + +1. Release registry support while still operating one instance. +2. Migrate existing single-instance installs into the registry format. +3. Install a second instance on a staging VM with a distinct port. +4. Verify UI database creation on both versions. +5. Verify phpMyAdmin SSO per version. +6. Verify plugin backup/restore per version. +7. Verify DirectAdmin backup/restore payload with two versions. +8. Verify external DirectAdmin restore without payload goes to configured default instance. +9. Enable remote hosts and verify CSF rules for every active instance port. + +--- + +## Production Acceptance Criteria + +- Installing `./scripts/install_db.sh 10.11 3011` creates `/var/lib/alt-mariadb-10.11`, `/var/lib/alt-mariadb-10.11/mariadb.sock`, and `alt-mariadb-10.11.service`. +- Installing `./scripts/install_db.sh 11.4 3020` does not overwrite the `10.11` registry, datadir, service, socket, config, or credentials. +- `/usr/local/directadmin/conf/alt-mysql.conf` and `/usr/local/directadmin/conf/alt-my.cnf` are shared and contain the same admin/root secret for all instances. +- Every installed instance has had anonymous users removed, test database removed, remote root accounts removed, and local root/admin password set. +- With one instance, the UI remains close to the current UI and shows the server version. +- With more than one instance, the UI shows a database-version column, shows a version selector during database creation, and does not show one global server version. +- Backup, restore, delete, privilege editing, host management, and phpMyAdmin login all use the selected `instance_id`. +- External DirectAdmin restores without plugin payload restore to the configured/default alt instance and preserve database name, user name, and password. +- CSF rules open every active alt MariaDB port only for the hosts defined in the plugin. diff --git a/php.ini b/php.ini new file mode 100644 index 0000000..401a02d --- /dev/null +++ b/php.ini @@ -0,0 +1,10 @@ +allow_url_fopen=Off +allow_url_include=Off +display_errors=Off +log_errors=On +error_reporting=E_ALL +session.use_strict_mode=1 +file_uploads=On +upload_max_filesize=4096M +post_max_size=4096M +max_file_uploads=20 diff --git a/plugin-settings.conf b/plugin-settings.conf new file mode 100644 index 0000000..5c4a005 --- /dev/null +++ b/plugin-settings.conf @@ -0,0 +1,93 @@ +# Ustawienia pluginu MySQL Manager. +# MYSQL_PLUGIN_CREDENTIALS_FILE - plik poświadczeń alternatywnej instancji MariaDB utworzony przez instalator. +MYSQL_PLUGIN_CREDENTIALS_FILE=/usr/local/directadmin/conf/alt-mysql.conf +# MYSQL_PLUGIN_METADATA_FILE - metadane aktywnej instancji alt-mariadb zapisane przez install_db.sh. +MYSQL_PLUGIN_METADATA_FILE=/usr/local/directadmin/conf/alt-mariadb.env +# MYSQL_PLUGIN_ALT_MY_CNF - klient MariaDB dla alt-mariadb, używany przez hooki DA. +MYSQL_PLUGIN_ALT_MY_CNF=/usr/local/directadmin/conf/alt-my.cnf +# MYSQL_PLUGIN_CLIENT_BIN_DIR - opcjonalny katalog binarek klienta MariaDB/MySQL; gdy pusty, plugin wykrywa /usr/local/mariadb-*/bin. +MYSQL_PLUGIN_CLIENT_BIN_DIR= +# MYSQL_PLUGIN_MAX_HOSTS_PER_USER - maksymalna liczba hostów zdalnych przypisanych do jednego użytkownika MySQL. +MYSQL_PLUGIN_MAX_HOSTS_PER_USER=30 +# MYSQL_PLUGIN_LOCAL_SERVER_HOST - opcjonalny IPv4 serwera DirectAdmin/aplikacji dodawany automatycznie dla zdalnego MySQL. +# Gdy pusty, plugin probuje wykryc adres z REQUEST/DirectAdmin/systemu i nadal zawsze dodaje localhost. +MYSQL_PLUGIN_LOCAL_SERVER_HOST= +# allow_remote_hosts - czy pokazywać UI i obsługę hostów zdalnych oraz automatycznie synchronizować CSF/MariaDB (1=tak, 0=nie). +allow_remote_hosts=0 +# modify_server_configuration - starszy przełącznik zgodności; allow_remote_hosts=1 wymusza synchronizację nawet gdy tu jest 0. +modify_server_configuration=0 +# Aliasy kompatybilności wstecznej. +MYSQL_PLUGIN_ALLOW_REMOTE_HOSTS=0 +MYSQL_PLUGIN_MODIFY_SERVER_CONFIGURATION=0 +# MYSQL_PLUGIN_SUDO_SYNC_HOSTS - ścieżka do opcjonalnego skryptu synchronizacji hostów MySQL. +MYSQL_PLUGIN_SUDO_SYNC_HOSTS=/usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh +# MYSQL_PLUGIN_REMOTE_BIND_ADDRESS - adres nasłuchu MariaDB ustawiany po włączeniu allow_remote_hosts. +MYSQL_PLUGIN_REMOTE_BIND_ADDRESS=0.0.0.0 +# MYSQL_PLUGIN_ALT_MARIADB_CNF / MYSQL_PLUGIN_ALT_MARIADB_SERVICE - instancja MariaDB zarządzana przez instalator. +MYSQL_PLUGIN_ALT_MARIADB_CNF=/etc/alt-mariadb.cnf +MYSQL_PLUGIN_ALT_MARIADB_SERVICE=alt-mariadb +# MYSQL_PLUGIN_CSF_* - pliki CSF używane do ograniczenia portu MariaDB wyłącznie do hostów zdefiniowanych w pluginie. +MYSQL_PLUGIN_CSF_ALLOW_FILE=/etc/csf/csf.allow +MYSQL_PLUGIN_CSF_CONF_FILE=/etc/csf/csf.conf +MYSQL_PLUGIN_CSF_MANAGED_ALLOW_FILE=/etc/csf/alt-mysql-plugin.allow +# always_enabled - gdy 1, plugin jest dostępny dla wszystkich użytkowników bez limitu baz i bez pól w edycji użytkownika/pakietu. +always_enabled=1 +# MYSQL_PLUGIN_DEFAULT_DB_LIMIT - domyślny limit baz dla użytkownika, gdy brak wartości w pakiecie/użytkowniku. +MYSQL_PLUGIN_DEFAULT_DB_LIMIT=5 +# ENABLE_BACKUP - czy pokazywać i umożliwiać tworzenie backupu baz danych (true/false). +ENABLE_BACKUP=true +# ENABLE_UPLOAD_BACKUP - czy pokazywać UI i obsługę przywracania backupu (true/false). +ENABLE_UPLOAD_BACKUP=true +# DA_ALT_MYSQL_RESTORE_ENABLED - wlacza restore baz alt-mariadb z natywnych restore DirectAdmina. +DA_ALT_MYSQL_RESTORE_ENABLED=true +# DA_ALT_MYSQL_RESTORE_OVERWRITE - allow albo deny; prompt jest traktowany jak deny w hookach bez UI. +DA_ALT_MYSQL_RESTORE_OVERWRITE=allow +# DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE - dla backupow bez payloadu pluginu migruje bazy z natywnej MariaDB DA do alt-mariadb. +DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE=true +# DA_ALT_MYSQL_NATIVE_CLEANUP - after_success usuwa natywne bazy stagingowe po udanej migracji; keep zostawia je do recznego sprzatania. +DA_ALT_MYSQL_NATIVE_CLEANUP=after_success +# DA_ALT_MYSQL_BLOCK_NATIVE_DB_HOOKS - blokuje natywne tworzenie/usuwanie baz w DA, aby plugin byl zrodlem prawdy. +DA_ALT_MYSQL_BLOCK_NATIVE_DB_HOOKS=true +# ENABLE_PHPMYADMIN - czy pokazywać integrację phpMyAdmin w panelu pluginu (true/false). +ENABLE_PHPMYADMIN=true +# PHPMYADMIN_PUBLIC_BASE_URL - opcjonalny publiczny adres WWW (bez końcowego /), np. https://panel.example.com. +# Gdy pusty, plugin automatycznie użyje aktualnego hosta bez portu DirectAdmin (np. bez :2222). +PHPMYADMIN_PUBLIC_BASE_URL= +# PHPMYADMIN_INSTALL_DIR - prywatna kopia phpMyAdmin używana wyłącznie przez plugin. +PHPMYADMIN_INSTALL_DIR=/var/www/html/alt-mysql-phpmyadmin +# PHPMYADMIN_URL_PATH - ścieżka URL do prywatnej kopii phpMyAdmin pluginu. +PHPMYADMIN_URL_PATH=/alt-mysql-phpmyadmin +# PHPMYADMIN_SSO_DIR - katalog runtime pluginu dla prywatnej integracji phpMyAdmin. +PHPMYADMIN_SSO_DIR=/var/www/html/alt-mysql-phpmyadmin/runtime +# PHPMYADMIN_TICKET_TTL - zachowane dla kompatybilności z runtime/config.json. +PHPMYADMIN_TICKET_TTL=120 +# PHPMYADMIN_SESSION_TTL - orientacyjny maksymalny czas sesji phpMyAdmin otwartej z pluginu (sekundy). +PHPMYADMIN_SESSION_TTL=900 +# PHPMYADMIN_ROLE_TTL - ważność tymczasowego użytkownika MySQL dla sesji phpMyAdmin (sekundy). +PHPMYADMIN_ROLE_TTL=1200 +# ROUNDCUBE_* - opcjonalna integracja Roundcube z alt-mariadb; migrację uruchamia się ręcznie skryptem roundcube_migrate_to_alt_mysql.sh. +ROUNDCUBE_ENABLED=true +ROUNDCUBE_DB_NAME=roundcube +ROUNDCUBE_DB_USER=roundcube +ROUNDCUBE_DB_PASSWORD_FILE=/usr/local/directadmin/conf/alt-roundcube.conf +ROUNDCUBE_CONFIG_FILE=/var/www/html/roundcube/config/config.inc.php +ROUNDCUBE_CUSTOM_CONFIG_FILE=/usr/local/directadmin/custombuild/custom/roundcube/config.inc.php +ROUNDCUBE_WRITE_CUSTOMBUILD_CONFIG=true +ROUNDCUBE_ALT_DSN_MODE=tcp +ROUNDCUBE_NATIVE_MY_CNF=/usr/local/directadmin/conf/my.cnf +# CUSTOMBUILD_HOOKS_ENABLED - instaluje post-hooki po aktualizacji Roundcube/phpMyAdmin przez CustomBuild. +CUSTOMBUILD_HOOKS_ENABLED=true +# ALLOW_RESTORE_TO_OTHER_DATABASE - czy pozwalać na przywracanie lokalnych backupów do innej bazy (true/false). +ALLOW_RESTORE_TO_OTHER_DATABASE=true +# ENABLE_TABLE_COUNT - czy zliczać tabele w bazach i pokazywać kolumnę Liczba Tabel (true/false). +ENABLE_TABLE_COUNT=true +# COUNT_DATABASE_SIZE - czy zliczać rozmiar baz i pokazywać kolumnę Rozmiar (true/false). +COUNT_DATABASE_SIZE=true +# COMPRESS_BACKUP - czy backupy tworzone przez plugin kompresować do .sql.gz (true/false). +COMPRESS_BACKUP=true +# HITME_BACKUP_LOCATION - główny katalog lokalnych backupów MySQL. +HITME_BACKUP_LOCATION=/home/admin/mysql_backup +# USER_BASE_BACKUP_DIR - katalog bazowy backupów wykonywanych przez użytkownika (DA_USER podmieniany automatycznie). +USER_BASE_BACKUP_DIR=/home/DA_USER/mysql_backup +# USER_BACKUP_DATE_FORMAT - format katalogu daty backupu (PHP date()). +USER_BACKUP_DATE_FORMAT=d.m.Y-H.i diff --git a/plugin.conf b/plugin.conf new file mode 100644 index 0000000..09ef20a --- /dev/null +++ b/plugin.conf @@ -0,0 +1,8 @@ +name=alt-mysql +id=alt-mysql +type=user +author=HITME.PL +version=1.2.10 +active=no +installed=no +user_run_as=root diff --git a/scripts/backup_job.sh b/scripts/backup_job.sh new file mode 100644 index 0000000..74d36ff --- /dev/null +++ b/scripts/backup_job.sh @@ -0,0 +1,238 @@ +#!/bin/bash +set -Eeuo pipefail + +ERROR_MSG="" +BACKUP_OK=0 +DA_USER="" +DB_NAME="" +JOB_ID="" +LOG_FILE="" + +JOB_FILE="${1:-}" +if [ -z "$JOB_FILE" ] || [ ! -f "$JOB_FILE" ]; then + echo "mysql: missing job file" >&2 + exit 1 +fi + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf" +LOG_DIR="${PLUGIN_DIR}/data/logs" +COMMON_FILE="${PLUGIN_DIR}/scripts/setup/mysql_common.sh" +mkdir -p "$LOG_DIR" + +urlencode() { + local s="$1" + local out="" + local i c + for ((i=0; i<${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) out+="$c" ;; + ' ') out+='%20' ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + printf '%s' "$out" +} + +notify_user() { + local code="$1" + if [ -z "${DA_USER:-}" ]; then + return 0 + fi + + local subject message + if [ "$code" -eq 0 ] && [ "$BACKUP_OK" -eq 1 ]; then + subject="Backup bazy MySQL zakończony pomyślnie" + message="Backup bazy ${DB_NAME} został wykonany. Plik: ${TARGET_FILE:-nieznany}." + else + subject="Błąd wykonywania backupu bazy MySQL" + if [ -n "$ERROR_MSG" ]; then + message="Backup bazy ${DB_NAME} nie powiódł się: ${ERROR_MSG}" + else + message="Backup bazy ${DB_NAME} nie powiódł się. Sprawdź log zadania." + fi + fi + + local task_queue="/usr/local/directadmin/data/task.queue" + local users="select1%3D$(urlencode "$DA_USER")" + local line="action=notify&value=users&subject=$(urlencode "$subject")&message=$(urlencode "$message")&users=${users}" + printf "%s\n" "$line" >> "$task_queue" +} + +fail() { + local msg="$1" + ERROR_MSG="$msg" + if [ -n "${LOG_FILE:-}" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${msg}" >> "$LOG_FILE" + fi +} + +log_unexpected_error() { + local line_no="$1" + local command="$2" + local code="$3" + if [ -z "${LOG_FILE:-}" ]; then + return 0 + fi + if [ -n "${ERROR_MSG:-}" ]; then + return 0 + fi + + ERROR_MSG="Nieoczekiwany błąd wykonywania backupu (kod ${code}, linia ${line_no})." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG} CMD: ${command}" >> "$LOG_FILE" +} + +load_plugin_settings() { + if [ ! -f "$CONFIG_FILE" ]; then + fail "Brak pliku konfiguracyjnego pluginu: $CONFIG_FILE" + exit 1 + fi + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +} + +quote_sh_value() { + local value="$1" + value="${value//\'/\'\\\'\'}" + printf "'%s'" "$value" +} + +set_job_var() { + local key="$1" + local value="$2" + local tmp + local owner_group="" + if [ -f "$JOB_FILE" ]; then + owner_group="$(stat -c '%u:%g' "$JOB_FILE" 2>/dev/null || stat -f '%u:%g' "$JOB_FILE" 2>/dev/null || true)" + fi + tmp="$(mktemp)" + grep -v "^${key}=" "$JOB_FILE" > "$tmp" 2>/dev/null || true + printf "%s=%s\n" "$key" "$(quote_sh_value "$value")" >> "$tmp" + mv "$tmp" "$JOB_FILE" + chmod 600 "$JOB_FILE" 2>/dev/null || true + if [ "$(id -u)" -eq 0 ] && [ -n "$owner_group" ]; then + chown "$owner_group" "$JOB_FILE" 2>/dev/null || true + fi +} + +on_exit() { + local code="$1" + if [ "$code" -ne 0 ] && [ -z "${ERROR_MSG:-}" ] && [ -n "${LOG_FILE:-}" ]; then + ERROR_MSG="Zadanie backupu zakończyło się błędem (kod ${code}) bez zarejestrowanego szczegółu." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG}" >> "$LOG_FILE" + fi + notify_user "$code" +} +trap 'log_unexpected_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +trap 'on_exit $?' EXIT + +[ -f "$COMMON_FILE" ] || { + echo "Brak helpera MySQL: $COMMON_FILE" >&2 + exit 1 +} +# shellcheck disable=SC1090 +source "$COMMON_FILE" + +# shellcheck disable=SC1090 +source "$JOB_FILE" + +JOB_ID="${JOB_ID:-$(basename "$JOB_FILE" .env)}" +DA_USER="${DA_USER:-}" +DB_NAME="${DB_NAME:-}" +DATE_DIR="${DATE_DIR:-$(date +%Y-%m-%d)}" +COMPRESS_BACKUP="${COMPRESS_BACKUP:-1}" +USER_BASE_BACKUP_DIR="${USER_BASE_BACKUP_DIR:-/home/${DA_USER}/mysql_backup}" +JOB_COMPRESS_BACKUP="${COMPRESS_BACKUP}" +JOB_USER_BASE_BACKUP_DIR="${USER_BASE_BACKUP_DIR}" + +LOG_FILE="${LOG_DIR}/${JOB_ID}.log" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Start backup job ${JOB_ID}" > "$LOG_FILE" + +if [ -z "$DA_USER" ] || [ -z "$DB_NAME" ]; then + fail "Brak DA_USER lub DB_NAME w pliku zadania." + exit 1 +fi + +if [[ "$DB_NAME" != "${DA_USER}_"* ]]; then + fail "Baza ${DB_NAME} nie należy do użytkownika ${DA_USER}." + exit 1 +fi + +load_plugin_settings +COMPRESS_BACKUP="${JOB_COMPRESS_BACKUP:-$COMPRESS_BACKUP}" +USER_BASE_BACKUP_DIR="${JOB_USER_BASE_BACKUP_DIR:-$USER_BASE_BACKUP_DIR}" +mysql_load_alt_credentials +MYSQL_BIN="$(mysql_alt_binary mysql)" || { + fail "Brak binarium mysql." + exit 1 +} +MYSQLDUMP_BIN="$(mysql_alt_binary mysqldump)" || { + fail "Brak binarium mysqldump." + exit 1 +} +mapfile -t MYSQL_ARGS < <(mysql_base_args) + +if [[ "$COMPRESS_BACKUP" =~ ^(true|yes|on)$ ]]; then + COMPRESS_BACKUP="1" +fi +if [[ "$COMPRESS_BACKUP" =~ ^(false|no|off)$ ]]; then + COMPRESS_BACKUP="0" +fi + +TARGET_DIR="${USER_BASE_BACKUP_DIR%/}/${DATE_DIR}" +mkdir -p "$TARGET_DIR" +chmod 700 "$TARGET_DIR" 2>/dev/null || true + +if [ "$COMPRESS_BACKUP" = "1" ]; then + TARGET_FILE="${TARGET_DIR}/${DB_NAME}.sql.gz" +else + TARGET_FILE="${TARGET_DIR}/${DB_NAME}.sql" +fi +TMP_FILE="${TARGET_FILE}.tmp.$$" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup source DB: ${DB_NAME}" >> "$LOG_FILE" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup target file: ${TARGET_FILE}" >> "$LOG_FILE" + +set +e +if [ "$COMPRESS_BACKUP" = "1" ]; then + MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQLDUMP_BIN" \ + "${MYSQL_ARGS[@]}" \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$DB_NAME" 2>>"$LOG_FILE" | gzip -c > "$TMP_FILE" + RC=${PIPESTATUS[0]} +else + MYSQL_PWD="$MYSQL_PASSWORD" "$MYSQLDUMP_BIN" \ + "${MYSQL_ARGS[@]}" \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$DB_NAME" > "$TMP_FILE" 2>>"$LOG_FILE" + RC=$? +fi +set -e + +if [ "$RC" -ne 0 ]; then + rm -f "$TMP_FILE" + fail "mysqldump zakończył się błędem dla bazy ${DB_NAME}." + exit 1 +fi + +mv "$TMP_FILE" "$TARGET_FILE" +chmod 600 "$TARGET_FILE" 2>/dev/null || true +set_job_var "TARGET_FILE" "$TARGET_FILE" +set_job_var "END_TS" "$(date +%s)" + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Backup completed successfully." >> "$LOG_FILE" +BACKUP_OK=1 +exit 0 diff --git a/scripts/da-integration/alt_mysql_restore_payload.sh b/scripts/da-integration/alt_mysql_restore_payload.sh new file mode 100644 index 0000000..0fc7954 --- /dev/null +++ b/scripts/da-integration/alt_mysql_restore_payload.sh @@ -0,0 +1,293 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh" +SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf" +LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}" +DA_USER="" +PAYLOAD_DIR="" +OVERWRITE_POLICY="" +RESTORE_ROLLBACK_FILE="" +RESTORE_ROLLBACK_CREATED=0 + +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" +} + +die() { + log "ERROR: $*" + printf 'alt-mysql restore payload: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: alt_mysql_restore_payload.sh --user DA_USER --payload /path/to/backup/alt_mysql [--overwrite allow|deny] +EOF + exit 2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --user) DA_USER="${2:-}"; shift 2 ;; + --payload) PAYLOAD_DIR="${2:-}"; shift 2 ;; + --overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;; + *) usage ;; + esac +done + +[ -n "$DA_USER" ] || usage +[ -n "$PAYLOAD_DIR" ] || usage +[[ "$DA_USER" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $DA_USER" +[ -d "$PAYLOAD_DIR" ] || die "payload directory does not exist: $PAYLOAD_DIR" +[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE" +# shellcheck source=../setup/mysql_common.sh +source "$COMMON_FILE" +mysql_load_plugin_settings_file "$SETTINGS_FILE" + +read_plugin_setting() { + local key="$1" + local default="$2" + local line value + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return 0 + fi + die "missing sha256sum/shasum" +} + +require_php_cli() { + command -v php >/dev/null 2>&1 || die "php CLI is required to parse manifest.json" +} + +manifest_database_rows() { + local manifest="$1" + php -r ' + $manifest = $argv[1]; + $data = json_decode(file_get_contents($manifest), true); + if (!is_array($data) || ($data["format"] ?? "") !== "da-alt-mysql-v1") { + fwrite(STDERR, "invalid manifest\n"); + exit(3); + } + foreach (($data["databases"] ?? []) as $db) { + $name = (string)($db["name"] ?? ""); + $file = (string)($db["file"] ?? ""); + $sha = (string)($db["sha256"] ?? ""); + if ($name === "" || $file === "" || $sha === "") { + fwrite(STDERR, "invalid database entry\n"); + exit(4); + } + echo $name, "\t", $file, "\t", $sha, "\n"; + } + ' "$manifest" +} + +safe_database_name() { + local db="$1" + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "unsafe database name in payload: $db" + printf '%s\n' "$db" +} + +database_allowed_for_user() { + local db="$1" + [[ "$db" == "${DA_USER}_"* ]] +} + +import_gzip_sql() { + local database="$1" + local file="$2" + if [[ "$file" == *.gz ]]; then + gunzip -c "$file" | mysql_exec "$database" + return "${PIPESTATUS[1]}" + fi + mysql_exec "$database" < "$file" +} + +cleanup_restore_rollback() { + if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then + rm -f "$RESTORE_ROLLBACK_FILE" + fi + RESTORE_ROLLBACK_FILE="" + RESTORE_ROLLBACK_CREATED=0 +} + +backup_alt_database_for_rollback() { + local db="$1" + cleanup_restore_rollback + RESTORE_ROLLBACK_FILE="$(mktemp)" + + if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then + cleanup_restore_rollback + return 0 + fi + + log "creating rollback dump: $db" + if mysqldump_exec \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$db" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then + RESTORE_ROLLBACK_CREATED=1 + return 0 + fi + + cleanup_restore_rollback + return 1 +} + +restore_alt_database_from_rollback() { + local db="$1" + if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then + return 0 + fi + + log "restoring rollback dump: $db" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1 || true + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true + mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || { + log "ERROR: rollback restore failed for $db" + return 1 + } +} + +trap cleanup_restore_rollback INT TERM EXIT + +metadata_cleanup_for_user() { + local escaped_user + escaped_user="$(mysql_escape_literal "$DA_USER")" + mysql_exec mysql -e "DELETE FROM da_plugin_access_hosts WHERE da_user='${escaped_user}' OR role_name LIKE '${escaped_user}\\_%';" + mysql_exec mysql -e "DELETE FROM da_plugin_grant_profiles WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" + mysql_exec mysql -e "DELETE FROM da_plugin_database_owners WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" +} + +verify_database_exists() { + local db="$1" + local escaped_db + escaped_db="$(mysql_escape_literal "$db")" + [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='${escaped_db}' LIMIT 1")" ] \ + || die "database was not restored: $db" +} + +sync_remote_hosts_if_enabled() { + local allow sync_script + allow="$(read_plugin_setting allow_remote_hosts 0)" + [ "$allow" = "1" ] || return 0 + sync_script="$(read_plugin_setting MYSQL_PLUGIN_SUDO_SYNC_HOSTS /usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh)" + if [ -x "$sync_script" ]; then + "$sync_script" >> "$LOG_FILE" 2>&1 || log "WARNING: host sync failed after restore" + fi +} + +main() { + local manifest users_file grants_file metadata_file db file rel_file expected_sha actual_sha overwrite + manifest="$PAYLOAD_DIR/manifest.json" + users_file="$PAYLOAD_DIR/grants/users.sql.gz" + grants_file="$PAYLOAD_DIR/grants/grants.sql.gz" + [ -r "$manifest" ] || die "missing manifest: $manifest" + [ -r "$users_file" ] || die "missing users payload: $users_file" + [ -r "$grants_file" ] || die "missing grants payload: $grants_file" + + require_php_cli + mysql_load_alt_credentials + mysql_ensure_metadata_schema + + overwrite="${OVERWRITE_POLICY:-$(read_plugin_setting DA_ALT_MYSQL_RESTORE_OVERWRITE allow)}" + case "$overwrite" in + allow|deny) ;; + prompt) overwrite="deny" ;; + *) overwrite="deny" ;; + esac + + log "restore payload start: user=$DA_USER payload=$PAYLOAD_DIR overwrite=$overwrite" + manifest_database_rows "$manifest" >/dev/null + + while IFS=$'\t' read -r db rel_file expected_sha; do + db="$(safe_database_name "$db")" + database_allowed_for_user "$db" || die "database $db does not match target user prefix $DA_USER" + file="$PAYLOAD_DIR/$rel_file" + [ -r "$file" ] || die "missing database dump: $file" + actual_sha="$(sha256_file "$file")" + [ "$actual_sha" = "$expected_sha" ] || die "checksum mismatch for $db" + if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then + die "target database exists and overwrite is denied: $db" + fi + done < <(manifest_database_rows "$manifest") + + log "restoring user definitions" + import_gzip_sql mysql "$users_file" >> "$LOG_FILE" 2>&1 || die "cannot restore MySQL users from payload" + + metadata_cleanup_for_user + + while IFS=$'\t' read -r db rel_file expected_sha; do + db="$(safe_database_name "$db")" + file="$PAYLOAD_DIR/$rel_file" + log "restoring database: $db" + backup_alt_database_for_rollback "$db" || die "cannot create rollback dump before restoring database: $db" + if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + die "cannot drop database before restore: $db" + fi + if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + die "cannot create database before restore: $db" + fi + if ! import_gzip_sql "$db" "$file" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + die "cannot import database dump: $db" + fi + cleanup_restore_rollback + verify_database_exists "$db" + done < <(manifest_database_rows "$manifest") + + for metadata_file in \ + "$PAYLOAD_DIR/metadata/da_plugin_database_owners.sql.gz" \ + "$PAYLOAD_DIR/metadata/da_plugin_grant_profiles.sql.gz" \ + "$PAYLOAD_DIR/metadata/da_plugin_access_hosts.sql.gz"; do + if [ -r "$metadata_file" ]; then + log "restoring metadata: ${metadata_file##*/}" + import_gzip_sql mysql "$metadata_file" >> "$LOG_FILE" 2>&1 || die "cannot restore metadata: $metadata_file" + fi + done + + log "restoring grants" + import_gzip_sql mysql "$grants_file" >> "$LOG_FILE" 2>&1 || die "cannot restore grants from payload" + mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true + sync_remote_hosts_if_enabled + + log "restore payload completed: user=$DA_USER" +} + +main "$@" diff --git a/scripts/da-integration/alt_mysql_user_backup_compress_pre.sh b/scripts/da-integration/alt_mysql_user_backup_compress_pre.sh new file mode 100644 index 0000000..421daa1 --- /dev/null +++ b/scripts/da-integration/alt_mysql_user_backup_compress_pre.sh @@ -0,0 +1,312 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh" +LOG_FILE="${DA_ALT_MYSQL_BACKUP_LOG:-$PLUGIN_DIR/data/logs/da-backup.log}" +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + esac +done + +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" +} + +die() { + log "ERROR: $*" + printf 'alt-mysql backup hook: %s\n' "$*" >&2 + exit 1 +} + +[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE" +# shellcheck source=../setup/mysql_common.sh +source "$COMMON_FILE" +mysql_load_plugin_settings_file "$PLUGIN_DIR/plugin-settings.conf" + +json_escape() { + printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +json_string() { + printf '"%s"' "$(json_escape "${1:-}")" +} + +sha256_file() { + local file="$1" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$file" | awk '{print $1}' + return 0 + fi + die "missing sha256sum/shasum" +} + +detect_da_user() { + local value="${DA_USER:-${username:-${user:-${USERNAME:-}}}}" + if [ -z "$value" ] && [ "${1:-}" != "--dry-run" ]; then + value="${1:-}" + fi + if [[ ! "$value" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then + die "cannot detect safe DirectAdmin username" + fi + printf '%s\n' "$value" +} + +candidate_dir_if_valid() { + local candidate="${1:-}" + [ -n "$candidate" ] || return 1 + [ -d "$candidate" ] || return 1 + printf '%s\n' "$(cd "$candidate" && pwd -P)" +} + +candidate_backup_root_if_valid() { + local candidate="${1:-}" + local dir="" + if ! dir="$(candidate_dir_if_valid "$candidate")"; then + return 1 + fi + if [ "$DRY_RUN" -eq 1 ] || [ -d "$dir/backup" ]; then + printf '%s\n' "$dir" + return 0 + fi + return 1 +} + +detect_backup_root() { + local current candidate dir file_candidate + current="$(pwd -P)" + + for candidate in \ + "${DA_ALT_MYSQL_BACKUP_ROOT:-}" \ + "${backup_path:-}" \ + "${backupdir:-}" \ + "${backup_dir:-}" \ + "${tmpdir:-}" \ + "$current"; do + if dir="$(candidate_backup_root_if_valid "$candidate")"; then + printf '%s\n' "$dir" + return 0 + fi + done + + for file_candidate in "${file:-}" "${filename:-}" "${backup_file:-}"; do + [ -n "$file_candidate" ] || continue + candidate="$(dirname "$file_candidate")" + if dir="$(candidate_backup_root_if_valid "$candidate")"; then + printf '%s\n' "$dir" + return 0 + fi + done + + die "cannot detect DirectAdmin backup assembly directory" +} + +list_user_databases() { + local da_user="$1" + local escaped_user + escaped_user="$(mysql_escape_literal "$da_user")" + mysql_exec mysql -Nse " + SELECT SCHEMA_NAME + FROM information_schema.SCHEMATA + WHERE SCHEMA_NAME LIKE '${escaped_user}\\_%' ESCAPE '\\' + ORDER BY SCHEMA_NAME + " | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' || true +} + +list_user_roles() { + local da_user="$1" + local escaped_user + escaped_user="$(mysql_escape_literal "$da_user")" + mysql_exec mysql -Nse " + SELECT DISTINCT User + FROM mysql.user + WHERE User LIKE '${escaped_user}\\_%' ESCAPE '\\' + AND User <> '' + ORDER BY User + " || true +} + +dump_users_file() { + local da_user="$1" + local output_file="$2" + local tmp_file user host q_user q_host create_sql + + tmp_file="$(mktemp)" + { + printf '%s\n' "-- alt-mysql user definitions for $da_user" + printf '%s\n' "SET sql_log_bin=0;" + } > "$tmp_file" + + while IFS=$'\t' read -r user host; do + [ -n "${user:-}" ] || continue + [ -n "${host:-}" ] || continue + q_user="$(mysql_escape_literal "$user")" + q_host="$(mysql_escape_literal "$host")" + create_sql="$(mysql_exec mysql -Nse "SHOW CREATE USER '${q_user}'@'${q_host}'" | awk -F'\t' '{print $NF}' || true)" + [ -n "$create_sql" ] || continue + printf "DROP USER IF EXISTS '%s'@'%s';\n" "$q_user" "$q_host" >> "$tmp_file" + printf '%s;\n' "$create_sql" >> "$tmp_file" + done < <( + mysql_exec mysql -Nse " + SELECT User, Host + FROM mysql.user + WHERE User LIKE '$(mysql_escape_literal "$da_user")\\_%' ESCAPE '\\' + AND User <> '' + ORDER BY User, Host + " || true + ) + + printf '%s\n' "SET sql_log_bin=1;" >> "$tmp_file" + gzip -9 < "$tmp_file" > "$output_file" + rm -f "$tmp_file" +} + +dump_grants_file() { + local da_user="$1" + local output_file="$2" + local tmp_file user host q_user q_host grant_line + + tmp_file="$(mktemp)" + printf '%s\n' "-- alt-mysql grants for $da_user" > "$tmp_file" + + while IFS=$'\t' read -r user host; do + [ -n "${user:-}" ] || continue + [ -n "${host:-}" ] || continue + q_user="$(mysql_escape_literal "$user")" + q_host="$(mysql_escape_literal "$host")" + while IFS= read -r grant_line; do + [ -n "$grant_line" ] || continue + printf '%s;\n' "$grant_line" >> "$tmp_file" + done < <(mysql_exec mysql -Nse "SHOW GRANTS FOR '${q_user}'@'${q_host}'" || true) + printf '\n' >> "$tmp_file" + done < <( + mysql_exec mysql -Nse " + SELECT User, Host + FROM mysql.user + WHERE User LIKE '$(mysql_escape_literal "$da_user")\\_%' ESCAPE '\\' + AND User <> '' + ORDER BY User, Host + " || true + ) + + gzip -9 < "$tmp_file" > "$output_file" + rm -f "$tmp_file" +} + +dump_metadata_table() { + local table="$1" + local where="$2" + local output_file="$3" + mysqldump_exec \ + --single-transaction \ + --skip-lock-tables \ + --no-create-info \ + --compact \ + --where="$where" \ + mysql "$table" | gzip -9 > "$output_file" +} + +write_manifest() { + local da_user="$1" + local payload_dir="$2" + shift 2 + local db_json="$1" + local manifest="$payload_dir/manifest.json" + local version + version="$(mysql_alt_version)" + mysql_load_alt_runtime + + cat > "$manifest" < "$db_file" + rel_file="databases/${db}.sql.gz" + sha="$(sha256_file "$db_file")" + entry=" { \"name\": $(json_string "$db"), \"file\": $(json_string "$rel_file"), \"sha256\": $(json_string "$sha") }" + if [ -n "$db_entries" ]; then + db_entries+=$',\n' + fi + db_entries+="$entry" + log "added database dump: $db" + done < <(list_user_databases "$da_user") + + dump_users_file "$da_user" "$grants_dir/users.sql.gz" + dump_grants_file "$da_user" "$grants_dir/grants.sql.gz" + + prefix="$(mysql_escape_literal "$da_user")" + where="db_name LIKE '${prefix}\\_%'" + dump_metadata_table da_plugin_database_owners "$where" "$metadata_dir/da_plugin_database_owners.sql.gz" + dump_metadata_table da_plugin_grant_profiles "$where" "$metadata_dir/da_plugin_grant_profiles.sql.gz" + where="da_user = '${prefix}' OR role_name LIKE '${prefix}\\_%'" + dump_metadata_table da_plugin_access_hosts "$where" "$metadata_dir/da_plugin_access_hosts.sql.gz" + + write_manifest "$da_user" "$payload_dir" "$db_entries" + chmod -R go-rwx "$payload_dir" 2>/dev/null || true + log "backup payload completed: $payload_dir" +} + +main "$@" diff --git a/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh b/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh new file mode 100644 index 0000000..1882075 --- /dev/null +++ b/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh @@ -0,0 +1,165 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf" +LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}" +RESTORE_PAYLOAD_SCRIPT="$SCRIPT_DIR/alt_mysql_restore_payload.sh" +NATIVE_MIGRATE_SCRIPT="$SCRIPT_DIR/da_restore_native_staging_to_alt_mysql.sh" + +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" +} + +die() { + log "ERROR: $*" + printf 'alt-mysql restore hook: %s\n' "$*" >&2 + exit 1 +} + +read_plugin_setting() { + local key="$1" + local default="$2" + local line value + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +detect_da_user() { + local value="${DA_USER:-${username:-${user:-${USERNAME:-}}}}" + if [ -z "$value" ]; then + value="${1:-}" + fi + if [[ ! "$value" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then + die "cannot detect safe DirectAdmin username" + fi + printf '%s\n' "$value" +} + +candidate_dir_if_valid() { + local candidate="${1:-}" + [ -n "$candidate" ] || return 1 + [ -d "$candidate" ] || return 1 + printf '%s\n' "$(cd "$candidate" && pwd -P)" +} + +find_payload_in_dir() { + local root="$1" + local payload="" + [ -d "$root" ] || return 1 + for payload in \ + "$root/backup/alt_mysql" \ + "$root/alt_mysql" \ + "$root/mysql/backup/alt_mysql"; do + if [ -r "$payload/manifest.json" ]; then + printf '%s\n' "$payload" + return 0 + fi + done + + payload="$(find "$root" -maxdepth 5 -type f -path '*/backup/alt_mysql/manifest.json' -print -quit 2>/dev/null || true)" + if [ -n "$payload" ]; then + dirname "$payload" + return 0 + fi + return 1 +} + +extract_payload_from_archive() { + local archive="$1" + local tmp_dir payload_member + [ -r "$archive" ] || return 1 + command -v tar >/dev/null 2>&1 || return 1 + + payload_member="$(tar -tf "$archive" 2>/dev/null | grep -E '(^|/)backup/alt_mysql/manifest\.json$' | head -n 1 || true)" + [ -n "$payload_member" ] || return 1 + + tmp_dir="$(mktemp -d)" + tar -xf "$archive" -C "$tmp_dir" --wildcards '*/backup/alt_mysql/*' 'backup/alt_mysql/*' 2>/dev/null \ + || tar -xf "$archive" -C "$tmp_dir" 2>/dev/null + + find_payload_in_dir "$tmp_dir" +} + +detect_payload_dir() { + local current candidate dir archive payload + current="$(pwd -P)" + for candidate in \ + "${DA_ALT_MYSQL_RESTORE_ROOT:-}" \ + "${restore_path:-}" \ + "${restore_dir:-}" \ + "${tmpdir:-}" \ + "${backup_path:-}" \ + "$current"; do + if dir="$(candidate_dir_if_valid "$candidate")"; then + if payload="$(find_payload_in_dir "$dir")"; then + printf '%s\n' "$payload" + return 0 + fi + fi + done + + for archive in "${filename:-}" "${file:-}" "${backup_file:-}"; do + [ -n "$archive" ] || continue + if payload="$(extract_payload_from_archive "$archive")"; then + printf '%s\n' "$payload" + return 0 + fi + done + + return 1 +} + +main() { + local da_user enabled overwrite payload native_mode + da_user="$(detect_da_user "${1:-}")" + enabled="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_ENABLED true)" + native_mode="$(read_plugin_setting DA_ALT_MYSQL_NATIVE_STAGING_MIGRATE true)" + + log "restore hook env: user=$da_user pwd=$(pwd -P) file=${file:-} filename=${filename:-}" + + case "$enabled" in + true|yes|on|1) ;; + *) log "restore hook disabled by DA_ALT_MYSQL_RESTORE_ENABLED=$enabled"; return 0 ;; + esac + + overwrite="$(read_plugin_setting DA_ALT_MYSQL_RESTORE_OVERWRITE allow)" + + if payload="$(detect_payload_dir)"; then + [ -x "$RESTORE_PAYLOAD_SCRIPT" ] || die "restore payload script is not executable: $RESTORE_PAYLOAD_SCRIPT" + log "plugin payload detected: $payload" + "$RESTORE_PAYLOAD_SCRIPT" --user "$da_user" --payload "$payload" --overwrite "$overwrite" + return 0 + fi + + log "plugin payload not found; considering native staging migration" + case "$native_mode" in + true|yes|on|1) + [ -x "$NATIVE_MIGRATE_SCRIPT" ] || die "native staging migration script is not executable: $NATIVE_MIGRATE_SCRIPT" + "$NATIVE_MIGRATE_SCRIPT" --user "$da_user" + ;; + *) + log "native staging migration disabled; account restore continues without alt-mysql DB migration" + ;; + esac +} + +main "$@" diff --git a/scripts/da-integration/block_native_database_action.sh b/scripts/da-integration/block_native_database_action.sh new file mode 100644 index 0000000..7607d53 --- /dev/null +++ b/scripts/da-integration/block_native_database_action.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf" +LOG_FILE="${DA_ALT_MYSQL_DB_HOOK_LOG:-$PLUGIN_DIR/data/logs/da-db-hooks.log}" +HOOK_NAME="${DA_HOOK_NAME:-$(basename "$0")}" + +mkdir -p "$(dirname "$LOG_FILE")" + +read_plugin_setting() { + local key="$1" + local default="$2" + local line value + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +log() { + printf '[%s] hook=%s username=%s database=%s user=%s %s\n' \ + "$(date '+%Y-%m-%d %H:%M:%S')" \ + "$HOOK_NAME" \ + "${username:-}" \ + "${database:-${name:-}}" \ + "${user:-}" \ + "$*" >> "$LOG_FILE" +} + +enabled="$(read_plugin_setting DA_ALT_MYSQL_BLOCK_NATIVE_DB_HOOKS true)" +case "$enabled" in + true|yes|on|1) ;; + *) log "native DB hook blocker disabled"; exit 0 ;; +esac + +if [ "${DA_ALT_MYSQL_ALLOW_NATIVE_DB_ACTION:-0}" = "1" ]; then + log "native DB action explicitly allowed by environment" + exit 0 +fi + +case "$HOOK_NAME" in + *_post.sh|*_post) + log "native DirectAdmin database action observed after completion" + exit 0 + ;; +esac + +log "blocked native DirectAdmin database action" +cat >&2 <<'EOF' +Databases on this server are managed by the MySQL plugin and the alt-mariadb instance. +Use the MySQL plugin instead of DirectAdmin native database actions. +EOF +exit 1 diff --git a/scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh b/scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh new file mode 100644 index 0000000..cf09251 --- /dev/null +++ b/scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh @@ -0,0 +1,448 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh" +SETTINGS_FILE="$PLUGIN_DIR/plugin-settings.conf" +LOG_FILE="${DA_ALT_MYSQL_RESTORE_LOG:-$PLUGIN_DIR/data/logs/da-restore.log}" +DA_USER="" +NATIVE_MY_CNF="${NATIVE_MY_CNF:-/usr/local/directadmin/conf/my.cnf}" +OVERWRITE_POLICY="" +CLEANUP_POLICY="" +RESTORE_ROLLBACK_FILE="" +RESTORE_ROLLBACK_CREATED=0 + +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >> "$LOG_FILE" +} + +die() { + log "ERROR: $*" + printf 'native staging to alt-mysql: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat >&2 <<'EOF' +Usage: da_restore_native_staging_to_alt_mysql.sh --user DA_USER [--native-my-cnf /path] [--overwrite allow|deny] [--cleanup after_success|keep] +EOF + exit 2 +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --user) DA_USER="${2:-}"; shift 2 ;; + --native-my-cnf) NATIVE_MY_CNF="${2:-}"; shift 2 ;; + --overwrite) OVERWRITE_POLICY="${2:-}"; shift 2 ;; + --cleanup) CLEANUP_POLICY="${2:-}"; shift 2 ;; + *) usage ;; + esac +done + +[ -n "$DA_USER" ] || usage +[[ "$DA_USER" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]] || die "unsafe DirectAdmin username: $DA_USER" +[ -r "$COMMON_FILE" ] || die "missing helper: $COMMON_FILE" +# shellcheck source=../setup/mysql_common.sh +source "$COMMON_FILE" +mysql_load_plugin_settings_file "$SETTINGS_FILE" + +read_plugin_setting() { + local key="$1" + local default="$2" + local line value + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +native_mysql() { + "$NATIVE_MYSQL_BIN" --defaults-extra-file="$NATIVE_MY_CNF" "$@" +} + +native_query() { + local database="$1" + local sql="$2" + native_mysql --batch --skip-column-names "$database" -e "$sql" +} + +safe_identifier() { + local value="$1" + [[ "$value" =~ ^[A-Za-z0-9_]+$ ]] || die "unsafe MySQL identifier: $value" + printf '%s\n' "$value" +} + +list_native_databases() { + native_query information_schema " + SELECT SCHEMA_NAME + FROM SCHEMATA + WHERE SCHEMA_NAME LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\' + ORDER BY SCHEMA_NAME + " | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' || true +} + +list_native_user_hosts() { + native_query mysql " + SELECT User, Host + FROM user + WHERE User LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\' + AND User <> '' + ORDER BY User, Host + " || true +} + +list_native_roles_for_db() { + local db="$1" + native_query mysql " + SELECT DISTINCT User + FROM db + WHERE Db = '$(mysql_escape_literal "$db")' + AND User LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\' + AND User <> '' + ORDER BY User + " || true +} + +native_show_create_user() { + local user="$1" + local host="$2" + native_query mysql "SHOW CREATE USER '$(mysql_escape_literal "$user")'@'$(mysql_escape_literal "$host")'" | awk -F'\t' '{print $NF}' +} + +native_show_grants() { + local user="$1" + local host="$2" + native_query mysql "SHOW GRANTS FOR '$(mysql_escape_literal "$user")'@'$(mysql_escape_literal "$host")'" || true +} + +apply_user_definition_to_alt() { + local user="$1" + local host="$2" + local create_sql + create_sql="$(native_show_create_user "$user" "$host" || true)" + [ -n "$create_sql" ] || die "cannot recover password/authentication definition for ${user}@${host}" + + { + printf "DROP USER IF EXISTS '%s'@'%s';\n" "$(mysql_escape_literal "$user")" "$(mysql_escape_literal "$host")" + printf '%s;\n' "$create_sql" + } | mysql_exec mysql >> "$LOG_FILE" 2>&1 || die "cannot recreate user with preserved password: ${user}@${host}" +} + +apply_grants_to_alt() { + local user="$1" + local host="$2" + local grant_line + while IFS= read -r grant_line; do + [ -n "$grant_line" ] || continue + printf '%s;\n' "$grant_line" | mysql_exec mysql >> "$LOG_FILE" 2>&1 \ + || die "cannot apply grant for ${user}@${host}" + done < <(native_show_grants "$user" "$host") +} + +cleanup_restore_rollback() { + if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then + rm -f "$RESTORE_ROLLBACK_FILE" + fi + RESTORE_ROLLBACK_FILE="" + RESTORE_ROLLBACK_CREATED=0 +} + +backup_alt_database_for_rollback() { + local db="$1" + cleanup_restore_rollback + RESTORE_ROLLBACK_FILE="$(mktemp)" + + if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then + cleanup_restore_rollback + return 0 + fi + + log "creating rollback dump: $db" + if mysqldump_exec \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$db" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then + RESTORE_ROLLBACK_CREATED=1 + return 0 + fi + + cleanup_restore_rollback + return 1 +} + +restore_alt_database_from_rollback() { + local db="$1" + if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then + return 0 + fi + + log "restoring rollback dump: $db" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1 || true + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true + mysql_exec "$db" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || { + log "ERROR: rollback restore failed for $db" + return 1 + } +} + +trap cleanup_restore_rollback INT TERM EXIT + +db_privilege_profile() { + local db="$1" + local role="$2" + local row privs=() + row="$(native_query mysql " + SELECT Select_priv, Insert_priv, Update_priv, Delete_priv, Create_priv, Drop_priv, Index_priv, Alter_priv, + Create_tmp_table_priv, Lock_tables_priv, Create_view_priv, Show_view_priv, Create_routine_priv, + Alter_routine_priv, Execute_priv, Event_priv, Trigger_priv + FROM db + WHERE Db='$(mysql_escape_literal "$db")' + AND User='$(mysql_escape_literal "$role")' + LIMIT 1 + " | head -n 1 || true)" + + if [ -z "$row" ]; then + printf 'ALL\n' + return 0 + fi + + IFS=$'\t' read -r select_p insert_p update_p delete_p create_p drop_p index_p alter_p tmp_p lock_p create_view_p show_view_p create_routine_p alter_routine_p execute_p event_p trigger_p <<< "$row" + [ "${select_p:-N}" = "Y" ] && privs+=("SELECT") + [ "${insert_p:-N}" = "Y" ] && privs+=("INSERT") + [ "${update_p:-N}" = "Y" ] && privs+=("UPDATE") + [ "${delete_p:-N}" = "Y" ] && privs+=("DELETE") + [ "${create_p:-N}" = "Y" ] && privs+=("CREATE") + [ "${drop_p:-N}" = "Y" ] && privs+=("DROP") + [ "${index_p:-N}" = "Y" ] && privs+=("INDEX") + [ "${alter_p:-N}" = "Y" ] && privs+=("ALTER") + [ "${tmp_p:-N}" = "Y" ] && privs+=("CREATE TEMPORARY TABLES") + [ "${lock_p:-N}" = "Y" ] && privs+=("LOCK TABLES") + [ "${create_view_p:-N}" = "Y" ] && privs+=("CREATE VIEW") + [ "${show_view_p:-N}" = "Y" ] && privs+=("SHOW VIEW") + [ "${create_routine_p:-N}" = "Y" ] && privs+=("CREATE ROUTINE") + [ "${alter_routine_p:-N}" = "Y" ] && privs+=("ALTER ROUTINE") + [ "${execute_p:-N}" = "Y" ] && privs+=("EXECUTE") + [ "${event_p:-N}" = "Y" ] && privs+=("EVENT") + [ "${trigger_p:-N}" = "Y" ] && privs+=("TRIGGER") + + if [ "${#privs[@]}" -eq 0 ]; then + printf 'ALL\n' + else + local IFS=, + printf '%s\n' "${privs[*]}" + fi +} + +metadata_cleanup_for_user() { + local escaped_user + escaped_user="$(mysql_escape_literal "$DA_USER")" + mysql_exec mysql -e "DELETE FROM da_plugin_access_hosts WHERE da_user='${escaped_user}' OR role_name LIKE '${escaped_user}\\_%';" + mysql_exec mysql -e "DELETE FROM da_plugin_grant_profiles WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" + mysql_exec mysql -e "DELETE FROM da_plugin_database_owners WHERE db_name LIKE '${escaped_user}\\_%' OR role_name LIKE '${escaped_user}\\_%';" +} + +rebuild_metadata_for_db() { + local db="$1" + local owner="" role profile host + while IFS= read -r role; do + [ -n "$role" ] || continue + if [ -z "$owner" ]; then + owner="$role" + fi + profile="$(db_privilege_profile "$db" "$role")" + mysql_exec mysql -e " + INSERT INTO da_plugin_grant_profiles (db_name, role_name, privileges) + VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "$profile")') + ON DUPLICATE KEY UPDATE privileges=VALUES(privileges), updated_at=CURRENT_TIMESTAMP; + " + while IFS=$'\t' read -r _user host; do + [ -n "${host:-}" ] || continue + mysql_exec mysql -e " + INSERT INTO da_plugin_access_hosts (da_user, role_name, host_pattern, note) + VALUES ('$(mysql_escape_literal "$DA_USER")', '$(mysql_escape_literal "$role")', '$(mysql_escape_literal "$host")', 'migrated from native DirectAdmin restore') + ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), note=VALUES(note), updated_at=CURRENT_TIMESTAMP; + " + done < <(list_native_user_hosts | awk -F'\t' -v role="$role" '$1 == role { print $0 }') + done < <(list_native_roles_for_db "$db") + + if [ -n "$owner" ]; then + mysql_exec mysql -e " + INSERT INTO da_plugin_database_owners (db_name, da_user, role_name) + VALUES ('$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$DA_USER")', '$(mysql_escape_literal "$owner")') + ON DUPLICATE KEY UPDATE da_user=VALUES(da_user), role_name=VALUES(role_name), updated_at=CURRENT_TIMESTAMP; + " + fi +} + +verify_alt_database() { + local db="$1" + [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ] \ + || die "alt database missing after import: $db" +} + +drop_native_staging() { + local db user host remaining + log "cleanup native staging for $DA_USER" + while IFS= read -r db; do + [ -n "$db" ] || continue + native_query mysql "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db")" >> "$LOG_FILE" 2>&1 || die "cannot drop native staged database: $db" + done < <(list_native_databases) + + while IFS=$'\t' read -r user host; do + [ -n "${user:-}" ] || continue + [ -n "${host:-}" ] || continue + remaining="$(native_query mysql " + SELECT COUNT(*) + FROM db + WHERE User='$(mysql_escape_literal "$user")' + AND Db NOT LIKE '$(mysql_escape_literal "$DA_USER")\\_%' ESCAPE '\\' + " | tail -n 1)" + if [ "${remaining:-0}" = "0" ]; then + native_query mysql "DROP USER IF EXISTS '$(mysql_escape_literal "$user")'@'$(mysql_escape_literal "$host")'" >> "$LOG_FILE" 2>&1 || true + else + log "keeping native user ${user}@${host}; it still has non-staging grants" + fi + done < <(list_native_user_hosts) +} + +sync_remote_hosts_if_enabled() { + local allow sync_script + allow="$(read_plugin_setting allow_remote_hosts 0)" + [ "$allow" = "1" ] || return 0 + sync_script="$(read_plugin_setting MYSQL_PLUGIN_SUDO_SYNC_HOSTS /usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh)" + if [ -x "$sync_script" ]; then + "$sync_script" >> "$LOG_FILE" 2>&1 || log "WARNING: host sync failed after native staging migration" + fi +} + +main() { + local db tmp_dump user host overwrite cleanup native_count + [ -r "$NATIVE_MY_CNF" ] || die "native DirectAdmin MySQL client config is not readable: $NATIVE_MY_CNF" + NATIVE_MYSQL_BIN="$(mysql_native_binary mysql)" || die "native mysql/mariadb client not found" + NATIVE_MYSQLDUMP_BIN="$(mysql_native_binary mysqldump)" || die "native mysqldump/mariadb-dump client not found" + + mysql_load_alt_credentials + mysql_ensure_metadata_schema + native_mysql mysql -e "SELECT 1" >/dev/null 2>&1 || die "native DirectAdmin MySQL staging is not reachable" + mysql_exec mysql -e "SELECT 1" >/dev/null 2>&1 || die "alt-mariadb is not reachable" + + overwrite="${OVERWRITE_POLICY:-$(read_plugin_setting DA_ALT_MYSQL_RESTORE_OVERWRITE allow)}" + cleanup="${CLEANUP_POLICY:-$(read_plugin_setting DA_ALT_MYSQL_NATIVE_CLEANUP after_success)}" + case "$overwrite" in allow|deny) ;; prompt) overwrite="deny" ;; *) overwrite="deny" ;; esac + case "$cleanup" in after_success|keep) ;; *) cleanup="keep" ;; esac + + native_count="$(list_native_databases | wc -l | tr -d '[:space:]')" + if [ "${native_count:-0}" = "0" ]; then + log "no native staged databases for $DA_USER" + return 0 + fi + + log "native staging migration start: user=$DA_USER db_count=$native_count overwrite=$overwrite cleanup=$cleanup" + + if [ "$overwrite" = "deny" ]; then + while IFS= read -r db; do + [ -n "$db" ] || continue + if [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then + die "target database exists and overwrite is denied: $db" + fi + done < <(list_native_databases) + fi + + while IFS=$'\t' read -r user host; do + [ -n "${user:-}" ] || continue + [ -n "${host:-}" ] || continue + log "preserving native user definition: ${user}@${host}" + apply_user_definition_to_alt "$user" "$host" + done < <(list_native_user_hosts) + + metadata_cleanup_for_user + + while IFS= read -r db; do + [ -n "$db" ] || continue + db="$(safe_identifier "$db")" + if [ "$overwrite" = "deny" ] && [ -n "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$db")' LIMIT 1")" ]; then + die "target database exists and overwrite is denied: $db" + fi + + tmp_dump="$(mktemp)" + log "dumping native staged database: $db" + "$NATIVE_MYSQLDUMP_BIN" --defaults-extra-file="$NATIVE_MY_CNF" \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$db" > "$tmp_dump" 2>>"$LOG_FILE" || { + rm -f "$tmp_dump" + die "cannot dump native staged database: $db" + } + + log "importing database into alt-mariadb: $db" + backup_alt_database_for_rollback "$db" || { + rm -f "$tmp_dump" + die "cannot create rollback dump before importing native staged database: $db" + } + if ! mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db");" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + rm -f "$tmp_dump" + die "cannot drop target database before importing native staged database: $db" + fi + if ! mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1; then + restore_alt_database_from_rollback "$db" || true + rm -f "$tmp_dump" + die "cannot create target database before importing native staged database: $db" + fi + mysql_exec "$db" < "$tmp_dump" >> "$LOG_FILE" 2>&1 || { + restore_alt_database_from_rollback "$db" || true + rm -f "$tmp_dump" + die "cannot import native staged database into alt-mariadb: $db" + } + rm -f "$tmp_dump" + cleanup_restore_rollback + verify_alt_database "$db" + rebuild_metadata_for_db "$db" + done < <(list_native_databases) + + while IFS=$'\t' read -r user host; do + [ -n "${user:-}" ] || continue + [ -n "${host:-}" ] || continue + log "applying preserved grants: ${user}@${host}" + apply_grants_to_alt "$user" "$host" + done < <(list_native_user_hosts) + + mysql_exec mysql -e "FLUSH PRIVILEGES;" >> "$LOG_FILE" 2>&1 || true + sync_remote_hosts_if_enabled + + if [ "$cleanup" = "after_success" ]; then + drop_native_staging + else + log "native staging cleanup disabled by policy" + fi + + log "native staging migration completed: user=$DA_USER" +} + +main "$@" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..4dee155 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,249 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SETTINGS_FILE="${ALT_MYSQL_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}" +CPIF="${ALT_MYSQL_CUSTOM_PACKAGE_ITEMS_CONF:-/usr/local/directadmin/data/admin/custom_package_items.conf}" +SUDOERS_FILE="/etc/sudoers.d/directadmin-alt-mysql-plugin" +CRON_FILE="/etc/cron.d/alt-mysql-jobs" +IS_ROOT=0 +if [ "${ALT_MYSQL_TEST_ASSUME_ROOT:-0}" = "1" ] || [ "$(id -u)" -eq 0 ]; then + IS_ROOT=1 +fi + +read_plugin_setting() { + local key="$1" + local default="$2" + local line value + + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + if [ -z "$line" ]; then + printf '%s\n' "$default" + return 0 + fi + + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +setting_enabled() { + case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in + 1|yes|true|on) return 0 ;; + *) return 1 ;; + esac +} + +always_enabled() { + setting_enabled "$(read_plugin_setting always_enabled 1)" +} + +remove_custom_package_item() { + local key="$1" + local tmp + + [ -f "$CPIF" ] || return 0 + tmp="$(mktemp)" + grep -Ev "^${key}=" "$CPIF" > "$tmp" || true + cat "$tmp" > "$CPIF" + rm -f "$tmp" +} + +set_permissions() { + local owner="$1" + local mode="$2" + local path="$3" + if [ -e "$path" ]; then + chown "$owner" "$path" 2>/dev/null || true + chmod "$mode" "$path" 2>/dev/null || true + fi +} + +set_mode_if_exists() { + local mode="$1" + local path="$2" + if [ -e "$path" ]; then + chmod "$mode" "$path" 2>/dev/null || true + fi +} + +bootstrap_custom_package_items() { + if [ "$IS_ROOT" -ne 1 ]; then + echo "mysql: warning: uruchom jako root, aby zaktualizować $CPIF" >&2 + return 0 + fi + + touch "$CPIF" + + remove_custom_package_item mysql_enabled + remove_custom_package_item mysql + remove_custom_package_item umysql + remove_custom_package_item mysql_max_databases + remove_custom_package_item mysql_unlimited + remove_custom_package_item umysql_max_databases + + if always_enabled; then + set_permissions diradmin:diradmin 640 "$CPIF" + return 0 + fi + + cat >> "$CPIF" <<'EOF' +mysql_enabled=type=checkbox&string=Enable mysql&desc=Zezwól na dostęp do baz danych MySQL&default=no +mysql=type=text&string=Bazy MySQL&desc=&default=5 +umysql=type=checkbox&string=Bez ograniczeń&desc=&default=no&custom=autofocus%20onfocus%3D%22%28function%28c%29%7Bif%28c.dataset.pgInit%3D%3D%3D%271%27%29%7Breturn%3B%7Dc.dataset.pgInit%3D%271%27%3Bvar%20l%3Ddocument.querySelector%28%22input%5Bname%3D%27mysql%27%5D%22%29%3Bif%28%21l%29%7Breturn%3B%7Dvar%20r%3Dc.closest%28%27tr%27%29%3Bvar%20td%3Dl.closest%28%27td%27%29%3Bif%28r%26%26td%29%7Bvar%20w%3Ddocument.getElementById%28%27da-pg-unlim-wrap%27%29%3Bif%28%21w%29%7Bw%3Ddocument.createElement%28%27label%27%29%3Bw.id%3D%27da-pg-unlim-wrap%27%3Bw.style.display%3D%27inline-flex%27%3Bw.style.alignItems%3D%27center%27%3Bw.style.gap%3D%278px%27%3Bw.style.marginLeft%3D%2712px%27%3Bw.appendChild%28c%29%3Bw.appendChild%28document.createTextNode%28%27Bez%20ogranicze%C5%84%27%29%29%3Btd.style.display%3D%27flex%27%3Btd.style.alignItems%3D%27center%27%3Btd.style.justifyContent%3D%27space-between%27%3Btd.appendChild%28w%29%3B%7Dr.style.display%3D%27none%27%3B%7Dvar%20s%3Dfunction%28%29%7Bl.disabled%3D%21%21c.checked%3B%7D%3Bc.addEventListener%28%27change%27%2Cs%29%3Bs%28%29%3B%7D%29%28this%29%22%20onchange%3D%22var%20l%3Ddocument.querySelector%28%22input%5Bname%3D%27mysql%27%5D%22%29%3Bif%28l%29%7Bl.disabled%3D%21%21this.checked%3B%7D%22 +EOF + + set_permissions diradmin:diradmin 640 "$CPIF" +} + +bootstrap_sudoers() { + if [ "$IS_ROOT" -ne 1 ]; then + echo "mysql: warning: uruchom jako root, aby dodać sudoers dla synchronizacji hostów MySQL" >&2 + return 0 + fi + + cat > "$SUDOERS_FILE" <&2 + return 0 + fi + + touch "$CRON_FILE" + if ! grep -Fq '/usr/local/directadmin/plugins/alt-mysql/scripts/worker.sh' "$CRON_FILE"; then + echo '* * * * * root /usr/local/directadmin/plugins/alt-mysql/scripts/worker.sh >/dev/null 2>&1' >> "$CRON_FILE" + fi + chmod 644 "$CRON_FILE" 2>/dev/null || true +} + +bootstrap_phpmyadmin() { + local phpmyadmin_setup="$PLUGIN_DIR/scripts/setup/phpmyadmin_private_install.sh" + + if [ "$IS_ROOT" -ne 1 ]; then + echo "mysql: warning: uruchom jako root, aby przygotować integrację phpMyAdmin." >&2 + return 0 + fi + + if [ ! -f "$phpmyadmin_setup" ]; then + echo "mysql: error: brak skryptu integracji phpMyAdmin: $phpmyadmin_setup" >&2 + return 1 + fi + + bash "$phpmyadmin_setup" +} + +bootstrap_da_integration() { + local da_setup="$PLUGIN_DIR/scripts/setup/da_integration_install.sh" + + if [ "$IS_ROOT" -ne 1 ]; then + echo "mysql: warning: uruchom jako root, aby zainstalować hooki integracji DirectAdmin backup/restore." >&2 + return 0 + fi + + if [ ! -f "$da_setup" ]; then + echo "mysql: error: brak skryptu integracji DirectAdmin: $da_setup" >&2 + return 1 + fi + + bash "$da_setup" install +} + +bootstrap_custombuild_hooks() { + local custombuild_setup="$PLUGIN_DIR/scripts/setup/custombuild_hooks_install.sh" + + if [ "$IS_ROOT" -ne 1 ]; then + echo "mysql: warning: uruchom jako root, aby zainstalować hooki CustomBuild po aktualizacjach Roundcube/phpMyAdmin." >&2 + return 0 + fi + + if [ ! -f "$custombuild_setup" ]; then + echo "mysql: error: brak skryptu hooków CustomBuild: $custombuild_setup" >&2 + return 1 + fi + + bash "$custombuild_setup" install +} + +activate_plugin() { + local conf="$PLUGIN_DIR/plugin.conf" + if [ -f "$conf" ]; then + sed -i 's/^active=.*/active=yes/' "$conf" || true + sed -i 's/^installed=.*/installed=yes/' "$conf" || true + fi +} + +if [ "${ALT_MYSQL_TEST_ONLY_CUSTOM_PACKAGE_ITEMS:-0}" = "1" ]; then + bootstrap_custom_package_items + exit 0 +fi + +mkdir -p "$PLUGIN_DIR/data" +mkdir -p "$PLUGIN_DIR/exec" "$PLUGIN_DIR/exec/lib" "$PLUGIN_DIR/exec/handlers" +mkdir -p "$PLUGIN_DIR/data/jobs/pending" "$PLUGIN_DIR/data/jobs/processing" "$PLUGIN_DIR/data/jobs/done" +mkdir -p "$PLUGIN_DIR/data/logs" "$PLUGIN_DIR/data/lock" "$PLUGIN_DIR/data/pids" "$PLUGIN_DIR/data/cancel" +mkdir -p "$PLUGIN_DIR/data/uploads" + +if [ "$IS_ROOT" -eq 1 ]; then + chown -R diradmin:diradmin "$PLUGIN_DIR" 2>/dev/null || true +else + echo "mysql: warning: uruchom jako root, aby ustawić właściciela plików pluginu (w przeciwnym razie może wystąpić biała strona)." >&2 +fi + +find "$PLUGIN_DIR" -type d -exec chmod 755 {} + 2>/dev/null || true +set_mode_if_exists 700 "$PLUGIN_DIR/data" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/pending" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/processing" +set_mode_if_exists 700 "$PLUGIN_DIR/data/jobs/done" +set_mode_if_exists 700 "$PLUGIN_DIR/data/logs" +set_mode_if_exists 700 "$PLUGIN_DIR/data/lock" +set_mode_if_exists 700 "$PLUGIN_DIR/data/pids" +set_mode_if_exists 700 "$PLUGIN_DIR/data/cancel" +set_mode_if_exists 700 "$PLUGIN_DIR/data/uploads" +find "$PLUGIN_DIR" -type f -name '*.html' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.raw' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.css' -exec chmod 644 {} + 2>/dev/null || true +find "$PLUGIN_DIR/scripts" -type f -name '*.sh' -exec chmod 755 {} + 2>/dev/null || true +find "$PLUGIN_DIR" -type f -name '*.php' -exec chmod 640 {} + 2>/dev/null || true +set_mode_if_exists 644 "$PLUGIN_DIR/plugin.conf" +set_mode_if_exists 644 "$PLUGIN_DIR/plugin-settings.conf" +set_mode_if_exists 644 "$PLUGIN_DIR/php.ini" + +if [ ! -f "$PLUGIN_DIR/data/secret.key" ]; then + tr -dc 'a-f0-9' "$PLUGIN_DIR/data/secret.key" || true +fi +set_mode_if_exists 600 "$PLUGIN_DIR/data/secret.key" + +touch "$PLUGIN_DIR/error.log" 2>/dev/null || true +if [ "$IS_ROOT" -eq 1 ]; then + chown diradmin:diradmin "$PLUGIN_DIR/error.log" 2>/dev/null || true +fi +set_mode_if_exists 640 "$PLUGIN_DIR/error.log" + +bootstrap_custom_package_items +bootstrap_sudoers +bootstrap_worker_cron +bootstrap_phpmyadmin +bootstrap_da_integration +bootstrap_custombuild_hooks +activate_plugin + +echo "mysql: instalacja zakończona." +echo "Przypomnienie: zapisz pakiety i user.conf w DirectAdmin, aby odświeżyć custom package items." diff --git a/scripts/install_db.sh b/scripts/install_db.sh new file mode 100755 index 0000000..bc9cb2d --- /dev/null +++ b/scripts/install_db.sh @@ -0,0 +1,872 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ============================================================================== +# Konfiguracja instalacji alternatywnej instancji MariaDB dla serwera DirectAdmin +# ============================================================================== + +MARIADB_VERSION="10.11" +MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION" +DATADIR="/var/lib/mariadb_data" +PORT="33033" +SOCKET="/var/lib/mariadb_data/mariadb.sock" + +MYSQL_USER_SOURCE_FILE="/usr/local/directadmin/conf/mysql.conf" +MYSQL_CLIENT_SOURCE_FILE="/usr/local/directadmin/conf/my.cnf" +ALT_MYSQL_CONF="/usr/local/directadmin/conf/alt-mysql.conf" +ALT_MY_CNF="/usr/local/directadmin/conf/alt-my.cnf" +ALT_METADATA_ENV="/usr/local/directadmin/conf/alt-mariadb.env" +INSTANCE_CNF="/etc/alt-mariadb.cnf" +SERVICE_NAME="alt-mariadb" +SERVICE_FILE="/etc/systemd/system/alt-mariadb.service" +LOG_DIR="/var/log/alt-mariadb" +RUN_DIR="/run/alt-mariadb" +SYSTEM_USER="mysql" +SYSTEM_GROUP="mysql" +BIND_ADDRESS="127.0.0.1" + +FORCE_INSTALL="no" +FORCE_REINIT="no" +FORCE_SERVICE_REWRITE="yes" + +# Jeżeli automatyczne wykrywanie adresu tarballa zawiedzie, ustaw pełny oficjalny URL ręcznie. +MARIADB_TARBALL_URL="" + +# Katalog roboczy pobierania. Skrypt nie używa pakietów RPM/DNF MariaDB ani MySQL. +DOWNLOAD_DIR="/usr/local/src" + +ALT_ADMIN_USER="" +ALT_ADMIN_PASS="" +DA_PORT="" +DA_SOCKET="" +DA_HOST="" +DA_DATADIR="" +TARBALL_PATH="" +TMP_DIR="" +RESOLVED_MARIADB_RELEASE="" +DOWNLOAD_URL="" + +log() { + printf '[INFO] %s\n' "$*" +} + +warn() { + printf '[UWAGA] %s\n' "$*" >&2 +} + +die() { + printf '[BŁĄD] %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${TMP_DIR:-}" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT + +usage() { + cat < + +Przykłady: + $0 10.11 33033 + $0 11.4 3020 + $0 11.8 3021 + +Dozwolone gałęzie MariaDB: 10.11, 11.4, 11.8. +EOF +} + +usage_error() { + usage >&2 + printf '[BŁĄD] %s\n' "$*" >&2 + exit 2 +} + +apply_cli_args() { + if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + usage + exit 0 + fi + + if [[ $# -eq 0 ]]; then + usage + exit 2 + fi + + if [[ $# -gt 2 ]]; then + usage_error "Podano zbyt wiele argumentów. Oczekiwano: MARIADB_VERSION PORT." + fi + + if [[ $# -lt 2 ]]; then + usage_error "Podaj wersję MariaDB i port TCP, np. 11.4 3020." + fi + + if [[ $# -ge 1 && -n "${1:-}" ]]; then + MARIADB_VERSION="$1" + fi + + if [[ $# -ge 2 && -n "${2:-}" ]]; then + PORT="$2" + fi + + MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION" +} + +require_root() { + if [[ "$(id -u)" -ne 0 ]]; then + die "Ten skrypt musi zostać uruchomiony jako root." + fi +} + +detect_os() { + [[ -r /etc/os-release ]] || die "Nie można odczytać /etc/os-release." + . /etc/os-release + + local os_id="${ID:-}" + local os_like="${ID_LIKE:-}" + + case "$os_id" in + almalinux|rhel|rocky|centos|ol) + log "Wykryto zgodny system: ${PRETTY_NAME:-$os_id}." + ;; + *) + if [[ "$os_like" == *"rhel"* || "$os_like" == *"fedora"* ]]; then + warn "System wygląda na zgodny z RHEL: ${PRETTY_NAME:-$os_id}." + else + die "Ten skrypt jest przeznaczony dla AlmaLinux lub systemów zgodnych z RHEL." + fi + ;; + esac +} + +read_kv_file_value() { + local file="$1" + local key="$2" + + awk -F= -v key="$key" ' + $1 == key { + sub(/^[^=]*=/, "") + print + exit + } + ' "$file" +} + +load_da_credentials() { + [[ -d /usr/local/directadmin ]] || die "Nie znaleziono katalogu /usr/local/directadmin." + [[ -f "$MYSQL_USER_SOURCE_FILE" ]] || die "Nie znaleziono pliku $MYSQL_USER_SOURCE_FILE." + [[ -f "$MYSQL_CLIENT_SOURCE_FILE" ]] || die "Nie znaleziono pliku $MYSQL_CLIENT_SOURCE_FILE." + + ALT_ADMIN_USER="$(read_kv_file_value "$MYSQL_USER_SOURCE_FILE" "user")" + ALT_ADMIN_PASS="$(read_kv_file_value "$MYSQL_USER_SOURCE_FILE" "passwd")" + DA_PORT="$(read_kv_file_value "$MYSQL_USER_SOURCE_FILE" "port")" + DA_SOCKET="$(read_kv_file_value "$MYSQL_USER_SOURCE_FILE" "socket")" + DA_HOST="$(read_kv_file_value "$MYSQL_USER_SOURCE_FILE" "host")" + + [[ -n "$ALT_ADMIN_USER" ]] || die "Nie udało się odczytać pola user z $MYSQL_USER_SOURCE_FILE." + [[ -n "$ALT_ADMIN_PASS" ]] || die "Nie udało się odczytać pola passwd z $MYSQL_USER_SOURCE_FILE." + + DA_DATADIR="$(detect_da_datadir || true)" + log "Odczytano dane administratora DirectAdmin bez wyświetlania hasła." +} + +detect_da_datadir() { + local candidate_files=( + "/etc/my.cnf" + "/etc/mysql/my.cnf" + ) + local file="" + + for file in "${candidate_files[@]}"; do + [[ -r "$file" ]] || continue + awk -F= ' + /^[[:space:]]*datadir[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + gsub(/^"|"$/, "", value) + print value + exit + } + ' "$file" + return 0 + done + + if [[ -d /var/lib/mysql ]]; then + printf '/var/lib/mysql\n' + fi +} + +validate_version() { + case "$MARIADB_VERSION" in + 10.11|11.4|11.8) + log "Wybrano obsługiwaną wersję MariaDB: $MARIADB_VERSION." + ;; + *) + die "Nieobsługiwana wersja MariaDB: $MARIADB_VERSION. Dozwolone wartości: 10.11, 11.4, 11.8." + ;; + esac +} + +validate_port() { + local numeric_port="" + + [[ "$PORT" =~ ^[0-9]+$ ]] || die "Nieprawidłowy port MariaDB: $PORT. Podaj liczbę od 1 do 65535." + numeric_port=$((10#$PORT)) + (( numeric_port >= 1 && numeric_port <= 65535 )) || die "Nieprawidłowy port MariaDB: $PORT. Podaj liczbę od 1 do 65535." + + log "Wybrano port TCP alternatywnej instancji MariaDB: $PORT." +} + +canonical_path() { + local path="$1" + if command -v realpath >/dev/null 2>&1; then + realpath -m "$path" + else + printf '%s\n' "$path" + fi +} + +port_is_listening() { + local port="$1" + + if command -v ss >/dev/null 2>&1; then + ss -ltn "( sport = :$port )" 2>/dev/null | awk 'NR > 1 { found=1 } END { exit !found }' + return $? + fi + + if command -v lsof >/dev/null 2>&1; then + lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return $? + fi + + warn "Nie znaleziono narzędzia ss ani lsof, więc nie można pewnie sprawdzić zajętości portu." + return 1 +} + +validate_paths() { + local alt_socket_canon da_socket_canon alt_datadir_canon da_datadir_canon + + alt_socket_canon="$(canonical_path "$SOCKET")" + da_socket_canon="$(canonical_path "${DA_SOCKET:-}")" + alt_datadir_canon="$(canonical_path "$DATADIR")" + da_datadir_canon="$(canonical_path "${DA_DATADIR:-}")" + + [[ "$alt_socket_canon" != "$da_socket_canon" ]] || die "Alternatywny socket jest taki sam jak socket DirectAdmin: $SOCKET." + [[ "$alt_datadir_canon" != "$(canonical_path /var/lib/mysql)" ]] || die "Alternatywny katalog danych nie może wskazywać na /var/lib/mysql." + + if [[ -n "${DA_DATADIR:-}" ]]; then + [[ "$alt_datadir_canon" != "$da_datadir_canon" ]] || die "Alternatywny katalog danych jest taki sam jak wykryty katalog danych DirectAdmin: $DATADIR." + fi + + if [[ -n "${DA_PORT:-}" ]]; then + [[ "$PORT" != "$DA_PORT" ]] || die "Alternatywny port $PORT jest taki sam jak port MariaDB DirectAdmin." + fi + + if port_is_listening "$PORT"; then + die "Port $PORT jest już zajęty. Zmień PORT w sekcji konfiguracji." + fi + + if [[ -S "$SOCKET" ]] && ! systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + die "Socket $SOCKET już istnieje, ale usługa $SERVICE_NAME nie jest aktywna. Przerwano dla bezpieczeństwa." + fi + + [[ "$SOCKET" == "$DATADIR"* || "$(dirname "$SOCKET")" != "/var/lib/mysql" ]] || die "Socket nie może znajdować się w katalogu DirectAdmin MariaDB." + + log "Sprawdzono rozdzielenie portu, socketu i katalogu danych od instancji DirectAdmin." +} + +install_dependencies() { + local deps=(curl tar gzip xz libaio ncurses-compat-libs) + local dep="" + local safe_deps=() + + command -v dnf >/dev/null 2>&1 || die "Nie znaleziono dnf. Ten skrypt instaluje neutralne zależności wyłącznie przez dnf." + + for dep in "${deps[@]}"; do + if [[ "$dep" == *mariadb* || "$dep" == *mysql* || "$dep" == *MariaDB* || "$dep" == *MySQL* ]]; then + die "Wewnętrzny błąd bezpieczeństwa: próba instalacji niedozwolonego pakietu $dep." + fi + safe_deps+=("$dep") + done + + log "Instaluję neutralne zależności systemowe bez pakietów MariaDB/MySQL." + dnf install -y "${safe_deps[@]}" +} + +detect_architecture() { + local machine + machine="$(uname -m)" + + case "$machine" in + x86_64|amd64) + printf 'x86_64\n' + ;; + *) + die "Nieobsługiwana architektura: $machine. Skrypt obsługuje automatycznie tylko x86_64/amd64." + ;; + esac +} + +resolve_latest_release_from_archive() { + local series="$1" + local listing="" + local release="" + + listing="$(curl -fsSL "https://archive.mariadb.org/" || true)" + release="$(printf '%s\n' "$listing" \ + | grep -Eo "mariadb-${series//./\\.}\.[0-9]+/" \ + | sed 's#^mariadb-##; s#/$##' \ + | sort -V \ + | tail -n 1)" + + [[ -n "$release" ]] || return 1 + printf '%s\n' "$release" +} + +url_exists() { + local url="$1" + curl -fsIL --retry 2 --connect-timeout 15 "$url" >/dev/null 2>&1 +} + +resolve_tarball_from_directory() { + local release="$1" + local arch="$2" + local dir_url="https://archive.mariadb.org/mariadb-${release}/bintar-linux-systemd-${arch}/" + local listing="" + local file="" + + listing="$(curl -fsSL "$dir_url" || true)" + file="$(printf '%s\n' "$listing" \ + | grep -Eo "mariadb-${release}-linux-systemd-${arch}\.tar\.(gz|xz)" \ + | head -n 1)" + + [[ -n "$file" ]] || return 1 + printf '%s%s\n' "$dir_url" "$file" +} + +resolve_download_url() { + local arch="" + local candidate="" + local candidates=() + + arch="$(detect_architecture)" + + if [[ -n "$MARIADB_TARBALL_URL" ]]; then + DOWNLOAD_URL="$MARIADB_TARBALL_URL" + log "Używam ręcznie ustawionego adresu tarballa MariaDB." + return 0 + fi + + log "Wyszukuję oficjalny tarball MariaDB w archiwum MariaDB dla gałęzi $MARIADB_VERSION." + warn "Nazwy tarballi mogą zależeć od schematu mirrorów MariaDB; w razie błędu ustaw MARIADB_TARBALL_URL ręcznie." + + RESOLVED_MARIADB_RELEASE="$(resolve_latest_release_from_archive "$MARIADB_VERSION" || true)" + [[ -n "$RESOLVED_MARIADB_RELEASE" ]] || die "Nie udało się wykryć najnowszego wydania dla gałęzi $MARIADB_VERSION." + + if candidate="$(resolve_tarball_from_directory "$RESOLVED_MARIADB_RELEASE" "$arch" || true)"; then + DOWNLOAD_URL="$candidate" + log "Wykryto tarball MariaDB $RESOLVED_MARIADB_RELEASE." + return 0 + fi + + candidates=( + "https://archive.mariadb.org/mariadb-${RESOLVED_MARIADB_RELEASE}/bintar-linux-systemd-${arch}/mariadb-${RESOLVED_MARIADB_RELEASE}-linux-systemd-${arch}.tar.gz" + "https://archive.mariadb.org/mariadb-${RESOLVED_MARIADB_RELEASE}/bintar-linux-systemd-${arch}/mariadb-${RESOLVED_MARIADB_RELEASE}-linux-systemd-${arch}.tar.xz" + "https://downloads.mariadb.org/f/mariadb-${RESOLVED_MARIADB_RELEASE}/bintar-linux-systemd-${arch}/mariadb-${RESOLVED_MARIADB_RELEASE}-linux-systemd-${arch}.tar.gz/from/https%3A//archive.mariadb.org/" + ) + + for candidate in "${candidates[@]}"; do + if url_exists "$candidate"; then + DOWNLOAD_URL="$candidate" + log "Wykryto działający adres tarballa MariaDB." + return 0 + fi + done + + die "Nie udało się automatycznie ustalić URL tarballa. Ustaw MARIADB_TARBALL_URL w sekcji konfiguracji." +} + +download_tarball() { + local filename="" + + [[ -n "$DOWNLOAD_URL" ]] || die "Brak adresu pobierania tarballa." + install -d -m 0755 "$DOWNLOAD_DIR" + + filename="$(basename "${DOWNLOAD_URL%%\?*}")" + [[ "$filename" == *.tar.gz || "$filename" == *.tar.xz || "$filename" == *.tgz ]] || filename="mariadb-${RESOLVED_MARIADB_RELEASE:-$MARIADB_VERSION}-linux-systemd.tar.gz" + TARBALL_PATH="$DOWNLOAD_DIR/$filename" + + log "Pobieram oficjalny tarball MariaDB do $TARBALL_PATH." + curl -fL --retry 3 --connect-timeout 20 -o "$TARBALL_PATH" "$DOWNLOAD_URL" + [[ -s "$TARBALL_PATH" ]] || die "Pobrany tarball jest pusty albo nie istnieje: $TARBALL_PATH." +} + +extract_tarball() { + local parent_dir="" + local extracted_dir="" + local backup_dir="" + + [[ -s "$TARBALL_PATH" ]] || die "Nie znaleziono tarballa do rozpakowania." + + if [[ -d "$MARIADB_DIR" ]] && [[ -n "$(find "$MARIADB_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + if [[ -x "$MARIADB_DIR/bin/mariadbd" && "$FORCE_INSTALL" != "yes" ]]; then + log "Katalog $MARIADB_DIR już zawiera binaria MariaDB, używam istniejącej instalacji programu." + return 0 + fi + if [[ "$FORCE_INSTALL" != "yes" ]]; then + die "Katalog $MARIADB_DIR już istnieje i nie jest pusty. Ustaw FORCE_INSTALL=\"yes\", aby pozwolić na bezpieczne zastąpienie." + fi + backup_dir="${MARIADB_DIR}.backup.$(date +%Y%m%d%H%M%S)" + log "Tworzę kopię istniejącego katalogu programu: $backup_dir." + mv "$MARIADB_DIR" "$backup_dir" + fi + + parent_dir="$(dirname "$MARIADB_DIR")" + install -d -m 0755 "$parent_dir" + TMP_DIR="$(mktemp -d)" + + log "Rozpakowuję tarball MariaDB do katalogu tymczasowego." + tar -xf "$TARBALL_PATH" -C "$TMP_DIR" + + extracted_dir="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d -print -quit)" + [[ -n "$extracted_dir" ]] || die "Tarball nie zawiera oczekiwanego katalogu głównego MariaDB." + + rm -rf "$MARIADB_DIR" + mv "$extracted_dir" "$MARIADB_DIR" + chown -R root:root "$MARIADB_DIR" + log "MariaDB została rozpakowana do $MARIADB_DIR." +} + +prepare_user_and_dirs() { + local socket_dir="" + + if getent passwd "$SYSTEM_USER" >/dev/null 2>&1 && getent group "$SYSTEM_GROUP" >/dev/null 2>&1; then + log "Używam istniejącego użytkownika systemowego $SYSTEM_USER:$SYSTEM_GROUP." + elif getent passwd mysql >/dev/null 2>&1 && getent group mysql >/dev/null 2>&1; then + SYSTEM_USER="mysql" + SYSTEM_GROUP="mysql" + log "Używam istniejącego użytkownika systemowego mysql:mysql." + else + SYSTEM_USER="altmysql" + SYSTEM_GROUP="altmysql" + if ! getent group "$SYSTEM_GROUP" >/dev/null 2>&1; then + groupadd --system "$SYSTEM_GROUP" + fi + if ! getent passwd "$SYSTEM_USER" >/dev/null 2>&1; then + useradd --system --gid "$SYSTEM_GROUP" --home-dir "$DATADIR" --shell /sbin/nologin "$SYSTEM_USER" + fi + log "Utworzono dedykowanego użytkownika systemowego $SYSTEM_USER:$SYSTEM_GROUP." + fi + + socket_dir="$(dirname "$SOCKET")" + install -d -m 0750 -o "$SYSTEM_USER" -g "$SYSTEM_GROUP" "$DATADIR" + install -d -m 0750 -o "$SYSTEM_USER" -g "$SYSTEM_GROUP" "$LOG_DIR" + install -d -m 0750 -o "$SYSTEM_USER" -g "$SYSTEM_GROUP" "$socket_dir" + install -d -m 0750 -o "$SYSTEM_USER" -g "$SYSTEM_GROUP" "$RUN_DIR" + chown -R "$SYSTEM_USER:$SYSTEM_GROUP" "$DATADIR" "$LOG_DIR" "$socket_dir" +} + +backup_existing_file() { + local file="$1" + local backup="" + + if [[ -e "$file" ]]; then + backup="${file}.backup.$(date +%Y%m%d%H%M%S)" + cp -a "$file" "$backup" + log "Utworzono kopię pliku $file jako $backup." + fi +} + +write_file_atomic() { + local target="$1" + local mode="$2" + local owner="$3" + local group="$4" + local tmp_file="" + + tmp_file="$(mktemp)" + cat > "$tmp_file" + install -m "$mode" -o "$owner" -g "$group" "$tmp_file" "$target" + rm -f "$tmp_file" +} + +cnf_double_quote() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\"/\\\"}" + printf '%s' "$value" +} + +write_instance_config() { + backup_existing_file "$INSTANCE_CNF" + + write_file_atomic "$INSTANCE_CNF" 0640 root "$SYSTEM_GROUP" </dev/null 2>&1; then + printf 'diradmin\n' + else + printf 'root\n' + fi +} + +write_da_style_credential_files() { + local group_name="" + local cnf_user="" + local cnf_pass="" + local cnf_socket="" + local credential_mode="0600" + + group_name="$(credential_group)" + if [[ "$group_name" == "diradmin" ]]; then + credential_mode="0640" + fi + + cnf_user="$(cnf_double_quote "$ALT_ADMIN_USER")" + cnf_pass="$(cnf_double_quote "$ALT_ADMIN_PASS")" + cnf_socket="$(cnf_double_quote "$SOCKET")" + + backup_existing_file "$ALT_MYSQL_CONF" + backup_existing_file "$ALT_MY_CNF" + + umask 077 + write_file_atomic "$ALT_MYSQL_CONF" "$credential_mode" root "$group_name" <&2 || true + else + warn "Plik logu $LOG_DIR/alt-mariadb.log jeszcze nie istnieje." + fi + + if command -v journalctl >/dev/null 2>&1; then + warn "Ostatnie wpisy journalctl dla $SERVICE_NAME:" + journalctl -u "$SERVICE_NAME" -n 80 --no-pager >&2 || true + fi +} + +start_service() { + local i="" + + log "Włączam i uruchamiam usługę $SERVICE_NAME." + systemctl enable "$SERVICE_NAME" + systemctl restart "$SERVICE_NAME" + + for i in $(seq 1 60); do + if [[ -S "$SOCKET" ]]; then + log "Socket alternatywnej instancji jest dostępny." + return 0 + fi + sleep 1 + done + + systemctl status "$SERVICE_NAME" --no-pager || true + print_service_diagnostics + die "Socket $SOCKET nie pojawił się w ciągu 60 sekund." +} + +sql_quote() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\'/\'\'}" + printf "'%s'" "$value" +} + +run_sql_as_root_or_admin() { + local sql="$1" + + if "$MARIADB_DIR/bin/mariadb" --defaults-extra-file="$ALT_MY_CNF" -e "SELECT 1;" >/dev/null 2>&1; then + printf '%s\n' "$sql" | "$MARIADB_DIR/bin/mariadb" --defaults-extra-file="$ALT_MY_CNF" + return 0 + fi + + if "$MARIADB_DIR/bin/mariadb" --protocol=socket --socket="$SOCKET" -uroot -e "SELECT 1;" >/dev/null 2>&1; then + printf '%s\n' "$sql" | "$MARIADB_DIR/bin/mariadb" --protocol=socket --socket="$SOCKET" -uroot + return 0 + fi + + die "Nie można zalogować się do alternatywnej instancji jako root bez hasła ani przez plik $ALT_MY_CNF." +} + +create_admin_user() { + local quoted_user="" + local quoted_pass="" + local sql="" + + quoted_user="$(sql_quote "$ALT_ADMIN_USER")" + quoted_pass="$(sql_quote "$ALT_ADMIN_PASS")" + + sql=" +CREATE USER IF NOT EXISTS ${quoted_user}@'localhost' IDENTIFIED BY ${quoted_pass}; +ALTER USER ${quoted_user}@'localhost' IDENTIFIED BY ${quoted_pass}; +GRANT ALL PRIVILEGES ON *.* TO ${quoted_user}@'localhost' WITH GRANT OPTION; +CREATE USER IF NOT EXISTS ${quoted_user}@'127.0.0.1' IDENTIFIED BY ${quoted_pass}; +ALTER USER ${quoted_user}@'127.0.0.1' IDENTIFIED BY ${quoted_pass}; +GRANT ALL PRIVILEGES ON *.* TO ${quoted_user}@'127.0.0.1' WITH GRANT OPTION; +FLUSH PRIVILEGES; +" + + log "Tworzę lub aktualizuję konto administratora alternatywnej instancji bez wyświetlania hasła." + run_sql_as_root_or_admin "$sql" +} + +run_final_tests() { + log "Wykonuję test połączenia przez socket." + "$MARIADB_DIR/bin/mariadb" --defaults-extra-file="$ALT_MY_CNF" -e "SELECT VERSION();" + + log "Wykonuję test połączenia TCP do 127.0.0.1 bez wyświetlania hasła w komunikacie." + "$MARIADB_DIR/bin/mariadb" \ + --defaults-extra-file="$ALT_MY_CNF" \ + --protocol=TCP \ + --host=127.0.0.1 \ + --port="$PORT" \ + -e "SELECT VERSION();" +} + +rerun_plugin_install_if_available() { + local script_dir="" + local plugin_install="" + + if [[ "${SKIP_PLUGIN_INSTALL_REFRESH:-0}" == "1" ]]; then + warn "Pominięto odświeżenie install.sh pluginu, bo SKIP_PLUGIN_INSTALL_REFRESH=1." + return 0 + fi + + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + plugin_install="$script_dir/install.sh" + + if [[ ! -f "$plugin_install" ]]; then + warn "Nie znaleziono install.sh pluginu przy $plugin_install; pomijam odświeżenie pluginu." + return 0 + fi + + log "Odświeżam instalację pluginu alt-mysql przez $plugin_install." + if [[ -x "$plugin_install" ]]; then + "$plugin_install" + else + bash "$plugin_install" + fi +} + +print_summary() { + cat <&2 + exit 1 +fi + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CONFIG_FILE="${PLUGIN_DIR}/plugin-settings.conf" +LOG_DIR="${PLUGIN_DIR}/data/logs" +COMMON_FILE="${PLUGIN_DIR}/scripts/setup/mysql_common.sh" +mkdir -p "$LOG_DIR" + +urlencode() { + local s="$1" + local out="" + local i c + for ((i=0; i<${#s}; i++)); do + c="${s:i:1}" + case "$c" in + [a-zA-Z0-9.~_-]) out+="$c" ;; + ' ') out+='%20' ;; + *) printf -v c '%%%02X' "'$c"; out+="$c" ;; + esac + done + printf '%s' "$out" +} + +notify_user() { + local code="$1" + if [ -z "${DA_USER:-}" ]; then + return 0 + fi + + local subject message + if [ "$code" -eq 0 ] && [ "$RESTORE_OK" -eq 1 ]; then + subject="Przywracanie bazy MySQL zakończone pomyślnie" + message="Przywracanie bazy ${DB_NAME} z pliku ${SOURCE_FILE:-nieznany} zakończone." + else + subject="Błąd przywracania bazy MySQL" + if [ -n "$ERROR_MSG" ]; then + message="Przywracanie bazy ${DB_NAME} nie powiodło się: ${ERROR_MSG}" + else + message="Przywracanie bazy ${DB_NAME} nie powiodło się. Sprawdź log zadania." + fi + fi + + local task_queue="/usr/local/directadmin/data/task.queue" + local users="select1%3D$(urlencode "$DA_USER")" + local line="action=notify&value=users&subject=$(urlencode "$subject")&message=$(urlencode "$message")&users=${users}" + printf "%s\n" "$line" >> "$task_queue" +} + +fail() { + local msg="$1" + ERROR_MSG="$msg" + if [ -n "${LOG_FILE:-}" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${msg}" >> "$LOG_FILE" + fi +} + +log_unexpected_error() { + local line_no="$1" + local command="$2" + local code="$3" + if [ -z "${LOG_FILE:-}" ]; then + return 0 + fi + if [ -n "${ERROR_MSG:-}" ]; then + return 0 + fi + + ERROR_MSG="Nieoczekiwany błąd wykonywania przywracania (kod ${code}, linia ${line_no})." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG} CMD: ${command}" >> "$LOG_FILE" +} + +load_plugin_settings() { + if [ ! -f "$CONFIG_FILE" ]; then + fail "Brak pliku konfiguracyjnego pluginu: $CONFIG_FILE" + exit 1 + fi + # shellcheck disable=SC1090 + source "$CONFIG_FILE" +} + +sanitize_mysql_dump() { + awk ' + function lower(s) { return tolower(s) } + { + line = $0 + l = lower(line) + if (l ~ /^[[:space:]]*create[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*drop[[:space:]]+database[[:space:]]+/) { next } + if (l ~ /^[[:space:]]*use[[:space:]]+[`"]/ ) { next } + if (l ~ /^[[:space:]]*use[[:space:]]+[a-z0-9_]+[[:space:]]*;/) { next } + print line + } + ' +} + +preclean_database() { + local db_name="$1" + local current_charset="" + local current_collation="" + + current_charset="$(mysql_exec mysql -Nse "SELECT DEFAULT_CHARACTER_SET_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${db_name}'" 2>>"$LOG_FILE" || true)" + current_collation="$(mysql_exec mysql -Nse "SELECT DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${db_name}'" 2>>"$LOG_FILE" || true)" + [ -n "$current_charset" ] || current_charset="utf8mb4" + [ -n "$current_collation" ] || current_collation="utf8mb4_unicode_ci" + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Recreating database ${db_name} before restore" >> "$LOG_FILE" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db_name");" >> "$LOG_FILE" 2>&1 + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db_name") CHARACTER SET ${current_charset} COLLATE ${current_collation};" >> "$LOG_FILE" 2>&1 +} + +cleanup_restore_rollback() { + if [ -n "${RESTORE_ROLLBACK_FILE:-}" ] && [ -f "$RESTORE_ROLLBACK_FILE" ]; then + rm -f "$RESTORE_ROLLBACK_FILE" + fi +} + +backup_alt_database_for_rollback() { + local db_name="$1" + RESTORE_ROLLBACK_FILE="$(mktemp)" + RESTORE_ROLLBACK_CREATED=0 + + if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '$(mysql_escape_literal "$db_name")' LIMIT 1" 2>>"$LOG_FILE" || true)" ]; then + rm -f "$RESTORE_ROLLBACK_FILE" + RESTORE_ROLLBACK_FILE="" + return 0 + fi + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Creating rollback dump for ${db_name}" >> "$LOG_FILE" + if mysqldump_exec \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$db_name" > "$RESTORE_ROLLBACK_FILE" 2>>"$LOG_FILE"; then + RESTORE_ROLLBACK_CREATED=1 + return 0 + fi + + rm -f "$RESTORE_ROLLBACK_FILE" + RESTORE_ROLLBACK_FILE="" + return 1 +} + +restore_alt_database_from_rollback() { + local db_name="$1" + if [ "$RESTORE_ROLLBACK_CREATED" -ne 1 ] || [ -z "${RESTORE_ROLLBACK_FILE:-}" ] || [ ! -r "$RESTORE_ROLLBACK_FILE" ]; then + return 0 + fi + + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restoring rollback dump for ${db_name}" >> "$LOG_FILE" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db_name");" >> "$LOG_FILE" 2>&1 || true + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db_name") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" >> "$LOG_FILE" 2>&1 || true + mysql_exec "$db_name" < "$RESTORE_ROLLBACK_FILE" >> "$LOG_FILE" 2>&1 || { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: rollback restore failed for ${db_name}" >> "$LOG_FILE" + return 1 + } +} + +quote_sh_value() { + local value="$1" + value="${value//\'/\'\\\'\'}" + printf "'%s'" "$value" +} + +set_job_var() { + local key="$1" + local value="$2" + local tmp + local owner_group="" + if [ -f "$JOB_FILE" ]; then + owner_group="$(stat -c '%u:%g' "$JOB_FILE" 2>/dev/null || stat -f '%u:%g' "$JOB_FILE" 2>/dev/null || true)" + fi + tmp="$(mktemp)" + grep -v "^${key}=" "$JOB_FILE" > "$tmp" 2>/dev/null || true + printf "%s=%s\n" "$key" "$(quote_sh_value "$value")" >> "$tmp" + mv "$tmp" "$JOB_FILE" + chmod 600 "$JOB_FILE" 2>/dev/null || true + if [ "$(id -u)" -eq 0 ] && [ -n "$owner_group" ]; then + chown "$owner_group" "$JOB_FILE" 2>/dev/null || true + fi +} + +on_exit() { + local code="$1" + if [ "$code" -ne 0 ] && [ -z "${ERROR_MSG:-}" ] && [ -n "${LOG_FILE:-}" ]; then + ERROR_MSG="Zadanie przywracania zakończyło się błędem (kod ${code}) bez zarejestrowanego szczegółu." + echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: ${ERROR_MSG}" >> "$LOG_FILE" + fi + notify_user "$code" +} +trap 'log_unexpected_error "$LINENO" "$BASH_COMMAND" "$?"' ERR +trap cleanup_restore_rollback INT TERM +trap 'code=$?; cleanup_restore_rollback; on_exit "$code"' EXIT + +[ -f "$COMMON_FILE" ] || { + echo "Brak helpera MySQL: $COMMON_FILE" >&2 + exit 1 +} +# shellcheck disable=SC1090 +source "$COMMON_FILE" + +# shellcheck disable=SC1090 +source "$JOB_FILE" + +JOB_ID="${JOB_ID:-$(basename "$JOB_FILE" .env)}" +DA_USER="${DA_USER:-}" +DB_NAME="${DB_NAME:-}" +RESTORE_MODE="${RESTORE_MODE:-overwrite}" +SOURCE_DB_NAME="${SOURCE_DB_NAME:-}" +SOURCE_FILE="${SOURCE_FILE:-}" + +LOG_FILE="${LOG_DIR}/${JOB_ID}.log" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Start restore job ${JOB_ID}" > "$LOG_FILE" + +if [ -z "$DA_USER" ] || [ -z "$DB_NAME" ] || [ -z "$SOURCE_FILE" ]; then + fail "Brak DA_USER, DB_NAME lub SOURCE_FILE w pliku zadania." + exit 1 +fi + +if [[ "$DB_NAME" != "${DA_USER}_"* ]]; then + fail "Baza ${DB_NAME} nie należy do użytkownika ${DA_USER}." + exit 1 +fi + +if [ ! -r "$SOURCE_FILE" ]; then + fail "Nie można odczytać pliku źródłowego: ${SOURCE_FILE}" + exit 1 +fi + +load_plugin_settings +mysql_load_alt_credentials +MYSQL_BIN="$(mysql_alt_binary mysql)" || { + fail "Brak binarium mysql." + exit 1 +} + +if [ -z "$(mysql_exec mysql -Nse "SELECT 1 FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = '${DB_NAME}'" 2>>"$LOG_FILE" || true)" ]; then + fail "Docelowa baza danych nie istnieje: ${DB_NAME}" + exit 1 +fi + +if ! backup_alt_database_for_rollback "$DB_NAME"; then + fail "Nie udało się utworzyć kopii rollback przed przywracaniem bazy ${DB_NAME}." + exit 1 +fi + +if ! preclean_database "$DB_NAME"; then + restore_alt_database_from_rollback "$DB_NAME" || true + fail "Nie udało się przygotować bazy ${DB_NAME} do importu; przywrócono kopię rollback, jeśli była dostępna." + exit 1 +fi + +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restore source file: ${SOURCE_FILE}" >> "$LOG_FILE" +if [ -n "$SOURCE_DB_NAME" ]; then + echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restore source DB label: ${SOURCE_DB_NAME}" >> "$LOG_FILE" +fi +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restore target DB: ${DB_NAME}" >> "$LOG_FILE" + +set +e +if [[ "$SOURCE_FILE" == *.gz ]]; then + gunzip -c "$SOURCE_FILE" | sanitize_mysql_dump | mysql_exec "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=${PIPESTATUS[2]} +else + sanitize_mysql_dump < "$SOURCE_FILE" | mysql_exec "$DB_NAME" >> "$LOG_FILE" 2>&1 + RC=${PIPESTATUS[1]} +fi +set -e + +if [ "$RC" -ne 0 ]; then + restore_alt_database_from_rollback "$DB_NAME" || true + fail "Import pliku ${SOURCE_FILE} do bazy ${DB_NAME} zakończył się błędem." + exit 1 +fi + +set_job_var "END_TS" "$(date +%s)" +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Restore completed successfully." >> "$LOG_FILE" +RESTORE_OK=1 +exit 0 diff --git a/scripts/setup/alt_mysql_health_check.sh b/scripts/setup/alt_mysql_health_check.sh new file mode 100644 index 0000000..e943905 --- /dev/null +++ b/scripts/setup/alt_mysql_health_check.sh @@ -0,0 +1,32 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./mysql_common.sh +source "$SCRIPT_DIR/mysql_common.sh" + +fail() { + echo "alt-mysql health: $*" >&2 + exit 1 +} + +mysql_load_alt_runtime +mysql_load_alt_credentials + +if command -v systemctl >/dev/null 2>&1; then + systemctl is-active --quiet "$MYSQL_ALT_SERVICE_NAME" || fail "service is not active: $MYSQL_ALT_SERVICE_NAME" +fi + +[ -r "$MYSQL_ALT_MY_CNF" ] || fail "client defaults file is not readable: $MYSQL_ALT_MY_CNF" +[ -r "$MYSQL_ALT_MYSQL_CONF" ] || fail "credentials file is not readable: $MYSQL_ALT_MYSQL_CONF" +[ -n "$MYSQL_ALT_PORT" ] || fail "alt port was not discovered" +[ -n "$MYSQL_ALT_SOCKET" ] || fail "alt socket was not discovered" + +"$(mysql_alt_binary mysql)" --defaults-extra-file="$MYSQL_ALT_MY_CNF" -e "SELECT 1" >/dev/null \ + || fail "cannot connect through $MYSQL_ALT_MY_CNF" + +mysql_ensure_metadata_schema +mysql_exec mysql -e "SELECT COUNT(*) FROM da_plugin_database_owners" >/dev/null \ + || fail "metadata schema is not usable" + +echo "alt-mysql health: OK" diff --git a/scripts/setup/custombuild_hooks_install.sh b/scripts/setup/custombuild_hooks_install.sh new file mode 100644 index 0000000..47016ec --- /dev/null +++ b/scripts/setup/custombuild_hooks_install.sh @@ -0,0 +1,126 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +CUSTOMBUILD_ROOT="${CUSTOMBUILD_ROOT:-/usr/local/directadmin/custombuild}" +ACTION="${1:-install}" +SETTINGS_FILE="${MYSQL_PLUGIN_SETTINGS_FILE:-$PLUGIN_DIR/plugin-settings.conf}" +MARKER="managed by DirectAdmin MySQL plugin CustomBuild hook" + +log() { + printf 'mysql custombuild-hooks: %s\n' "$*" +} + +die() { + printf 'mysql custombuild-hooks: %s\n' "$*" >&2 + exit 1 +} + +read_setting() { + local key="$1" + local default="$2" + local line value + + [ -r "$SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + + line="$(grep -E "^${key}=" "$SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +custombuild_hooks_enabled() { + case "$(read_setting CUSTOMBUILD_HOOKS_ENABLED true)" in + true|yes|on|1) return 0 ;; + *) return 1 ;; + esac +} + +write_hook() { + local target="$1" + local app="$2" + local repair_script="$3" + local health_script="$4" + local repair_name health_name tmp + + [ -f "$repair_script" ] || die "missing repair script: $repair_script" + [ -f "$health_script" ] || die "missing health check script: $health_script" + repair_name="$(basename "$repair_script")" + health_name="$(basename "$health_script")" + + mkdir -p "$(dirname "$target")" + tmp="$(mktemp)" + cat > "$tmp" </dev/null || true + +{ + printf '%s\\n' "=== \$(date '+%Y-%m-%d %H:%M:%S') ${app} CustomBuild post hook ===" + "\$PLUGIN_DIR/scripts/setup/${repair_name}" + if ! "\$PLUGIN_DIR/scripts/setup/${health_name}"; then + printf '%s\\n' "${app}: warning: health check failed after repair" + fi +} >> "\$LOG_FILE" 2>&1 +EOF + install -m 0755 "$tmp" "$target" + rm -f "$tmp" + log "installed CustomBuild post hook: $target" +} + +install_all() { + custombuild_hooks_enabled || { + log "disabled by CUSTOMBUILD_HOOKS_ENABLED" + return 0 + } + + write_hook \ + "$CUSTOMBUILD_ROOT/custom/hooks/roundcube/post/90-alt-mysql-repair.sh" \ + "roundcube" \ + "$PLUGIN_DIR/scripts/setup/roundcube_repair_config.sh" \ + "$PLUGIN_DIR/scripts/setup/roundcube_health_check.sh" + + write_hook \ + "$CUSTOMBUILD_ROOT/custom/hooks/phpmyadmin/post/90-alt-mysql-repair.sh" \ + "phpmyadmin" \ + "$PLUGIN_DIR/scripts/setup/phpmyadmin_repair.sh" \ + "$PLUGIN_DIR/scripts/setup/phpmyadmin_health_check.sh" +} + +uninstall_all() { + local hook + for hook in \ + "$CUSTOMBUILD_ROOT/custom/hooks/roundcube/post/90-alt-mysql-repair.sh" \ + "$CUSTOMBUILD_ROOT/custom/hooks/phpmyadmin/post/90-alt-mysql-repair.sh" + do + if [ -f "$hook" ] && grep -Fq "$MARKER" "$hook"; then + rm -f "$hook" + log "removed CustomBuild post hook: $hook" + fi + done +} + +case "$ACTION" in + install) install_all ;; + uninstall) uninstall_all ;; + *) die "unknown action: $ACTION" ;; +esac diff --git a/scripts/setup/da_integration_install.sh b/scripts/setup/da_integration_install.sh new file mode 100644 index 0000000..b8bce0b --- /dev/null +++ b/scripts/setup/da_integration_install.sh @@ -0,0 +1,124 @@ +#!/bin/bash +set -Eeuo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/../.." && pwd)" +HOOK_ROOT="${DA_CUSTOM_HOOK_DIR:-/usr/local/directadmin/scripts/custom}" +ACTION="${1:-install}" +MARKER="managed by DirectAdmin MySQL plugin hook dispatcher" + +log() { + printf 'mysql da-integration: %s\n' "$*" +} + +die() { + printf 'mysql da-integration: %s\n' "$*" >&2 + exit 1 +} + +write_dispatcher() { + local target="$1" + local tmp + tmp="$(mktemp)" + cat > "$tmp" <<'EOF' +#!/bin/bash +# managed by DirectAdmin MySQL plugin hook dispatcher +set -u + +hook_name="$(basename "$0")" +hook_dir="${0}.d" +status=0 + +if [ ! -d "$hook_dir" ]; then + exit 0 +fi + +for hook in "$hook_dir"/*.sh; do + [ -e "$hook" ] || continue + [ -x "$hook" ] || continue + DA_HOOK_NAME="$hook_name" "$hook" "$@" + status=$? + if [ "$status" -ne 0 ]; then + exit "$status" + fi +done + +exit 0 +EOF + install -m 0755 "$tmp" "$target" + rm -f "$tmp" +} + +install_hook() { + local hook_name="$1" + local source_script="$2" + local link_name="${3:-10-alt-mysql.sh}" + local target="$HOOK_ROOT/$hook_name" + local hook_dir="$HOOK_ROOT/${hook_name}.d" + local preserved="" + + [ -f "$source_script" ] || die "missing hook source: $source_script" + chmod 0755 "$source_script" 2>/dev/null || true + mkdir -p "$hook_dir" + + if [ -e "$target" ] && ! grep -Fq "$MARKER" "$target" 2>/dev/null; then + if [ -d "$target" ]; then + die "cannot preserve hook because target is a directory: $target" + fi + preserved="$hook_dir/00-existing.sh" + if [ -e "$preserved" ]; then + preserved="$hook_dir/00-existing.$(date +%Y%m%d%H%M%S).sh" + fi + mv "$target" "$preserved" + chmod 0755 "$preserved" 2>/dev/null || true + log "preserved existing hook $hook_name as $preserved" + fi + + write_dispatcher "$target" + ln -sfn "$source_script" "$hook_dir/$link_name" + chmod 0755 "$target" "$source_script" 2>/dev/null || true + log "installed hook $hook_name -> $source_script" +} + +remove_hook_link() { + local hook_name="$1" + local link_name="${2:-10-alt-mysql.sh}" + local target="$HOOK_ROOT/$hook_name" + local hook_dir="$HOOK_ROOT/${hook_name}.d" + + rm -f "$hook_dir/$link_name" + + if [ -d "$hook_dir" ] && ! find "$hook_dir" -mindepth 1 -maxdepth 1 -print -quit | grep -q .; then + rmdir "$hook_dir" 2>/dev/null || true + if [ -f "$target" ] && grep -Fq "$MARKER" "$target" 2>/dev/null; then + rm -f "$target" + fi + fi +} + +install_all() { + mkdir -p "$HOOK_ROOT" + install_hook "user_backup_compress_pre.sh" "$PLUGIN_DIR/scripts/da-integration/alt_mysql_user_backup_compress_pre.sh" + install_hook "user_restore_post_pre_cleanup.sh" "$PLUGIN_DIR/scripts/da-integration/alt_mysql_user_restore_post_pre_cleanup.sh" + + install_hook "database_create_pre.sh" "$PLUGIN_DIR/scripts/da-integration/block_native_database_action.sh" "20-alt-mysql-block-native-db.sh" + install_hook "database_delete_pre.sh" "$PLUGIN_DIR/scripts/da-integration/block_native_database_action.sh" "20-alt-mysql-block-native-db.sh" + install_hook "database_user_password_change_pre.sh" "$PLUGIN_DIR/scripts/da-integration/block_native_database_action.sh" "20-alt-mysql-block-native-db.sh" + install_hook "database_user_create_post.sh" "$PLUGIN_DIR/scripts/da-integration/block_native_database_action.sh" "20-alt-mysql-observe-native-db.sh" + install_hook "database_destroy_user_post.sh" "$PLUGIN_DIR/scripts/da-integration/block_native_database_action.sh" "20-alt-mysql-observe-native-db.sh" +} + +uninstall_all() { + remove_hook_link "user_backup_compress_pre.sh" + remove_hook_link "user_restore_post_pre_cleanup.sh" + remove_hook_link "database_create_pre.sh" "20-alt-mysql-block-native-db.sh" + remove_hook_link "database_delete_pre.sh" "20-alt-mysql-block-native-db.sh" + remove_hook_link "database_user_password_change_pre.sh" "20-alt-mysql-block-native-db.sh" + remove_hook_link "database_user_create_post.sh" "20-alt-mysql-observe-native-db.sh" + remove_hook_link "database_destroy_user_post.sh" "20-alt-mysql-observe-native-db.sh" +} + +case "$ACTION" in + install) install_all ;; + uninstall) uninstall_all ;; + *) die "unknown action: $ACTION" ;; +esac diff --git a/scripts/setup/dbrestore.sh b/scripts/setup/dbrestore.sh new file mode 100755 index 0000000..c70b540 --- /dev/null +++ b/scripts/setup/dbrestore.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./mysql_common.sh +source "$SCRIPT_DIR/mysql_common.sh" + +usage() { + cat <<'EOF' +Użycie: + dbrestore.sh [nazwa_bazy] + +Opis: + Przywraca pojedynczą bazę MySQL z pliku dumpa. Gdy nazwa bazy nie zostanie + podana, skrypt wyprowadza ją z nazwy pliku. +EOF +} + +[ "${1:-}" = "--help" ] && { + usage + exit 0 +} + +[ $# -ge 1 ] || { + usage + exit 1 +} + +FILE="$1" +[ -f "$FILE" ] || { + echo "Plik nie istnieje: $FILE" >&2 + exit 1 +} + +case "$FILE" in + *.sql|*.sql.gz|*.gz) ;; + *) + echo "Dozwolone są pliki .sql, .sql.gz lub .gz." >&2 + exit 1 + ;; +esac + +TARGET_DB="${2:-$(basename "$FILE" | sed -E 's/\.sql(\.gz)?$//; s/\.gz$//')}" +TARGET_DB="$(mysql_safe_identifier "$TARGET_DB")" + +mysql_load_alt_credentials + +mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$TARGET_DB");" +mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$TARGET_DB") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" +mysql_restore_stream "$TARGET_DB" "$FILE" + +echo "Przywracanie bazy ${TARGET_DB} zakończone." diff --git a/scripts/setup/mysql_backup.sh b/scripts/setup/mysql_backup.sh new file mode 100644 index 0000000..3a83412 --- /dev/null +++ b/scripts/setup/mysql_backup.sh @@ -0,0 +1,99 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./mysql_common.sh +source "$SCRIPT_DIR/mysql_common.sh" + +BASE_DIR="${BASE_DIR:-/home/admin/mysql_backup}" +DATE_FORMAT="${DATE_FORMAT:-%d-%m-%Y}" +ADD_TIME="${ADD_TIME:-0}" +RETENTION="${RETENTION:-7}" +ENABLE_COMPRESSION="${ENABLE_COMPRESSION:-1}" +LOG_FILE="${LOG_FILE:-/var/log/mysql_backup.log}" + +timestamp() { + date '+%Y-%m-%d %H:%M:%S' +} + +log() { + printf '[%s] %s\n' "$(timestamp)" "$*" | tee -a "$LOG_FILE" +} + +dump_globals_file() { + local output_file="$1" + local tmp_file + tmp_file="$(mktemp)" + { + echo "-- Global users and grants for MySQL" + echo "-- Generated at $(timestamp)" + } > "$tmp_file" + + while IFS=$'\t' read -r db_user db_host; do + [ -n "$db_user" ] || continue + [ -n "$db_host" ] || continue + printf "DROP USER IF EXISTS '%s'@'%s';\n" "$db_user" "$db_host" >> "$tmp_file" + mysql_exec mysql -Nse "SHOW CREATE USER '${db_user}'@'${db_host}'" >> "$tmp_file" || true + echo ";" >> "$tmp_file" + while IFS= read -r grant_line; do + [ -n "$grant_line" ] || continue + printf "%s;\n" "$grant_line" >> "$tmp_file" + done < <(mysql_exec mysql -Nse "SHOW GRANTS FOR '${db_user}'@'${db_host}'" || true) + echo >> "$tmp_file" + done < <( + mysql_exec mysql -Nse " + SELECT user, host + FROM mysql.user + WHERE user <> '' + AND user NOT IN ('mysql.sys', 'mysql.session', 'mysql.infoschema', 'mariadb.sys') + ORDER BY user, host + " || true + ) + + if [ "$ENABLE_COMPRESSION" = "1" ]; then + gzip -9 < "$tmp_file" > "$output_file" + else + mv "$tmp_file" "$output_file" + return 0 + fi + rm -f "$tmp_file" +} + +mysql_load_alt_credentials + +mkdir -p "$BASE_DIR" + +if [ "$ADD_TIME" = "1" ]; then + DATE_STR="$(date +"${DATE_FORMAT}_%H-%M")" +else + DATE_STR="$(date +"${DATE_FORMAT}")" +fi +BACKUP_DIR="${BASE_DIR}/${DATE_STR}" +mkdir -p "$BACKUP_DIR" + +log "Start backupu MySQL do ${BACKUP_DIR}" + +GLOBALS_FILE="${BACKUP_DIR}/_globals.sql" +if [ "$ENABLE_COMPRESSION" = "1" ]; then + GLOBALS_FILE="${GLOBALS_FILE}.gz" +fi +dump_globals_file "$GLOBALS_FILE" +log "Zapisano ${GLOBALS_FILE##*/}" + +while IFS= read -r db_name; do + [ -n "$db_name" ] || continue + target_file="${BACKUP_DIR}/${db_name}.sql" + if [ "$ENABLE_COMPRESSION" = "1" ]; then + mysqldump_exec --single-transaction --skip-lock-tables --routines --triggers --events --databases "$db_name" | gzip -9 > "${target_file}.gz" + log "Backup ${db_name} -> ${target_file##*/}.gz" + else + mysqldump_exec --single-transaction --skip-lock-tables --routines --triggers --events --databases "$db_name" > "$target_file" + log "Backup ${db_name} -> ${target_file##*/}" + fi +done < <(mysql_non_system_databases) + +if [ "$RETENTION" -gt 0 ]; then + find "$BASE_DIR" -mindepth 1 -maxdepth 1 -type d -mtime +"$RETENTION" -exec rm -rf {} + +fi + +log "Backup MySQL zakończony." diff --git a/scripts/setup/mysql_common.sh b/scripts/setup/mysql_common.sh new file mode 100755 index 0000000..fad8e4a --- /dev/null +++ b/scripts/setup/mysql_common.sh @@ -0,0 +1,489 @@ +#!/bin/bash +set -euo pipefail + +MYSQL_PLUGIN_CREDENTIALS_FILE="${MYSQL_PLUGIN_CREDENTIALS_FILE:-/usr/local/directadmin/conf/alt-mysql.conf}" +MYSQL_PLUGIN_METADATA_FILE="${MYSQL_PLUGIN_METADATA_FILE:-/usr/local/directadmin/conf/alt-mariadb.env}" +MYSQL_PLUGIN_ALT_MY_CNF="${MYSQL_PLUGIN_ALT_MY_CNF:-/usr/local/directadmin/conf/alt-my.cnf}" +MYSQL_PLUGIN_ALT_INSTANCE_CNF="${MYSQL_PLUGIN_ALT_INSTANCE_CNF:-/etc/alt-mariadb.cnf}" + +trim_value() { + local value="${1:-}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +strip_inline_comment() { + local value + value="$(trim_value "${1:-}")" + value="${value%%#*}" + printf '%s' "$(trim_value "$value")" +} + +unquote_value() { + local value + value="$(trim_value "${1:-}")" + if [ "${#value}" -ge 2 ] && [ "${value:0:1}" = "'" ] && [ "${value: -1}" = "'" ]; then + value="${value:1:${#value}-2}" + elif [ "${#value}" -ge 2 ] && [ "${value:0:1}" = '"' ] && [ "${value: -1}" = '"' ]; then + value="${value:1:${#value}-2}" + fi + printf '%s' "$value" +} + +mysql_require_root() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + echo "Ten skrypt musi być uruchomiony jako root." >&2 + exit 1 + fi +} + +mysql_detect_binary() { + local bin_name="$1" + local candidate="" + local alt_name="" + if [ -n "${MYSQL_PLUGIN_CLIENT_BIN_DIR:-}" ]; then + for alt_name in "$bin_name" "$(mysql_binary_alias "$bin_name")"; do + [ -n "$alt_name" ] || continue + candidate="${MYSQL_PLUGIN_CLIENT_BIN_DIR%/}/${alt_name}" + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + fi + + for alt_name in "$bin_name" "$(mysql_binary_alias "$bin_name")"; do + [ -n "$alt_name" ] || continue + candidate="$(command -v "$alt_name" 2>/dev/null || true)" + if [ -n "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + for alt_name in "$bin_name" "$(mysql_binary_alias "$bin_name")"; do + [ -n "$alt_name" ] || continue + while IFS= read -r candidate; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done < <(find /usr/local -maxdepth 3 -type f -path '/usr/local/mariadb-*/bin/'"$alt_name" 2>/dev/null | sort -V -r) + for candidate in /usr/bin/"$alt_name" /usr/local/bin/"$alt_name"; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + done + return 1 +} + +mysql_binary_alias() { + case "$1" in + mysql) printf 'mariadb\n' ;; + mysqldump) printf 'mariadb-dump\n' ;; + mysqladmin) printf 'mariadb-admin\n' ;; + mysql_upgrade) printf 'mariadb-upgrade\n' ;; + *) printf '\n' ;; + esac +} + +mysql_load_plugin_settings_file() { + local file="${1:-}" + local raw_line key value + [ -n "$file" ] || return 0 + [ -r "$file" ] || return 0 + + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + raw_line="$(trim_value "$raw_line")" + [ -n "$raw_line" ] || continue + case "$raw_line" in + \#*) continue ;; + esac + [[ "$raw_line" == *=* ]] || continue + + key="$(trim_value "${raw_line%%=*}")" + value="$(unquote_value "$(strip_inline_comment "${raw_line#*=}")")" + case "$key" in + MYSQL_PLUGIN_CREDENTIALS_FILE) MYSQL_PLUGIN_CREDENTIALS_FILE="$value" ;; + MYSQL_PLUGIN_METADATA_FILE) MYSQL_PLUGIN_METADATA_FILE="$value" ;; + MYSQL_PLUGIN_ALT_MY_CNF) MYSQL_PLUGIN_ALT_MY_CNF="$value" ;; + MYSQL_PLUGIN_ALT_MARIADB_CNF) MYSQL_PLUGIN_ALT_INSTANCE_CNF="$value" ;; + MYSQL_PLUGIN_CLIENT_BIN_DIR) MYSQL_PLUGIN_CLIENT_BIN_DIR="$value" ;; + esac + done < "$file" +} + +mysql_read_key_value() { + local file="$1" + local key="$2" + [ -r "$file" ] || return 1 + + awk -F= -v key="$key" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + $0 ~ /^[[:space:]]*#/ || $0 !~ /=/ { next } + { + k = trim($1) + if (k != key) { next } + sub(/^[^=]*=/, "") + v = trim($0) + if (v ~ /^'\''.*'\''$/ || v ~ /^".*"$/) { + v = substr(v, 2, length(v) - 2) + } + print v + exit + } + ' "$file" +} + +mysql_read_cnf_value() { + local file="$1" + local section="$2" + local key="$3" + [ -r "$file" ] || return 1 + + awk -F= -v section="$section" -v key="$key" ' + function trim(s) { + sub(/^[[:space:]]+/, "", s) + sub(/[[:space:]]+$/, "", s) + return s + } + /^[[:space:]]*#/ || /^[[:space:]]*;/ { next } + /^\[[^]]+\]/ { + current=$0 + gsub(/^\[[[:space:]]*/, "", current) + gsub(/[[:space:]]*\]$/, "", current) + current=trim(current) + next + } + current == section && $0 ~ /=/ { + k=trim($1) + if (k != key) { next } + sub(/^[^=]*=/, "") + v=trim($0) + if (v ~ /^'\''.*'\''$/ || v ~ /^".*"$/) { + v=substr(v, 2, length(v)-2) + } + print v + exit + } + ' "$file" +} + +mysql_load_alt_runtime() { + MYSQL_ALT_METADATA_FILE="${MYSQL_ALT_METADATA_FILE:-$MYSQL_PLUGIN_METADATA_FILE}" + MYSQL_ALT_MYSQL_CONF="${MYSQL_ALT_MYSQL_CONF:-$MYSQL_PLUGIN_CREDENTIALS_FILE}" + MYSQL_ALT_MY_CNF="${MYSQL_ALT_MY_CNF:-$MYSQL_PLUGIN_ALT_MY_CNF}" + MYSQL_ALT_INSTANCE_CNF="${MYSQL_ALT_INSTANCE_CNF:-$MYSQL_PLUGIN_ALT_INSTANCE_CNF}" + MYSQL_ALT_SERVICE_NAME="${MYSQL_ALT_SERVICE_NAME:-alt-mariadb}" + MYSQL_ALT_BASEDIR="${MYSQL_ALT_BASEDIR:-}" + MYSQL_ALT_DATADIR="${MYSQL_ALT_DATADIR:-}" + MYSQL_ALT_PORT="${MYSQL_ALT_PORT:-}" + MYSQL_ALT_SOCKET="${MYSQL_ALT_SOCKET:-}" + + if [ -r "$MYSQL_ALT_METADATA_FILE" ]; then + MYSQL_ALT_SERVICE_NAME="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" SERVICE_NAME || printf '%s' "$MYSQL_ALT_SERVICE_NAME")" + MYSQL_ALT_BASEDIR="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" MARIADB_DIR || printf '%s' "$MYSQL_ALT_BASEDIR")" + MYSQL_ALT_DATADIR="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" DATADIR || printf '%s' "$MYSQL_ALT_DATADIR")" + MYSQL_ALT_PORT="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" PORT || printf '%s' "$MYSQL_ALT_PORT")" + MYSQL_ALT_SOCKET="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" SOCKET || printf '%s' "$MYSQL_ALT_SOCKET")" + MYSQL_ALT_MYSQL_CONF="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" ALT_MYSQL_CONF || printf '%s' "$MYSQL_ALT_MYSQL_CONF")" + MYSQL_ALT_MY_CNF="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" ALT_MY_CNF || printf '%s' "$MYSQL_ALT_MY_CNF")" + MYSQL_ALT_INSTANCE_CNF="$(mysql_read_key_value "$MYSQL_ALT_METADATA_FILE" INSTANCE_CNF || printf '%s' "$MYSQL_ALT_INSTANCE_CNF")" + fi + + if [ -r "$MYSQL_ALT_MYSQL_CONF" ]; then + [ -n "$MYSQL_ALT_PORT" ] || MYSQL_ALT_PORT="$(mysql_read_key_value "$MYSQL_ALT_MYSQL_CONF" port || true)" + [ -n "$MYSQL_ALT_SOCKET" ] || MYSQL_ALT_SOCKET="$(mysql_read_key_value "$MYSQL_ALT_MYSQL_CONF" socket || true)" + fi + + if [ -r "$MYSQL_ALT_INSTANCE_CNF" ]; then + [ -n "$MYSQL_ALT_BASEDIR" ] || MYSQL_ALT_BASEDIR="$(mysql_read_cnf_value "$MYSQL_ALT_INSTANCE_CNF" mariadb basedir || true)" + [ -n "$MYSQL_ALT_DATADIR" ] || MYSQL_ALT_DATADIR="$(mysql_read_cnf_value "$MYSQL_ALT_INSTANCE_CNF" mariadb datadir || true)" + [ -n "$MYSQL_ALT_PORT" ] || MYSQL_ALT_PORT="$(mysql_read_cnf_value "$MYSQL_ALT_INSTANCE_CNF" mariadb port || true)" + [ -n "$MYSQL_ALT_SOCKET" ] || MYSQL_ALT_SOCKET="$(mysql_read_cnf_value "$MYSQL_ALT_INSTANCE_CNF" mariadb socket || true)" + fi + + if [ -n "$MYSQL_ALT_BASEDIR" ] && [ -d "$MYSQL_ALT_BASEDIR/bin" ]; then + MYSQL_PLUGIN_CLIENT_BIN_DIR="${MYSQL_PLUGIN_CLIENT_BIN_DIR:-$MYSQL_ALT_BASEDIR/bin}" + fi + + [ -n "$MYSQL_ALT_PORT" ] || MYSQL_ALT_PORT="3306" + [ -n "$MYSQL_ALT_MYSQL_CONF" ] || MYSQL_ALT_MYSQL_CONF="/usr/local/directadmin/conf/alt-mysql.conf" + [ -n "$MYSQL_ALT_MY_CNF" ] || MYSQL_ALT_MY_CNF="/usr/local/directadmin/conf/alt-my.cnf" + [ -n "$MYSQL_ALT_INSTANCE_CNF" ] || MYSQL_ALT_INSTANCE_CNF="/etc/alt-mariadb.cnf" + [ -n "$MYSQL_ALT_SERVICE_NAME" ] || MYSQL_ALT_SERVICE_NAME="alt-mariadb" +} + +mysql_alt_service_name() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_SERVICE_NAME" +} + +mysql_alt_basedir() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_BASEDIR" +} + +mysql_alt_datadir() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_DATADIR" +} + +mysql_alt_port() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_PORT" +} + +mysql_alt_socket() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_SOCKET" +} + +mysql_alt_client_defaults() { + mysql_load_alt_runtime + printf '%s\n' "$MYSQL_ALT_MY_CNF" +} + +mysql_alt_binary() { + local bin_name="$1" + mysql_load_alt_runtime + mysql_detect_binary "$bin_name" +} + +mysql_native_binary() { + local bin_name="$1" + local alt_name candidate + for alt_name in "$bin_name" "$(mysql_binary_alias "$bin_name")"; do + [ -n "$alt_name" ] || continue + for candidate in /usr/bin/"$alt_name" /usr/local/bin/"$alt_name"; do + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + candidate="$(command -v "$alt_name" 2>/dev/null || true)" + if [ -n "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +mysql_load_credentials() { + local file="${1:-$MYSQL_PLUGIN_CREDENTIALS_FILE}" + [ -r "$file" ] || { + echo "Brak pliku poświadczeń MySQL: $file" >&2 + exit 1 + } + + MYSQL_HOST="" + MYSQL_PORT="" + MYSQL_USER="" + MYSQL_PASSWORD="" + MYSQL_DATABASE="" + MYSQL_SOCKET="" + + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + raw_line="$(trim_value "$raw_line")" + [ -n "$raw_line" ] || continue + case "$raw_line" in + \#*) continue ;; + esac + + if [[ "$raw_line" == *=* ]]; then + local key="${raw_line%%=*}" + local value="${raw_line#*=}" + key="$(printf '%s' "$key" | tr '[:upper:]' '[:lower:]')" + key="$(trim_value "$key")" + value="$(unquote_value "$(strip_inline_comment "$value")")" + case "$key" in + host|mysql_host) MYSQL_HOST="$value" ;; + port|mysql_port) MYSQL_PORT="$value" ;; + user|username|mysql_user) MYSQL_USER="$value" ;; + password|passwd|pass|mysql_password) MYSQL_PASSWORD="$value" ;; + database|dbname|mysql_database) MYSQL_DATABASE="$value" ;; + socket|mysql_socket) MYSQL_SOCKET="$value" ;; + esac + continue + fi + + local host_part port_part db_part user_part pass_part rest + host_part="${raw_line%%:*}" + rest="${raw_line#*:}" + port_part="${rest%%:*}" + rest="${rest#*:}" + db_part="${rest%%:*}" + rest="${rest#*:}" + user_part="${rest%%:*}" + pass_part="${rest#*:}" + MYSQL_HOST="$host_part" + MYSQL_PORT="$port_part" + MYSQL_DATABASE="$db_part" + MYSQL_USER="$user_part" + MYSQL_PASSWORD="$pass_part" + break + done < "$file" + + MYSQL_HOST="$(trim_value "${MYSQL_HOST:-}")" + MYSQL_PORT="$(trim_value "${MYSQL_PORT:-}")" + MYSQL_USER="$(trim_value "${MYSQL_USER:-}")" + MYSQL_PASSWORD="${MYSQL_PASSWORD:-}" + MYSQL_DATABASE="$(trim_value "${MYSQL_DATABASE:-}")" + MYSQL_SOCKET="$(trim_value "${MYSQL_SOCKET:-}")" + + [ -n "$MYSQL_HOST" ] || MYSQL_HOST="localhost" + [ -n "$MYSQL_PORT" ] || MYSQL_PORT="3306" + [ -n "$MYSQL_USER" ] || MYSQL_USER="root" + [ -n "$MYSQL_DATABASE" ] || MYSQL_DATABASE="mysql" +} + +mysql_load_alt_credentials() { + mysql_load_alt_runtime + mysql_load_credentials "$MYSQL_ALT_MYSQL_CONF" +} + +mysql_base_args() { + local args=("--user=$MYSQL_USER") + if [ "$MYSQL_HOST" = "localhost" ]; then + if [ -n "$MYSQL_SOCKET" ]; then + args+=("--socket=$MYSQL_SOCKET") + else + args+=("--host=localhost") + fi + else + args+=("--protocol=TCP" "--host=$MYSQL_HOST" "--port=$MYSQL_PORT") + fi + printf '%s\n' "${args[@]}" +} + +mysql_exec() { + local database="$1" + shift + local -a args=() + while IFS= read -r arg; do + args+=("$arg") + done < <(mysql_base_args) + MYSQL_PWD="$MYSQL_PASSWORD" "$(mysql_detect_binary mysql)" "${args[@]}" --database="$database" "$@" +} + +mysql_exec_sql() { + local database="$1" + local sql="$2" + mysql_exec "$database" -e "$sql" +} + +mysqldump_exec() { + local -a args=() + while IFS= read -r arg; do + args+=("$arg") + done < <(mysql_base_args) + MYSQL_PWD="$MYSQL_PASSWORD" "$(mysql_detect_binary mysqldump)" "${args[@]}" "$@" +} + +mysql_alt_version() { + mysql_load_alt_credentials + mysql_exec mysql -Nse "SELECT VERSION()" 2>/dev/null || true +} + +mysql_ensure_metadata_schema() { + mysql_exec mysql -e " +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; +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; +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; +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; +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; +" +} + +mysql_service_name() { + local service + for service in mysqld mariadb mysql; do + if systemctl list-unit-files "${service}.service" >/dev/null 2>&1; then + printf '%s\n' "$service" + return 0 + fi + done + printf '%s\n' "mysqld" +} + +mysql_safe_identifier() { + local value="${1:-}" + if [[ ! "$value" =~ ^[A-Za-z0-9_]+$ ]]; then + echo "Nieprawidłowy identyfikator MySQL: $value" >&2 + exit 1 + fi + printf '%s' "$value" +} + +mysql_quote_identifier() { + local value + value="$(mysql_safe_identifier "$1")" + printf '`%s`' "$value" +} + +mysql_escape_literal() { + printf "%s" "${1:-}" | sed "s/'/''/g" +} + +mysql_non_system_databases() { + mysql_exec mysql -Nse "SHOW DATABASES" | grep -Ev '^(information_schema|performance_schema|mysql|sys)$' || true +} + +mysql_restore_stream() { + local database="$1" + local file="$2" + if [[ "$file" == *.gz ]]; then + gunzip -c "$file" | mysql_exec "$database" + return ${PIPESTATUS[1]} + fi + mysql_exec "$database" < "$file" +} diff --git a/scripts/setup/mysql_host_sync.sh b/scripts/setup/mysql_host_sync.sh new file mode 100755 index 0000000..12d0fb2 --- /dev/null +++ b/scripts/setup/mysql_host_sync.sh @@ -0,0 +1,376 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PLUGIN_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +PLUGIN_SETTINGS_FILE="${MYSQL_HOST_SYNC_PLUGIN_SETTINGS:-$PLUGIN_DIR/plugin-settings.conf}" + +# shellcheck source=./mysql_common.sh +source "$SCRIPT_DIR/mysql_common.sh" + +load_plugin_settings() { + local file="$1" + local raw_line key value + + [ -r "$file" ] || return 0 + + while IFS= read -r raw_line || [ -n "$raw_line" ]; do + raw_line="$(trim_value "$raw_line")" + [ -n "$raw_line" ] || continue + case "$raw_line" in + \#*) continue ;; + esac + [[ "$raw_line" == *=* ]] || continue + + key="$(trim_value "${raw_line%%=*}")" + value="$(unquote_value "$(strip_inline_comment "${raw_line#*=}")")" + + case "$key" in + MYSQL_PLUGIN_CREDENTIALS_FILE) MYSQL_PLUGIN_CREDENTIALS_FILE="$value" ;; + MYSQL_PLUGIN_METADATA_FILE) MYSQL_PLUGIN_METADATA_FILE="$value" ;; + MYSQL_PLUGIN_ALT_MY_CNF) MYSQL_PLUGIN_ALT_MY_CNF="$value" ;; + MYSQL_PLUGIN_CLIENT_BIN_DIR) MYSQL_PLUGIN_CLIENT_BIN_DIR="$value" ;; + MYSQL_PLUGIN_REMOTE_BIND_ADDRESS) MYSQL_PLUGIN_REMOTE_BIND_ADDRESS="$value" ;; + MYSQL_PLUGIN_CSF_ALLOW_FILE) MYSQL_HOST_SYNC_CSF_ALLOW="$value" ;; + MYSQL_PLUGIN_CSF_CONF_FILE) MYSQL_HOST_SYNC_CSF_CONF="$value" ;; + MYSQL_PLUGIN_CSF_MANAGED_ALLOW_FILE) MYSQL_HOST_SYNC_MANAGED_ALLOW="$value" ;; + MYSQL_PLUGIN_ALT_MARIADB_CNF) MYSQL_HOST_SYNC_INSTANCE_CNF="$value" ;; + MYSQL_PLUGIN_ALT_MARIADB_SERVICE) MYSQL_HOST_SYNC_SERVICE="$value" ;; + esac + done < "$file" +} + +require_root_or_sudo() { + if [ "${MYSQL_HOST_SYNC_ASSUME_ROOT:-0}" = "1" ]; then + return 0 + fi + + if [ "${EUID:-$(id -u)}" -eq 0 ]; then + return 0 + fi + + if command -v sudo >/dev/null 2>&1; then + exec sudo -n "$0" "$@" + fi + + echo "mysql: synchronizacja hostów wymaga uprawnień root." >&2 + exit 1 +} + +is_ipv4() { + local ip="${1:-}" + local IFS=. + local -a octets + [[ "$ip" =~ ^[0-9]+(\.[0-9]+){3}$ ]] || return 1 + read -r -a octets <<< "$ip" + [ "${#octets[@]}" -eq 4 ] || return 1 + local octet + for octet in "${octets[@]}"; do + [[ "$octet" =~ ^[0-9]+$ ]] || return 1 + [ "$octet" -ge 0 ] && [ "$octet" -le 255 ] || return 1 + done +} + +is_local_host() { + case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in + ""|localhost|127.0.0.1|::1) return 0 ;; + *) return 1 ;; + esac +} + +collect_remote_hosts() { + local sql output host + sql="SELECT DISTINCT host_pattern FROM da_plugin_access_hosts WHERE host_pattern <> '' ORDER BY host_pattern" + + if ! output="$(mysql_exec mysql --batch --skip-column-names --raw -e "$sql")"; then + echo "mysql: nie można odczytać listy hostów zdalnych z da_plugin_access_hosts." >&2 + return 1 + fi + + while IFS= read -r host || [ -n "$host" ]; do + host="$(trim_value "$host")" + [ -n "$host" ] || continue + is_local_host "$host" && continue + is_ipv4 "$host" || continue + printf '%s\n' "$host" + done <<< "$output" | sort -u +} + +write_managed_allow_file() { + local target="$1" + local port="$2" + local tmp + shift 2 + local -a hosts=("$@") + + install -d -m 0755 "$(dirname "$target")" + tmp="$(mktemp)" + + { + echo "# Managed by DirectAdmin MySQL plugin. Do not edit manually." + echo "# Remote MariaDB access for port $port." + echo "# Source: da_plugin_access_hosts in the plugin metadata schema." + local host + for host in "${hosts[@]}"; do + echo "tcp|in|d=$port|s=$host" + done + } > "$tmp" + + if [ ! -f "$target" ] || ! cmp -s "$tmp" "$target"; then + if [ -f "$target" ]; then + cat "$tmp" > "$target" + chmod 0644 "$target" + else + install -m 0644 "$tmp" "$target" + fi + changed_csf=1 + fi + + rm -f "$tmp" +} + +ensure_csf_include() { + local csf_allow="$1" + local managed_allow="$2" + local include_line="Include $managed_allow" + + [ -f "$csf_allow" ] || { + echo "mysql: brak pliku CSF allow: $csf_allow" >&2 + return 1 + } + + if grep -Fxq "$include_line" "$csf_allow"; then + return 0 + fi + + printf '\n%s\n' "$include_line" >> "$csf_allow" + changed_csf=1 +} + +remove_port_from_csv() { + local csv="$1" + local port="$2" + local old_ifs="$IFS" + local item out="" start end left_start left_end right_start right_end + IFS=',' + for item in $csv; do + item="$(trim_value "$item")" + [ -n "$item" ] || continue + + if [ "$item" = "$port" ]; then + continue + fi + + if [[ "$item" =~ ^([0-9]+):([0-9]+)$ ]]; then + start="${BASH_REMATCH[1]}" + end="${BASH_REMATCH[2]}" + if [ "$start" -le "$port" ] && [ "$port" -le "$end" ]; then + left_start="$start" + left_end=$((port - 1)) + right_start=$((port + 1)) + right_end="$end" + + if [ "$left_start" -le "$left_end" ]; then + if [ -z "$out" ]; then + out="$left_start" + else + out="$out,$left_start" + fi + if [ "$left_start" -ne "$left_end" ]; then + out="$out:$left_end" + fi + fi + + if [ "$right_start" -le "$right_end" ]; then + if [ -z "$out" ]; then + out="$right_start" + else + out="$out,$right_start" + fi + if [ "$right_start" -ne "$right_end" ]; then + out="$out:$right_end" + fi + fi + continue + fi + fi + + if [ -z "$out" ]; then + out="$item" + else + out="$out,$item" + fi + done + IFS="$old_ifs" + printf '%s' "$out" +} + +remove_port_from_global_csf_lists() { + local csf_conf="$1" + local port="$2" + local tmp changed=0 line new_list new_line + + [ -f "$csf_conf" ] || { + echo "mysql: brak pliku CSF config: $csf_conf" >&2 + return 1 + } + + tmp="$(mktemp)" + while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ ^([[:space:]]*)(TCP6?_IN)([[:space:]]*=[[:space:]]*)\"([^\"]*)\"(.*)$ ]]; then + new_list="$(remove_port_from_csv "${BASH_REMATCH[4]}" "$port")" + new_line="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}\"$new_list\"${BASH_REMATCH[5]}" + if [ "$new_line" != "$line" ]; then + changed=1 + fi + printf '%s\n' "$new_line" >> "$tmp" + else + printf '%s\n' "$line" >> "$tmp" + fi + done < "$csf_conf" + + if [ "$changed" -eq 1 ]; then + cat "$tmp" > "$csf_conf" + chmod 0600 "$csf_conf" + changed_csf=1 + fi + rm -f "$tmp" +} + +ensure_remote_bind_address() { + local cnf="$1" + local desired="$2" + local tmp changed=0 inserted=0 in_mariadb=0 line new_line + + [ -f "$cnf" ] || { + echo "mysql: brak pliku konfiguracji MariaDB: $cnf" >&2 + return 1 + } + is_ipv4 "$desired" || { + echo "mysql: nieprawidłowy bind-address dla zdalnego MySQL: $desired" >&2 + return 1 + } + + tmp="$(mktemp)" + while IFS= read -r line || [ -n "$line" ]; do + if [[ "$line" =~ ^[[:space:]]*\[mariadb\][[:space:]]*$ ]]; then + in_mariadb=1 + printf '%s\n' "$line" >> "$tmp" + continue + fi + + if [ "$in_mariadb" -eq 1 ] && [[ "$line" =~ ^[[:space:]]*\[.*\][[:space:]]*$ ]]; then + if [ "$inserted" -eq 0 ]; then + printf 'bind-address=%s\n' "$desired" >> "$tmp" + inserted=1 + changed=1 + fi + in_mariadb=0 + fi + + if [ "$in_mariadb" -eq 1 ] && [[ "$line" =~ ^[[:space:]]*bind-address[[:space:]]*= ]]; then + new_line="bind-address=$desired" + if [ "$line" != "$new_line" ]; then + changed=1 + fi + printf '%s\n' "$new_line" >> "$tmp" + inserted=1 + continue + fi + + printf '%s\n' "$line" >> "$tmp" + done < "$cnf" + + if [ "$inserted" -eq 0 ]; then + printf '\n[mariadb]\nbind-address=%s\n' "$desired" >> "$tmp" + changed=1 + fi + + if [ "$changed" -eq 1 ]; then + cat "$tmp" > "$cnf" + chmod 0640 "$cnf" + changed_bind=1 + fi + rm -f "$tmp" +} + +reload_csf() { + if [ "${MYSQL_HOST_SYNC_SKIP_CSF_RELOAD:-0}" = "1" ]; then + return 0 + fi + + if command -v csf >/dev/null 2>&1; then + csf -r + return 0 + fi + + echo "mysql: nie znaleziono polecenia csf, nie można przeładować firewalla." >&2 + return 1 +} + +restart_mariadb_service() { + local service="$1" + + if [ "${MYSQL_HOST_SYNC_SKIP_SERVICE_RESTART:-0}" = "1" ]; then + return 0 + fi + + if command -v systemctl >/dev/null 2>&1; then + systemctl restart "$service" + return 0 + fi + + if command -v service >/dev/null 2>&1; then + service "$service" restart + return 0 + fi + + echo "mysql: nie znaleziono systemctl/service, nie można zrestartować $service." >&2 + return 1 +} + +main() { + require_root_or_sudo "$@" + load_plugin_settings "$PLUGIN_SETTINGS_FILE" + mysql_load_alt_credentials + mysql_ensure_metadata_schema + + local csf_dir="${MYSQL_HOST_SYNC_CSF_DIR:-/etc/csf}" + local csf_allow="${MYSQL_HOST_SYNC_CSF_ALLOW:-$csf_dir/csf.allow}" + local csf_conf="${MYSQL_HOST_SYNC_CSF_CONF:-$csf_dir/csf.conf}" + local managed_allow="${MYSQL_HOST_SYNC_MANAGED_ALLOW:-$csf_dir/alt-mysql-plugin.allow}" + local instance_cnf="${MYSQL_HOST_SYNC_INSTANCE_CNF:-$MYSQL_ALT_INSTANCE_CNF}" + local bind_address="${MYSQL_REMOTE_BIND_ADDRESS:-${MYSQL_PLUGIN_REMOTE_BIND_ADDRESS:-0.0.0.0}}" + local service="${MYSQL_HOST_SYNC_SERVICE:-$MYSQL_ALT_SERVICE_NAME}" + local changed_csf=0 changed_bind=0 host_output + local -a hosts=() + + host_output="$(collect_remote_hosts)" + while IFS= read -r host || [ -n "$host" ]; do + [ -n "$host" ] || continue + hosts+=("$host") + done <<< "$host_output" + + write_managed_allow_file "$managed_allow" "$MYSQL_PORT" "${hosts[@]}" + ensure_csf_include "$csf_allow" "$managed_allow" + remove_port_from_global_csf_lists "$csf_conf" "$MYSQL_PORT" + ensure_remote_bind_address "$instance_cnf" "$bind_address" + + if [ "$changed_bind" -eq 1 ]; then + restart_mariadb_service "$service" + fi + + if [ "$changed_csf" -eq 1 ]; then + reload_csf + fi + + cat <&2 +} + +die() { + printf '[BŁĄD] %s\n' "$*" >&2 + exit 1 +} + +cleanup() { + if [ -n "${TMP_DIR:-}" ] && [ -d "$TMP_DIR" ]; then + rm -rf "$TMP_DIR" + fi +} +trap cleanup EXIT + +rollback_upgrade() { + local code="$1" + if [ "$code" -eq 0 ] || [ "$UPGRADE_COMMITTED" -eq 1 ]; then + cleanup + return 0 + fi + + warn "Upgrade nie zakończył się poprawnie, uruchamiam rollback." + + if [ -n "$INSTANCE_CNF_BACKUP" ] && [ -r "$INSTANCE_CNF_BACKUP" ]; then + cp -a "$INSTANCE_CNF_BACKUP" "$INSTANCE_CNF" || true + warn "RESTORED_INSTANCE_CNF: $INSTANCE_CNF" + fi + + if [ "$UPGRADE_EXTRACTED" -eq 1 ] && [ -d "$MARIADB_DIR" ]; then + if [ -n "$TARGET_BACKUP_DIR" ] && [ -d "$TARGET_BACKUP_DIR" ]; then + rm -rf "$MARIADB_DIR" || true + elif [ "$MARIADB_DIR" != "$CURRENT_MARIADB_DIR" ]; then + rm -rf "$MARIADB_DIR" || true + fi + fi + + if [ -n "$TARGET_BACKUP_DIR" ] && [ -d "$TARGET_BACKUP_DIR" ] && [ ! -e "$MARIADB_DIR" ]; then + mv "$TARGET_BACKUP_DIR" "$MARIADB_DIR" || true + warn "RESTORED_PREVIOUS_DIR: $MARIADB_DIR" + fi + + if [ "$SERVICE_WAS_ACTIVE" -eq 1 ]; then + if command -v systemctl >/dev/null 2>&1; then + systemctl daemon-reload || true + systemctl restart "$SERVICE_NAME" || true + fi + fi + + cleanup +} +trap 'code=$?; rollback_upgrade "$code"' EXIT + +require_root() { + if [ "${EUID:-$(id -u)}" -ne 0 ]; then + die "Ten skrypt musi być uruchomiony jako root." + fi +} + +validate_version() { + case "$TARGET_MARIADB_VERSION" in + 10.11|11.4|11.8) + log "Wybrano docelową wersję MariaDB: $TARGET_MARIADB_VERSION." + ;; + *) + die "Nieobsługiwana wersja MariaDB: $TARGET_MARIADB_VERSION. Dozwolone wartości: 10.11, 11.4, 11.8." + ;; + esac +} + +detect_architecture() { + case "$(uname -m)" in + x86_64|amd64) printf 'x86_64\n' ;; + *) die "Nieobsługiwana architektura: $(uname -m). Skrypt obsługuje automatycznie tylko x86_64/amd64." ;; + esac +} + +detect_current_mariadb_dir() { + if [ -r "$INSTANCE_CNF" ]; then + CURRENT_MARIADB_DIR="$(awk -F= ' + /^[[:space:]]*basedir[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' "$INSTANCE_CNF")" + DATADIR="$(awk -F= ' + /^[[:space:]]*datadir[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' "$INSTANCE_CNF")" + PORT="$(awk -F= ' + /^[[:space:]]*port[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' "$INSTANCE_CNF")" + SOCKET="$(awk -F= ' + /^[[:space:]]*socket[[:space:]]*=/ { + value=$2 + gsub(/^[[:space:]]+|[[:space:]]+$/, "", value) + print value + exit + } + ' "$INSTANCE_CNF")" + fi + + if [ -z "$CURRENT_MARIADB_DIR" ] && systemctl cat "$SERVICE_NAME" >/dev/null 2>&1; then + CURRENT_MARIADB_DIR="$(systemctl cat "$SERVICE_NAME" \ + | awk '/ExecStart=/ { + sub(/^.*ExecStart=/, "") + sub(/\/bin\/mariadbd.*$/, "") + print + exit + }')" + fi + + [ -n "$CURRENT_MARIADB_DIR" ] || die "Nie udało się wykryć bieżącego katalogu MariaDB." + [ -x "$CURRENT_MARIADB_DIR/bin/mariadbd" ] || die "Bieżący katalog MariaDB nie zawiera bin/mariadbd: $CURRENT_MARIADB_DIR." + [ -n "$DATADIR" ] || die "Nie udało się wykryć katalogu danych z $INSTANCE_CNF." + log "Wykryto bieżący katalog MariaDB: $CURRENT_MARIADB_DIR." +} + +resolve_latest_release_from_archive() { + local series="$1" + local listing="" + local release="" + + listing="$(curl -fsSL "https://archive.mariadb.org/" || true)" + release="$(printf '%s\n' "$listing" \ + | grep -Eo "mariadb-${series//./\\.}\.[0-9]+/" \ + | sed 's#^mariadb-##; s#/$##' \ + | sort -V \ + | tail -n 1)" + + [ -n "$release" ] || return 1 + printf '%s\n' "$release" +} + +resolve_tarball_from_directory() { + local release="$1" + local arch="$2" + local dir_url="https://archive.mariadb.org/mariadb-${release}/bintar-linux-systemd-${arch}/" + local listing="" + local file="" + + listing="$(curl -fsSL "$dir_url" || true)" + file="$(printf '%s\n' "$listing" \ + | grep -Eo "mariadb-${release}-linux-systemd-${arch}\.tar\.(gz|xz)" \ + | head -n 1)" + + [ -n "$file" ] || return 1 + printf '%s%s\n' "$dir_url" "$file" +} + +resolve_download_url() { + local arch="" + + arch="$(detect_architecture)" + if [ -n "$MARIADB_TARBALL_URL" ]; then + DOWNLOAD_URL="$MARIADB_TARBALL_URL" + log "Używam ręcznie ustawionego adresu tarballa MariaDB." + return 0 + fi + + RESOLVED_MARIADB_RELEASE="$(resolve_latest_release_from_archive "$TARGET_MARIADB_VERSION" || true)" + [ -n "$RESOLVED_MARIADB_RELEASE" ] || die "Nie udało się wykryć najnowszego wydania dla gałęzi $TARGET_MARIADB_VERSION." + DOWNLOAD_URL="$(resolve_tarball_from_directory "$RESOLVED_MARIADB_RELEASE" "$arch" || true)" + [ -n "$DOWNLOAD_URL" ] || die "Nie udało się automatycznie ustalić URL tarballa. Ustaw MARIADB_TARBALL_URL." + log "Wykryto tarball MariaDB $RESOLVED_MARIADB_RELEASE." +} + +download_tarball() { + local filename="" + + install -d -m 0755 "$DOWNLOAD_DIR" + filename="$(basename "${DOWNLOAD_URL%%\?*}")" + TARBALL_PATH="$DOWNLOAD_DIR/$filename" + log "Pobieram tarball MariaDB do $TARBALL_PATH." + curl -fL --retry 3 --connect-timeout 20 -o "$TARBALL_PATH" "$DOWNLOAD_URL" + [ -s "$TARBALL_PATH" ] || die "Pobrany tarball jest pusty albo nie istnieje: $TARBALL_PATH." +} + +extract_tarball() { + local extracted_dir="" + local backup_dir="" + + if [ -d "$MARIADB_DIR" ] && [ -n "$(find "$MARIADB_DIR" -mindepth 1 -maxdepth 1 -print -quit)" ]; then + if [ "$FORCE_UPGRADE" != "yes" ]; then + die "Katalog docelowy $MARIADB_DIR już istnieje. Ustaw FORCE_UPGRADE=yes, aby zastąpić go po kopii." + fi + TARGET_BACKUP_DIR="${MARIADB_DIR}.pre-upgrade.$(date +%Y%m%d%H%M%S)" + log "Tworzę kopię istniejącego katalogu docelowego: $TARGET_BACKUP_DIR." + mv "$MARIADB_DIR" "$TARGET_BACKUP_DIR" + fi + + TMP_DIR="$(mktemp -d)" + tar -xf "$TARBALL_PATH" -C "$TMP_DIR" + extracted_dir="$(find "$TMP_DIR" -mindepth 1 -maxdepth 1 -type d -print -quit)" + [ -n "$extracted_dir" ] || die "Tarball nie zawiera oczekiwanego katalogu MariaDB." + mv "$extracted_dir" "$MARIADB_DIR" + chown -R root:root "$MARIADB_DIR" + UPGRADE_EXTRACTED=1 + log "Nowe binaria MariaDB są w $MARIADB_DIR." +} + +rewrite_instance_basedir() { + local backup="" + + [ -r "$INSTANCE_CNF" ] || die "Brak konfiguracji instancji: $INSTANCE_CNF." + backup="${INSTANCE_CNF}.pre-upgrade.$(date +%Y%m%d%H%M%S)" + cp -a "$INSTANCE_CNF" "$backup" + INSTANCE_CNF_BACKUP="$backup" + sed -i "s#^[[:space:]]*basedir[[:space:]]*=.*#basedir=$MARIADB_DIR#" "$INSTANCE_CNF" + sed -i "s#^[[:space:]]*plugin-dir[[:space:]]*=.*#plugin-dir=$MARIADB_DIR/lib/plugin#" "$INSTANCE_CNF" + log "Zaktualizowano basedir i plugin-dir w $INSTANCE_CNF. Kopia: $backup." +} + +restart_service() { + log "Restartuję usługę $SERVICE_NAME." + systemctl daemon-reload + systemctl restart "$SERVICE_NAME" + sleep 5 + systemctl is-active --quiet "$SERVICE_NAME" || { + systemctl status "$SERVICE_NAME" --no-pager || true + die "Usługa $SERVICE_NAME nie uruchomiła się po upgrade." + } +} + +run_mariadb_upgrade() { + local upgrade_bin="" + + if [ -x "$MARIADB_DIR/bin/mariadb-upgrade" ]; then + upgrade_bin="$MARIADB_DIR/bin/mariadb-upgrade" + elif [ -x "$MARIADB_DIR/bin/mysql_upgrade" ]; then + upgrade_bin="$MARIADB_DIR/bin/mysql_upgrade" + else + warn "Nie znaleziono mariadb-upgrade ani mysql_upgrade, pomijam etap aktualizacji tabel systemowych." + return 0 + fi + + [ -r "$ALT_MY_CNF" ] || die "Brak pliku klienta alternatywnej instancji: $ALT_MY_CNF." + log "Uruchamiam aktualizację tabel systemowych MariaDB." + "$upgrade_bin" --defaults-extra-file="$ALT_MY_CNF" +} + +credential_group() { + if getent group diradmin >/dev/null 2>&1; then + printf 'diradmin\n' + else + printf 'root\n' + fi +} + +sh_single_quote() { + local value="$1" + value="${value//\'/\'\\\'\'}" + printf "'%s'" "$value" +} + +write_alt_metadata_env() { + local group_name metadata_mode tmp_file + group_name="$(credential_group)" + metadata_mode="0600" + if [ "$group_name" = "diradmin" ]; then + metadata_mode="0640" + fi + + tmp_file="$(mktemp)" + cat > "$tmp_file" <&2 + exit 1 +} + +[ -d "$PHPMYADMIN_DIR" ] || fail "phpMyAdmin directory not found: $PHPMYADMIN_DIR" +[ -r "$PHPMYADMIN_DIR/index.php" ] || fail "phpMyAdmin index.php is not readable: $PHPMYADMIN_DIR/index.php" +[ -r "$CONFIG_INC" ] || fail "config.inc.php is not readable: $CONFIG_INC" +[ -r "$SIGNON_SCRIPT" ] || fail "signon script is not readable: $SIGNON_SCRIPT" +[ -r "$SIGNON_URL_PAGE" ] || fail "SignonURL page is not readable: $SIGNON_URL_PAGE" +grep -Fq "DA_MYSQL_PMA_DEFAULT_CONF" "$CONFIG_INC" || fail "config.inc.php does not configure alt-mysql endpoint" +grep -Fq "auth_type'] = 'signon" "$CONFIG_INC" || fail "config.inc.php does not configure signon auth" +grep -Fq "SignonURL'] = da_mysql_phpmyadmin_signon_url" "$CONFIG_INC" || fail "config.inc.php does not configure dynamic SignonURL" +grep -Fq "da_mysql_login_consume_ticket" "$SIGNON_URL_PAGE" || fail "da_login.php does not consume SSO tickets" +grep -Fq "DA_MYSQL_DEFAULT_CONF = '/usr/local/directadmin/conf/alt-mysql.conf'" "$SIGNON_SCRIPT" \ + || fail "signon script does not default to alt-mysql.conf" + +if [ -r "$RUNTIME_CONFIG" ] && command -v php >/dev/null 2>&1; then + php -r ' + $path = $argv[1]; + $data = json_decode(file_get_contents($path), true); + if (!is_array($data)) { + fwrite(STDERR, "runtime config is not valid JSON\n"); + exit(2); + } + if (($data["credentials_file"] ?? "") === "/usr/local/directadmin/conf/mysql.conf") { + fwrite(STDERR, "runtime config points to native DirectAdmin mysql.conf\n"); + exit(3); + } + ' "$RUNTIME_CONFIG" || fail "runtime config validation failed" +fi + +echo "phpMyAdmin alt-mysql health: OK" diff --git a/scripts/setup/phpmyadmin_install.sh b/scripts/setup/phpmyadmin_install.sh new file mode 100644 index 0000000..fb4c286 --- /dev/null +++ b/scripts/setup/phpmyadmin_install.sh @@ -0,0 +1,486 @@ +#!/bin/bash +set -euo pipefail + +PHPMYADMIN_PRIVATE_DIR="${PHPMYADMIN_PRIVATE_DIR:-${PHPMYADMIN_DIR:-/var/www/html/alt-mysql-phpmyadmin}}" +PHPMYADMIN_VERSION="${PHPMYADMIN_VERSION:-5.2.3}" +PHPMYADMIN_TARBALL_URL="${PHPMYADMIN_TARBALL_URL:-https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.gz}" +PHPMYADMIN_DOWNLOAD_DIR="${PHPMYADMIN_DOWNLOAD_DIR:-/usr/local/src}" +PHPMYADMIN_SOURCE_DIR="${PHPMYADMIN_SOURCE_DIR:-}" +RUNTIME_DIR="${PHPMYADMIN_PRIVATE_DIR}/runtime" +TICKETS_DIR="${RUNTIME_DIR}/tickets" +CONFIG_INC="${PHPMYADMIN_PRIVATE_DIR}/config.inc.php" +SIGNON_SCRIPT="${PHPMYADMIN_PRIVATE_DIR}/da_signon.php" +SIGNON_URL_PAGE="${PHPMYADMIN_PRIVATE_DIR}/da_login.php" +BLOWFISH_SECRET_FILE="${RUNTIME_DIR}/blowfish_secret" + +if [ "${PHPMYADMIN_INSTALL_ASSUME_ROOT:-0}" != "1" ] && [ "${EUID:-$(id -u)}" -ne 0 ]; then + echo "Skrypt musi być uruchomiony jako root." >&2 + exit 1 +fi + +log() { + printf 'phpMyAdmin alt-mysql: %s\n' "$*" +} + +die() { + printf 'phpMyAdmin alt-mysql: %s\n' "$*" >&2 + exit 1 +} + +install_private_copy_from_source() { + local source_dir="$1" + [ -d "$source_dir" ] || die "Katalog źródłowy phpMyAdmin nie istnieje: $source_dir" + [ -f "$source_dir/index.php" ] || die "Katalog źródłowy nie wygląda jak phpMyAdmin: $source_dir" + + mkdir -p "$PHPMYADMIN_PRIVATE_DIR" + cp -a "$source_dir"/. "$PHPMYADMIN_PRIVATE_DIR"/ +} + +download_private_copy() { + local tarball extracted tmp_dir + + command -v curl >/dev/null 2>&1 || die "Brak curl; ustaw PHPMYADMIN_SOURCE_DIR albo doinstaluj curl." + command -v tar >/dev/null 2>&1 || die "Brak tar." + + mkdir -p "$PHPMYADMIN_DOWNLOAD_DIR" + tarball="$PHPMYADMIN_DOWNLOAD_DIR/phpMyAdmin-${PHPMYADMIN_VERSION}-all-languages.tar.gz" + if [ ! -s "$tarball" ]; then + log "Pobieram prywatny phpMyAdmin ${PHPMYADMIN_VERSION}." + curl -fL --retry 3 --connect-timeout 20 -o "$tarball" "$PHPMYADMIN_TARBALL_URL" + fi + + tmp_dir="$(mktemp -d)" + tar -xzf "$tarball" -C "$tmp_dir" + extracted="$(find "$tmp_dir" -mindepth 1 -maxdepth 1 -type d -name 'phpMyAdmin-*' -print -quit)" + [ -n "$extracted" ] || { + rm -rf "$tmp_dir" + die "Tarball phpMyAdmin nie zawiera oczekiwanego katalogu." + } + + install_private_copy_from_source "$extracted" + rm -rf "$tmp_dir" +} + +ensure_private_copy() { + if [ -f "$PHPMYADMIN_PRIVATE_DIR/index.php" ]; then + return 0 + fi + + rm -rf "$PHPMYADMIN_PRIVATE_DIR" + if [ -n "$PHPMYADMIN_SOURCE_DIR" ]; then + install_private_copy_from_source "$PHPMYADMIN_SOURCE_DIR" + else + download_private_copy + fi +} + +generate_secret() { + if [ -r "$BLOWFISH_SECRET_FILE" ]; then + local existing + existing="$(tr -d '\r\n' < "$BLOWFISH_SECRET_FILE")" + if [ -n "$existing" ]; then + printf '%s\n' "$existing" + return 0 + fi + fi + + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return 0 + fi + + tr -dc 'a-f0-9' "$BLOWFISH_SECRET_FILE" + chmod 600 "$BLOWFISH_SECRET_FILE" 2>/dev/null || true + cat > "$RUNTIME_DIR/.htaccess" <<'EOF' +Require all denied +Deny from all +EOF + cat > "$TICKETS_DIR/.htaccess" <<'EOF' +Require all denied +Deny from all +EOF + printf '%s\n' 'Forbidden' > "$RUNTIME_DIR/index.html" + printf '%s\n' 'Forbidden' > "$TICKETS_DIR/index.html" + + cat > "$CONFIG_INC" <<'PHP' + '127.0.0.1', + 'port' => '33033', + 'socket' => '', + ]; + + if (!is_readable($path)) { + return $data; + } + + $lines = file($path, FILE_IGNORE_NEW_LINES); + if ($lines === false) { + return $data; + } + + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || $line[0] === '#' || strpos($line, '=') === false) { + continue; + } + [$key, $value] = explode('=', $line, 2); + $key = strtolower(trim($key)); + $value = trim(trim($value), "\"'"); + if ($key === 'host' && $value !== '') { + $data['host'] = $value; + } elseif (($key === 'port' || $key === 'mysql_port') && ctype_digit($value)) { + $data['port'] = $value; + } elseif (($key === 'socket' || $key === 'mysql_socket') && $value !== '') { + $data['socket'] = $value; + } + } + + if ($data['host'] === '' || $data['host'] === 'localhost') { + $data['host'] = '127.0.0.1'; + } + + return $data; +} + +function da_mysql_phpmyadmin_blowfish_secret(): string +{ + if (is_readable(DA_MYSQL_PMA_BLOWFISH_SECRET_FILE)) { + $secret = trim((string)file_get_contents(DA_MYSQL_PMA_BLOWFISH_SECRET_FILE)); + if ($secret !== '') { + return $secret; + } + } + + return 'alt-mysql-phpmyadmin-secret-change-me'; +} + +function da_mysql_phpmyadmin_url_path(array $runtimeConfig): string +{ + $path = trim((string)($runtimeConfig['phpmyadmin_url_path'] ?? '/alt-mysql-phpmyadmin')); + if ($path === '') { + return '/alt-mysql-phpmyadmin'; + } + if ($path[0] !== '/') { + $path = '/' . $path; + } + + $path = rtrim($path, '/'); + return $path !== '' ? $path : '/alt-mysql-phpmyadmin'; +} + +function da_mysql_phpmyadmin_public_base_url(array $runtimeConfig): string +{ + $configured = trim((string)($runtimeConfig['phpmyadmin_public_base_url'] ?? '')); + if (preg_match('#^https?://#i', $configured) === 1) { + return rtrim($configured, '/'); + } + + $scheme = 'http'; + $https = strtolower((string)($_SERVER['HTTPS'] ?? '')); + if ($https !== '' && $https !== 'off' && $https !== '0') { + $scheme = 'https'; + } elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') { + $scheme = 'https'; + } elseif (strtolower((string)($_SERVER['REQUEST_SCHEME'] ?? '')) === 'https') { + $scheme = 'https'; + } + + $host = trim((string)($_SERVER['HTTP_HOST'] ?? '')); + if ($host === '') { + $host = trim((string)($_SERVER['SERVER_NAME'] ?? 'localhost')); + } + if ($host === '') { + $host = 'localhost'; + } + + return $scheme . '://' . $host; +} + +function da_mysql_phpmyadmin_signon_url(array $runtimeConfig): string +{ + return da_mysql_phpmyadmin_public_base_url($runtimeConfig) + . da_mysql_phpmyadmin_url_path($runtimeConfig) + . '/da_login.php'; +} + +$runtimeConfig = da_mysql_phpmyadmin_runtime_config(); +$credentialsFile = (string)($runtimeConfig['credentials_file'] ?? DA_MYSQL_PMA_DEFAULT_CONF); +if ($credentialsFile === '') { + $credentialsFile = DA_MYSQL_PMA_DEFAULT_CONF; +} +$daMysqlServer = da_mysql_phpmyadmin_parse_conf($credentialsFile); + +$cfg = []; +$cfg['blowfish_secret'] = da_mysql_phpmyadmin_blowfish_secret(); +$cfg['TempDir'] = __DIR__ . '/runtime/tmp'; +$cfg['LoginCookieValidity'] = 900; + +$i = 0; +$i++; +$cfg['Servers'][$i]['host'] = $daMysqlServer['host']; +$cfg['Servers'][$i]['port'] = $daMysqlServer['port']; +$cfg['Servers'][$i]['socket'] = $daMysqlServer['host'] === 'localhost' ? $daMysqlServer['socket'] : ''; +$cfg['Servers'][$i]['auth_type'] = 'signon'; +$cfg['Servers'][$i]['SignonScript'] = __DIR__ . '/da_signon.php'; +$cfg['Servers'][$i]['SignonSession'] = 'DA_MYSQL_PHPMYADMIN'; +$cfg['Servers'][$i]['SignonURL'] = da_mysql_phpmyadmin_signon_url($runtimeConfig); +$cfg['Servers'][$i]['LogoutURL'] = da_mysql_phpmyadmin_signon_url($runtimeConfig); +$cfg['Servers'][$i]['AllowNoPassword'] = false; +$cfg['Servers'][$i]['verbose'] = 'DirectAdmin alt-mysql'; +$cfg['Servers'][$i]['only_db'] = ''; +$cfg['ServerDefault'] = 1; +$cfg['AllowArbitraryServer'] = false; +PHP + + cat > "$SIGNON_URL_PAGE" <<'PHP' +alt-mysql phpMyAdmin'; + echo '

    alt-mysql phpMyAdmin

    ' . $safe . '

    '; + echo ''; + exit; +} + +function da_mysql_login_consume_ticket(array $runtimeConfig): array +{ + $token = strtolower((string)($_GET['ticket'] ?? '')); + if (preg_match('/\A[a-f0-9]{64}\z/', $token) !== 1) { + da_mysql_login_error('Missing or invalid phpMyAdmin login ticket.'); + } + + $path = da_mysql_login_ticket_dir($runtimeConfig) . '/' . $token . '.json'; + if (!is_readable($path)) { + da_mysql_login_error('The phpMyAdmin login ticket is not available or has already been used.'); + } + + $raw = file_get_contents($path); + @unlink($path); + $ticket = is_string($raw) ? json_decode($raw, true) : null; + if (!is_array($ticket)) { + da_mysql_login_error('The phpMyAdmin login ticket is invalid.'); + } + + if ((int)($ticket['expires_at'] ?? 0) < time()) { + da_mysql_login_error('The phpMyAdmin login ticket has expired.'); + } + + $auth = $ticket['auth'] ?? []; + if (!is_array($auth) || (string)($auth['user'] ?? '') === '' || (string)($auth['password'] ?? '') === '') { + da_mysql_login_error('The phpMyAdmin login ticket does not contain credentials.'); + } + + return $ticket; +} + +function da_mysql_login_redirect_target(array $ticket): string +{ + $route = (string)($ticket['route'] ?? '/database/structure'); + if (preg_match('#^/[A-Za-z0-9_./-]+$#', $route) !== 1 || strpos($route, '//') !== false) { + $route = '/'; + } + + $query = ['route' => $route]; + $db = (string)($ticket['db'] ?? ($ticket['auth']['db'] ?? '')); + if ($db !== '') { + $query['db'] = $db; + } + + return 'index.php?' . http_build_query($query); +} + +$runtimeConfig = da_mysql_login_runtime_config(); +$ticket = da_mysql_login_consume_ticket($runtimeConfig); +$secure = false; +$https = strtolower((string)($_SERVER['HTTPS'] ?? '')); +if ($https !== '' && $https !== 'off' && $https !== '0') { + $secure = true; +} elseif (strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https') { + $secure = true; +} + +session_name(DA_MYSQL_SESSION); +session_set_cookie_params([ + 'lifetime' => 0, + 'path' => da_mysql_login_url_path($runtimeConfig), + 'secure' => $secure, + 'httponly' => true, + 'samesite' => 'Lax', +]); +session_start(); +$_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] = $ticket['auth']; +session_write_close(); + +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Pragma: no-cache'); +header('Location: ' . da_mysql_login_redirect_target($ticket), true, 302); +exit; +PHP + + cat > "$SIGNON_SCRIPT" <<'PHP' +/dev/null || true + chmod 755 "$RUNTIME_DIR" "$TICKETS_DIR" "$RUNTIME_DIR/tmp" 2>/dev/null || true + chmod 644 "$CONFIG_INC" "$SIGNON_SCRIPT" "$SIGNON_URL_PAGE" "$RUNTIME_DIR/.htaccess" "$TICKETS_DIR/.htaccess" "$RUNTIME_DIR/index.html" "$TICKETS_DIR/index.html" 2>/dev/null || true +} + +ensure_private_copy +write_private_config + +log "Prywatna kopia phpMyAdmin dla alt-mysql jest gotowa w ${PHPMYADMIN_PRIVATE_DIR}." diff --git a/scripts/setup/phpmyadmin_private_install.sh b/scripts/setup/phpmyadmin_private_install.sh new file mode 100644 index 0000000..c5e73da --- /dev/null +++ b/scripts/setup/phpmyadmin_private_install.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$SCRIPT_DIR/phpmyadmin_install.sh" "$@" diff --git a/scripts/setup/phpmyadmin_repair.sh b/scripts/setup/phpmyadmin_repair.sh new file mode 100644 index 0000000..6abb4cf --- /dev/null +++ b/scripts/setup/phpmyadmin_repair.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec "$SCRIPT_DIR/phpmyadmin_install.sh" diff --git a/scripts/setup/recompile_php.sh b/scripts/setup/recompile_php.sh new file mode 100755 index 0000000..c8ffdd0 --- /dev/null +++ b/scripts/setup/recompile_php.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -euo pipefail + +CB_DIR="/usr/local/directadmin/custombuild" + +[ "${EUID:-$(id -u)}" -eq 0 ] || { + echo "Skrypt musi być uruchomiony jako root." >&2 + exit 1 +} + +[ -x "${CB_DIR}/build" ] || { + echo "Nie znaleziono DirectAdmin CustomBuild w ${CB_DIR}." >&2 + exit 1 +} + +php_slots=() +for slot in 1 2 3 4 5 6 7 8 9; do + version="$(awk -F= -v key="php${slot}_release" '$1 == key {print $2}' "${CB_DIR}/options.conf" 2>/dev/null | tr -d '[:space:]')" + case "${version,,}" in + ""|no|off) continue ;; + esac + php_slots+=("$version") +done + +[ ${#php_slots[@]} -gt 0 ] || { + echo "Nie wykryto aktywnych wersji PHP w CustomBuild." >&2 + exit 1 +} + +for version in "${php_slots[@]}"; do + echo "Rebuild PHP ${version}" + "${CB_DIR}/build" php "$version" +done + +"${CB_DIR}/build" rewrite_confs || true +echo "Rekompilacja PHP zakończona. Rozszerzenia mysqli i pdo_mysql są dostarczane przez standardowy build DirectAdmin." diff --git a/scripts/setup/restore_all.sh b/scripts/setup/restore_all.sh new file mode 100755 index 0000000..11f4867 --- /dev/null +++ b/scripts/setup/restore_all.sh @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./mysql_common.sh +source "$SCRIPT_DIR/mysql_common.sh" + +usage() { + cat <<'EOF' +Użycie: + restore_all.sh [--restore-users] + +Opis: + Przywraca wszystkie bazy MySQL z katalogu backupu. Jeśli obecny jest plik + `_globals.sql` lub `_globals.sql.gz`, można go odtworzyć flagą + `--restore-users`. +EOF +} + +BACKUP_DIR="" +RESTORE_USERS="0" +for arg in "$@"; do + case "$arg" in + --help|-h) + usage + exit 0 + ;; + --restore-users) + RESTORE_USERS="1" + ;; + *) + if [ -n "$BACKUP_DIR" ]; then + echo "Podano więcej niż jeden katalog backupu." >&2 + exit 1 + fi + BACKUP_DIR="$arg" + ;; + esac +done + +[ -n "$BACKUP_DIR" ] || { + usage + exit 1 +} +[ -d "$BACKUP_DIR" ] || { + echo "Katalog nie istnieje: $BACKUP_DIR" >&2 + exit 1 +} + +mysql_load_alt_credentials + +if [ "$RESTORE_USERS" = "1" ]; then + for globals_file in "$BACKUP_DIR/_globals.sql.gz" "$BACKUP_DIR/_globals.sql"; do + [ -f "$globals_file" ] || continue + mysql_restore_stream mysql "$globals_file" + break + done +fi + +find "$BACKUP_DIR" -maxdepth 1 -type f \( -name '*.sql' -o -name '*.sql.gz' \) ! -name '_globals.*' | sort | while IFS= read -r file; do + [ -n "$file" ] || continue + db_name="$(basename "$file" | sed -E 's/\.sql(\.gz)?$//')" + db_name="$(mysql_safe_identifier "$db_name")" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$db_name");" + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$db_name") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql_restore_stream "$db_name" "$file" + echo "Przywrócono ${db_name}" +done diff --git a/scripts/setup/roundcube_common.sh b/scripts/setup/roundcube_common.sh new file mode 100644 index 0000000..36dc5ba --- /dev/null +++ b/scripts/setup/roundcube_common.sh @@ -0,0 +1,186 @@ +#!/bin/bash +set -euo pipefail + +ROUNDCUBE_PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +ROUNDCUBE_SETTINGS_FILE="${ROUNDCUBE_SETTINGS_FILE:-$ROUNDCUBE_PLUGIN_DIR/plugin-settings.conf}" +ROUNDCUBE_SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=./mysql_common.sh +source "$ROUNDCUBE_SCRIPT_DIR/mysql_common.sh" +mysql_load_plugin_settings_file "$ROUNDCUBE_SETTINGS_FILE" + +roundcube_read_setting() { + local key="$1" + local default="$2" + local line value + [ -r "$ROUNDCUBE_SETTINGS_FILE" ] || { + printf '%s\n' "$default" + return 0 + } + line="$(grep -E "^${key}=" "$ROUNDCUBE_SETTINGS_FILE" | tail -n 1 || true)" + [ -n "$line" ] || { + printf '%s\n' "$default" + return 0 + } + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +roundcube_enabled() { + case "$(roundcube_read_setting ROUNDCUBE_ENABLED true)" in + true|yes|on|1) return 0 ;; + *) return 1 ;; + esac +} + +roundcube_config_file() { + roundcube_read_setting ROUNDCUBE_CONFIG_FILE /var/www/html/roundcube/config/config.inc.php +} + +roundcube_custom_config_file() { + roundcube_read_setting ROUNDCUBE_CUSTOM_CONFIG_FILE /usr/local/directadmin/custombuild/custom/roundcube/config.inc.php +} + +roundcube_write_custombuild_config() { + case "$(roundcube_read_setting ROUNDCUBE_WRITE_CUSTOMBUILD_CONFIG true)" in + true|yes|on|1) return 0 ;; + *) return 1 ;; + esac +} + +roundcube_db_name() { + roundcube_read_setting ROUNDCUBE_DB_NAME roundcube +} + +roundcube_db_user() { + roundcube_read_setting ROUNDCUBE_DB_USER roundcube +} + +roundcube_password_file() { + roundcube_read_setting ROUNDCUBE_DB_PASSWORD_FILE /usr/local/directadmin/conf/alt-roundcube.conf +} + +roundcube_native_my_cnf() { + roundcube_read_setting ROUNDCUBE_NATIVE_MY_CNF /usr/local/directadmin/conf/my.cnf +} + +roundcube_endpoint_mode() { + roundcube_read_setting ROUNDCUBE_ALT_DSN_MODE tcp +} + +roundcube_generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -base64 36 | tr -d '\n' + return 0 + fi + tr -dc 'A-Za-z0-9_@%+=' /dev/null 2>&1 && getent group diradmin >/dev/null 2>&1; then + group_name="diradmin" + elif command -v getent >/dev/null 2>&1 && getent group root >/dev/null 2>&1; then + group_name="root" + fi + tmp="$(mktemp)" + { + printf 'user=%s\n' "$user" + printf 'password=%s\n' "$pass" + } > "$tmp" + if [ "${EUID:-$(id -u)}" -eq 0 ] && [ -n "$group_name" ]; then + install -m 0640 -o root -g "$group_name" "$tmp" "$file" + else + install -m 0600 "$tmp" "$file" + fi + rm -f "$tmp" + printf '%s\n' "$pass" +} + +roundcube_load_password() { + local file pass + file="$(roundcube_password_file)" + [ -r "$file" ] || return 1 + pass="$(awk -F= '$1 == "password" || $1 == "passwd" { sub(/^[^=]*=/, ""); print; exit }' "$file")" + [ -n "$pass" ] || return 1 + printf '%s\n' "$pass" +} + +roundcube_urlencode() { + php -r 'echo rawurlencode($argv[1]);' "$1" +} + +roundcube_alt_dsn() { + local db user pass host port mode encoded_user encoded_pass + mysql_load_alt_runtime + db="$(roundcube_db_name)" + user="$(roundcube_db_user)" + pass="$(roundcube_load_or_create_password)" + mode="$(roundcube_endpoint_mode)" + encoded_user="$(roundcube_urlencode "$user")" + encoded_pass="$(roundcube_urlencode "$pass")" + + case "$mode" in + socket) + printf 'mysql://%s:%s@unix(%s)/%s\n' "$encoded_user" "$encoded_pass" "$MYSQL_ALT_SOCKET" "$db" + ;; + *) + host="127.0.0.1" + port="$MYSQL_ALT_PORT" + printf 'mysql://%s:%s@%s:%s/%s\n' "$encoded_user" "$encoded_pass" "$host" "$port" "$db" + ;; + esac +} + +roundcube_sql_quote() { + local value="$1" + value="${value//\\/\\\\}" + value="${value//\'/\'\'}" + printf "'%s'" "$value" +} + +roundcube_create_alt_database_and_user() { + local db user pass q_user q_pass + mysql_load_alt_credentials + mysql_ensure_metadata_schema + db="$(roundcube_db_name)" + user="$(roundcube_db_user)" + pass="$(roundcube_load_or_create_password)" + q_user="$(roundcube_sql_quote "$user")" + q_pass="$(roundcube_sql_quote "$pass")" + + mysql_safe_identifier "$db" >/dev/null + mysql_safe_identifier "$user" >/dev/null + + mysql_exec mysql -e "CREATE DATABASE IF NOT EXISTS $(mysql_quote_identifier "$db") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql_exec mysql -e "CREATE USER IF NOT EXISTS ${q_user}@'localhost' IDENTIFIED BY ${q_pass};" + mysql_exec mysql -e "ALTER USER ${q_user}@'localhost' IDENTIFIED BY ${q_pass};" + mysql_exec mysql -e "CREATE USER IF NOT EXISTS ${q_user}@'127.0.0.1' IDENTIFIED BY ${q_pass};" + mysql_exec mysql -e "ALTER USER ${q_user}@'127.0.0.1' IDENTIFIED BY ${q_pass};" + mysql_exec mysql -e "GRANT ALL PRIVILEGES ON $(mysql_quote_identifier "$db").* TO ${q_user}@'localhost';" + mysql_exec mysql -e "GRANT ALL PRIVILEGES ON $(mysql_quote_identifier "$db").* TO ${q_user}@'127.0.0.1';" + mysql_exec mysql -e " + INSERT INTO da_plugin_system_databases (service_name, db_name, db_user, endpoint_mode, config_path, enabled) + VALUES ('roundcube', '$(mysql_escape_literal "$db")', '$(mysql_escape_literal "$user")', '$(mysql_escape_literal "$(roundcube_endpoint_mode)")', '$(mysql_escape_literal "$(roundcube_config_file)")', 1) + ON DUPLICATE KEY UPDATE db_name=VALUES(db_name), db_user=VALUES(db_user), endpoint_mode=VALUES(endpoint_mode), config_path=VALUES(config_path), enabled=VALUES(enabled), updated_at=CURRENT_TIMESTAMP; + " +} diff --git a/scripts/setup/roundcube_health_check.sh b/scripts/setup/roundcube_health_check.sh new file mode 100644 index 0000000..ffa626b --- /dev/null +++ b/scripts/setup/roundcube_health_check.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./roundcube_common.sh +source "$SCRIPT_DIR/roundcube_common.sh" + +fail() { + echo "roundcube alt-mysql health: $*" >&2 + exit 1 +} + +roundcube_enabled || { + echo "roundcube alt-mysql health: disabled" + exit 0 +} + +CONFIG_FILE="$(roundcube_config_file)" +DB_NAME="$(roundcube_db_name)" +DB_USER="$(roundcube_db_user)" +PASS="$(roundcube_load_password)" || fail "Roundcube password file is missing or invalid: $(roundcube_password_file)" +mysql_load_alt_credentials +mysql_load_alt_runtime + +[ -r "$CONFIG_FILE" ] || fail "config file is not readable: $CONFIG_FILE" +grep -Fq "BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE" "$CONFIG_FILE" || fail "managed Roundcube DB block is missing" +grep -Fq "$DB_NAME" "$CONFIG_FILE" || fail "Roundcube config does not reference expected database" +if grep -Fq "/var/lib/mysql/mysql.sock" "$CONFIG_FILE"; then + fail "Roundcube config references native DirectAdmin socket" +fi +if grep -Fq "/usr/local/directadmin/conf/mysql.conf" "$CONFIG_FILE"; then + fail "Roundcube config references native DirectAdmin mysql.conf" +fi + +MYSQL_PWD="$PASS" "$(mysql_alt_binary mysql)" --protocol=TCP --host=127.0.0.1 --port="$MYSQL_ALT_PORT" --user="$DB_USER" --database="$DB_NAME" -e "SELECT 1" >/dev/null \ + || fail "cannot connect to Roundcube database on alt-mariadb as $DB_USER" + +echo "roundcube alt-mysql health: OK" diff --git a/scripts/setup/roundcube_migrate_to_alt_mysql.sh b/scripts/setup/roundcube_migrate_to_alt_mysql.sh new file mode 100644 index 0000000..84f9cf2 --- /dev/null +++ b/scripts/setup/roundcube_migrate_to_alt_mysql.sh @@ -0,0 +1,65 @@ +#!/bin/bash +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./roundcube_common.sh +source "$SCRIPT_DIR/roundcube_common.sh" + +FORCE="${FORCE:-0}" +SKIP_NATIVE_DUMP="${SKIP_NATIVE_DUMP:-0}" +LOG_FILE="${LOG_FILE:-$ROUNDCUBE_PLUGIN_DIR/data/logs/roundcube-migrate.log}" +mkdir -p "$(dirname "$LOG_FILE")" + +log() { + printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" +} + +die() { + log "ERROR: $*" + exit 1 +} + +roundcube_enabled || die "Roundcube integration is disabled by ROUNDCUBE_ENABLED." + +DB_NAME="$(roundcube_db_name)" +NATIVE_MY_CNF="$(roundcube_native_my_cnf)" +TMP_DUMP="$(mktemp)" +trap 'rm -f "$TMP_DUMP"' EXIT + +roundcube_create_alt_database_and_user + +if [ "$SKIP_NATIVE_DUMP" != "1" ]; then + [ -r "$NATIVE_MY_CNF" ] || die "Native DirectAdmin MySQL config not readable: $NATIVE_MY_CNF" + NATIVE_MYSQL_BIN="$(mysql_native_binary mysql)" || die "native mysql/mariadb client not found" + NATIVE_MYSQLDUMP_BIN="$(mysql_native_binary mysqldump)" || die "native mysqldump/mariadb-dump client not found" + + if ! "$NATIVE_MYSQL_BIN" --defaults-extra-file="$NATIVE_MY_CNF" --batch --skip-column-names information_schema \ + -e "SELECT 1 FROM SCHEMATA WHERE SCHEMA_NAME='$(mysql_escape_literal "$DB_NAME")' LIMIT 1" | grep -q 1; then + if [ "$FORCE" != "1" ]; then + die "Native Roundcube database $DB_NAME not found. Use SKIP_NATIVE_DUMP=1 if this is a new Roundcube install." + fi + log "Native Roundcube database $DB_NAME not found; continuing because FORCE=1." + else + log "Dumping native Roundcube database: $DB_NAME" + "$NATIVE_MYSQLDUMP_BIN" --defaults-extra-file="$NATIVE_MY_CNF" \ + --single-transaction \ + --quick \ + --skip-lock-tables \ + --routines \ + --triggers \ + --events \ + --default-character-set=utf8mb4 \ + "$DB_NAME" > "$TMP_DUMP" 2>>"$LOG_FILE" || die "native Roundcube dump failed" + + log "Importing Roundcube database into alt-mariadb: $DB_NAME" + mysql_exec mysql -e "DROP DATABASE IF EXISTS $(mysql_quote_identifier "$DB_NAME");" + mysql_exec mysql -e "CREATE DATABASE $(mysql_quote_identifier "$DB_NAME") CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql_exec "$DB_NAME" < "$TMP_DUMP" >> "$LOG_FILE" 2>&1 || die "Roundcube import into alt-mariadb failed" + roundcube_create_alt_database_and_user + fi +fi + +"$SCRIPT_DIR/roundcube_repair_config.sh" +"$SCRIPT_DIR/roundcube_health_check.sh" + +log "Roundcube migration to alt-mariadb completed." diff --git a/scripts/setup/roundcube_repair_config.sh b/scripts/setup/roundcube_repair_config.sh new file mode 100644 index 0000000..6d632e8 --- /dev/null +++ b/scripts/setup/roundcube_repair_config.sh @@ -0,0 +1,79 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +# shellcheck source=./roundcube_common.sh +source "$SCRIPT_DIR/roundcube_common.sh" + +rewrite_roundcube_config() { + local source_file="$1" + local target_file="$2" + local dsn="$3" + local tmp parent + + [ -f "$source_file" ] || { + echo "roundcube: source config file not found: $source_file" >&2 + return 1 + } + + if [ -e "$target_file" ]; then + [ -w "$target_file" ] || { + echo "roundcube: config file is not writable: $target_file" >&2 + return 1 + } + else + parent="$(dirname "$target_file")" + mkdir -p "$parent" + fi + + tmp="$(mktemp)" + awk ' + /BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE/ { skip=1; next } + /END DIRECTADMIN MYSQL PLUGIN ROUNDCUBE/ { skip=0; next } + skip != 1 { print } + ' "$source_file" > "$tmp" + + cat >> "$tmp" < "$target_file" + else + install -m 0644 "$tmp" "$target_file" + fi + rm -f "$tmp" +} + +roundcube_enabled || { + echo "roundcube: integration disabled by ROUNDCUBE_ENABLED." + exit 0 +} + +CONFIG_FILE="$(roundcube_config_file)" +[ -f "$CONFIG_FILE" ] || { + echo "roundcube: config file not found: $CONFIG_FILE" >&2 + exit 1 +} +[ -w "$CONFIG_FILE" ] || { + echo "roundcube: config file is not writable: $CONFIG_FILE" >&2 + exit 1 +} + +DSN="$(roundcube_alt_dsn)" +rewrite_roundcube_config "$CONFIG_FILE" "$CONFIG_FILE" "$DSN" + +echo "roundcube: config repaired: $CONFIG_FILE" + +if roundcube_write_custombuild_config; then + CUSTOM_CONFIG_FILE="$(roundcube_custom_config_file)" + if [ -f "$CUSTOM_CONFIG_FILE" ]; then + rewrite_roundcube_config "$CUSTOM_CONFIG_FILE" "$CUSTOM_CONFIG_FILE" "$DSN" + else + rewrite_roundcube_config "$CONFIG_FILE" "$CUSTOM_CONFIG_FILE" "$DSN" + fi + echo "roundcube: CustomBuild config repaired: $CUSTOM_CONFIG_FILE" +fi diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100755 index 0000000..e5d342c --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,106 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CPIF="/usr/local/directadmin/data/admin/custom_package_items.conf" +SUDOERS_FILE="/etc/sudoers.d/directadmin-alt-mysql-plugin" +CRON_FILE="/etc/cron.d/alt-mysql-jobs" +CSF_ALLOW_FILE="/etc/csf/csf.allow" +CSF_MANAGED_ALLOW_FILE="/etc/csf/alt-mysql-plugin.allow" +DA_INTEGRATION_INSTALLER="$(cd "$(dirname "$0")/.." && pwd)/scripts/setup/da_integration_install.sh" +CUSTOMBUILD_HOOKS_INSTALLER="$(cd "$(dirname "$0")/.." && pwd)/scripts/setup/custombuild_hooks_install.sh" + +read_plugin_setting() { + local key="$1" + local default="$2" + local file="$PLUGIN_DIR/plugin-settings.conf" + local line value + + [ -r "$file" ] || { + printf '%s\n' "$default" + return 0 + } + + line="$(grep -E "^${key}=" "$file" | tail -n 1 || true)" + if [ -z "$line" ]; then + printf '%s\n' "$default" + return 0 + fi + + value="${line#*=}" + value="${value%%#*}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + value="$(printf '%s' "$value" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" + printf '%s\n' "${value:-$default}" +} + +cleanup_csf_managed_file() { + local managed_file="$1" + local tmp include_line changed=0 + + include_line="Include $managed_file" + + if [ -f "$CSF_ALLOW_FILE" ]; then + tmp="$(mktemp)" + grep -Fxv "$include_line" "$CSF_ALLOW_FILE" > "$tmp" || true + if ! cmp -s "$tmp" "$CSF_ALLOW_FILE"; then + cat "$tmp" > "$CSF_ALLOW_FILE" + changed=1 + fi + rm -f "$tmp" + fi + + if [ -f "$managed_file" ]; then + rm -f "$managed_file" + changed=1 + fi + + return "$changed" +} + +cleanup_csf_remote_mysql() { + local changed=0 configured_managed_allow + + CSF_ALLOW_FILE="$(read_plugin_setting MYSQL_PLUGIN_CSF_ALLOW_FILE "$CSF_ALLOW_FILE")" + configured_managed_allow="$(read_plugin_setting MYSQL_PLUGIN_CSF_MANAGED_ALLOW_FILE "$CSF_MANAGED_ALLOW_FILE")" + + cleanup_csf_managed_file "$configured_managed_allow" || changed=1 + + if [ "$changed" -eq 1 ] && command -v csf >/dev/null 2>&1; then + csf -r >/dev/null 2>&1 || true + fi +} + +if [ "$(id -u)" -eq 0 ]; then + if [ -f "$CPIF" ]; then + sed -i '/^mysql_enabled=/d' "$CPIF" || true + sed -i '/^mysql=/d' "$CPIF" || true + sed -i '/^umysql=/d' "$CPIF" || true + sed -i '/^mysql_max_databases=/d' "$CPIF" || true + sed -i '/^mysql_unlimited=/d' "$CPIF" || true + sed -i '/^umysql_max_databases=/d' "$CPIF" || true + chown diradmin:diradmin "$CPIF" 2>/dev/null || true + chmod 640 "$CPIF" 2>/dev/null || true + fi + + rm -f "$SUDOERS_FILE" "$CRON_FILE" || true + cleanup_csf_remote_mysql + if [ -x "$DA_INTEGRATION_INSTALLER" ]; then + "$DA_INTEGRATION_INSTALLER" uninstall || true + fi + if [ -x "$CUSTOMBUILD_HOOKS_INSTALLER" ]; then + "$CUSTOMBUILD_HOOKS_INSTALLER" uninstall || true + fi +else + echo "mysql: warning: uruchom jako root, aby usunąć wpisy z custom_package_items i sudoers" >&2 +fi + +if [ -f "$PLUGIN_DIR/plugin.conf" ]; then + sed -i 's/^active=.*/active=no/' "$PLUGIN_DIR/plugin.conf" || true + sed -i 's/^installed=.*/installed=no/' "$PLUGIN_DIR/plugin.conf" || true +fi + +echo "mysql: deaktywowany." diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..430dd6e --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,3 @@ +#!/bin/bash +set -euo pipefail +"$(cd "$(dirname "$0")" && pwd)/install.sh" diff --git a/scripts/worker.sh b/scripts/worker.sh new file mode 100644 index 0000000..1511d7e --- /dev/null +++ b/scripts/worker.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DATA_DIR="$PLUGIN_DIR/data" +JOB_DIR="$DATA_DIR/jobs/pending" +PROCESSING_DIR="$DATA_DIR/jobs/processing" +DONE_DIR="$DATA_DIR/jobs/done" +WORKER_LOCK_DIR="$DATA_DIR/worker.lock" +DB_LOCK_DIR="$DATA_DIR/lock" +PID_DIR="$DATA_DIR/pids" +CANCEL_DIR="$DATA_DIR/cancel" + +mkdir -p "$JOB_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$PID_DIR" "$CANCEL_DIR" "$DB_LOCK_DIR" + +if ! mkdir "$WORKER_LOCK_DIR" 2>/dev/null; then + exit 0 +fi + +cleanup() { + rmdir "$WORKER_LOCK_DIR" 2>/dev/null || true +} +trap cleanup EXIT + +db_lock_path_for_db() { + local db_name="$1" + local safe + safe="$(printf '%s' "$db_name" | tr -c 'A-Za-z0-9_.-' '_')" + printf '%s/%s.lock' "$DB_LOCK_DIR" "$safe" +} + +release_db_lock_later() { + local lock_path="$1" + if [ -z "$lock_path" ]; then + return 0 + fi + ( + sleep 60 + rm -f "$lock_path" 2>/dev/null || true + ) >/dev/null 2>&1 & +} + +while true; do + JOB_FILE="$(ls -1 "$JOB_DIR"/*.env 2>/dev/null | head -n 1 || true)" + if [ -z "${JOB_FILE:-}" ]; then + break + fi + + BASE_NAME="$(basename "$JOB_FILE")" + RUN_FILE="$PROCESSING_DIR/$BASE_NAME" + mv "$JOB_FILE" "$RUN_FILE" + JOB_ID="${BASE_NAME%.env}" + PID_FILE="$PID_DIR/${JOB_ID}.pid" + + JOB_TYPE="restore" + DB_NAME="" + DB_LOCK_FILE="" + # shellcheck disable=SC1090 + source "$RUN_FILE" || true + JOB_TYPE="${JOB_TYPE:-restore}" + DB_NAME="${DB_NAME:-}" + DB_LOCK_FILE="${DB_LOCK_FILE:-}" + + LOCK_PATH="" + if [ -n "$DB_LOCK_FILE" ]; then + case "$DB_LOCK_FILE" in + "$DB_LOCK_DIR"/*.lock) LOCK_PATH="$DB_LOCK_FILE" ;; + *) LOCK_PATH="" ;; + esac + fi + if [ -z "$LOCK_PATH" ] && [ -n "$DB_NAME" ]; then + LOCK_PATH="$(db_lock_path_for_db "$DB_NAME")" + fi + + RUNNER="$PLUGIN_DIR/scripts/restore_job.sh" + if [ "$JOB_TYPE" = "backup" ]; then + RUNNER="$PLUGIN_DIR/scripts/backup_job.sh" + fi + + set +e + "$RUNNER" "$RUN_FILE" & + RUN_PID=$! + echo "PID=${RUN_PID}" >> "$RUN_FILE" + echo "${RUN_PID}" > "$PID_FILE" + wait "$RUN_PID" + EXIT_CODE=$? + set -e + + rm -f "$PID_FILE" + + CANCEL_FLAG="${CANCEL_DIR}/${JOB_ID}.flag" + if [ -f "$CANCEL_FLAG" ]; then + rm -f "$CANCEL_FLAG" + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.cancel" + elif [ "$EXIT_CODE" -eq 0 ]; then + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.ok" + else + mv "$RUN_FILE" "$DONE_DIR/${JOB_ID}.fail" + fi + + release_db_lock_later "$LOCK_PATH" +done + +exit 0 diff --git a/tests/always_enabled_test.php b/tests/always_enabled_test.php new file mode 100644 index 0000000..e1c23f3 --- /dev/null +++ b/tests/always_enabled_test.php @@ -0,0 +1,84 @@ +newInstanceWithoutConstructor(); + foreach ([ + 'username' => 'demo', + 'prefix' => 'demo_', + 'userConf' => $userConf, + 'packageConf' => $packageConf, + 'settings' => $settings, + ] as $property => $value) { + $prop = $class->getProperty($property); + $prop->setAccessible(true); + $prop->setValue($user, $value); + } + return $user; +} + +$alwaysSettings = load_settings("always_enabled=1\nMYSQL_PLUGIN_DEFAULT_DB_LIMIT=5\n"); +assert_true($alwaysSettings->alwaysEnabled(), 'always_enabled=1 should be enabled'); +$alwaysUser = make_da_user($alwaysSettings, [ + 'mysql_enabled' => 'no', + 'mysql' => '0', + 'plugins_deny' => 'alt-mysql', +], []); +assert_true($alwaysUser->hasPluginAccess(), 'always_enabled=1 should bypass per-user enable and plugin deny rules'); +assert_same(0, $alwaysUser->maxDatabases(), 'always_enabled=1 should make database limit unlimited'); + +$defaultSettings = load_settings(""); +assert_true($defaultSettings->alwaysEnabled(), 'always_enabled should default to enabled'); +$defaultUser = make_da_user($defaultSettings, [], []); +assert_true($defaultUser->hasPluginAccess(), 'default always_enabled should grant access without custom package items'); +assert_same(0, $defaultUser->maxDatabases(), 'default always_enabled should make database limit unlimited'); + +$legacySettings = load_settings("always_enabled=0\nMYSQL_PLUGIN_DEFAULT_DB_LIMIT=5\n"); +assert_true(!$legacySettings->alwaysEnabled(), 'always_enabled=0 should disable automatic access'); +$legacyUser = make_da_user($legacySettings, [ + 'mysql_enabled' => 'no', + 'mysql' => '0', +], []); +assert_true(!$legacyUser->hasPluginAccess(), 'always_enabled=0 should preserve per-user access checks'); +assert_same(0, $legacyUser->maxDatabases(), 'explicit mysql=0 remains zero in legacy mode'); + +echo "always_enabled_test: OK\n"; diff --git a/tests/backup_queue_service_test.sh b/tests/backup_queue_service_test.sh new file mode 100644 index 0000000..8e78f01 --- /dev/null +++ b/tests/backup_queue_service_test.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +FILE="$PLUGIN_DIR/exec/lib/BackupQueueService.php" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +if grep -Fq '$safeBase,' "$FILE"; then + fail "BackupQueueService.php still references undefined safeBase" +fi + +echo "backup_queue_service_test: OK" diff --git a/tests/custom_package_items_test.sh b/tests/custom_package_items_test.sh new file mode 100644 index 0000000..85a2459 --- /dev/null +++ b/tests/custom_package_items_test.sh @@ -0,0 +1,42 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +CPIF="$TMP_DIR/custom_package_items.conf" +SETTINGS="$TMP_DIR/plugin-settings.conf" + +ALT_MYSQL_TEST_ASSUME_ROOT=1 \ +ALT_MYSQL_TEST_ONLY_CUSTOM_PACKAGE_ITEMS=1 \ +ALT_MYSQL_CUSTOM_PACKAGE_ITEMS_CONF="$CPIF" \ +ALT_MYSQL_SETTINGS_FILE="$SETTINGS" \ +bash "$PLUGIN_DIR/scripts/install.sh" >/dev/null + +[ -f "$CPIF" ] || fail "custom package items file was not created" +if grep -Eq '^(mysql_enabled|mysql|umysql|mysql_max_databases|mysql_unlimited|umysql_max_databases)=' "$CPIF"; then + cat "$CPIF" >&2 + fail "always_enabled=1 should not add per-user access or database limit custom fields" +fi + +cat > "$SETTINGS" <<'EOF' +always_enabled=0 +EOF + +ALT_MYSQL_TEST_ASSUME_ROOT=1 \ +ALT_MYSQL_TEST_ONLY_CUSTOM_PACKAGE_ITEMS=1 \ +ALT_MYSQL_CUSTOM_PACKAGE_ITEMS_CONF="$CPIF" \ +ALT_MYSQL_SETTINGS_FILE="$SETTINGS" \ +bash "$PLUGIN_DIR/scripts/install.sh" >/dev/null + +grep -Fq 'mysql_enabled=' "$CPIF" || fail "always_enabled=0 should add mysql_enabled field" +grep -Fq 'mysql=type=text' "$CPIF" || fail "always_enabled=0 should add mysql database limit input" +grep -Fq 'umysql=type=checkbox' "$CPIF" || fail "always_enabled=0 should add unlimited checkbox" + +echo "custom_package_items_test: OK" diff --git a/tests/custombuild_hooks_install_test.sh b/tests/custombuild_hooks_install_test.sh new file mode 100644 index 0000000..2f47ac3 --- /dev/null +++ b/tests/custombuild_hooks_install_test.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +INSTALLER="$PLUGIN_DIR/scripts/setup/custombuild_hooks_install.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +CUSTOMBUILD_ROOT="$TMP_DIR/custombuild" bash "$INSTALLER" install >/dev/null + +ROUND_HOOK="$TMP_DIR/custombuild/custom/hooks/roundcube/post/90-alt-mysql-repair.sh" +PMA_HOOK="$TMP_DIR/custombuild/custom/hooks/phpmyadmin/post/90-alt-mysql-repair.sh" + +[ -x "$ROUND_HOOK" ] || fail "Roundcube CustomBuild post hook not installed" +[ -x "$PMA_HOOK" ] || fail "phpMyAdmin CustomBuild post hook not installed" +grep -Fq "roundcube_repair_config.sh" "$ROUND_HOOK" || fail "Roundcube hook does not call repair" +grep -Fq "roundcube_health_check.sh" "$ROUND_HOOK" || fail "Roundcube hook does not call health check" +grep -Fq "phpmyadmin_repair.sh" "$PMA_HOOK" || fail "phpMyAdmin hook does not call repair" +grep -Fq "phpmyadmin_health_check.sh" "$PMA_HOOK" || fail "phpMyAdmin hook does not call health check" + +CUSTOMBUILD_ROOT="$TMP_DIR/custombuild" bash "$INSTALLER" uninstall >/dev/null + +[ ! -e "$ROUND_HOOK" ] || fail "Roundcube CustomBuild post hook not removed" +[ ! -e "$PMA_HOOK" ] || fail "phpMyAdmin CustomBuild post hook not removed" + +echo "custombuild_hooks_install_test: OK" diff --git a/tests/da_integration_install_test.sh b/tests/da_integration_install_test.sh new file mode 100644 index 0000000..3fb5d3a --- /dev/null +++ b/tests/da_integration_install_test.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +INSTALLER="$PLUGIN_DIR/scripts/setup/da_integration_install.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR/custom" +cat > "$TMP_DIR/custom/user_backup_compress_pre.sh" <<'EOF' +#!/bin/bash +echo existing +EOF +chmod 755 "$TMP_DIR/custom/user_backup_compress_pre.sh" + +DA_CUSTOM_HOOK_DIR="$TMP_DIR/custom" bash "$INSTALLER" install >/dev/null + +[ -f "$TMP_DIR/custom/user_backup_compress_pre.sh" ] || fail "dispatcher not created" +grep -Fq "managed by DirectAdmin MySQL plugin hook dispatcher" "$TMP_DIR/custom/user_backup_compress_pre.sh" \ + || fail "dispatcher marker missing" +[ -f "$TMP_DIR/custom/user_backup_compress_pre.sh.d/00-existing.sh" ] || fail "existing hook not preserved" +[ -L "$TMP_DIR/custom/user_backup_compress_pre.sh.d/10-alt-mysql.sh" ] || fail "alt backup hook link missing" +[ -L "$TMP_DIR/custom/user_restore_post_pre_cleanup.sh.d/10-alt-mysql.sh" ] || fail "alt restore hook link missing" +[ -L "$TMP_DIR/custom/database_create_pre.sh.d/20-alt-mysql-block-native-db.sh" ] || fail "native DB blocker link missing" + +DA_CUSTOM_HOOK_DIR="$TMP_DIR/custom" bash "$INSTALLER" uninstall >/dev/null +[ ! -e "$TMP_DIR/custom/user_backup_compress_pre.sh.d/10-alt-mysql.sh" ] || fail "alt backup hook link not removed" +[ -f "$TMP_DIR/custom/user_backup_compress_pre.sh.d/00-existing.sh" ] || fail "preserved existing hook should remain" + +echo "da_integration_install_test: OK" diff --git a/tests/install_db_cli_test.sh b/tests/install_db_cli_test.sh new file mode 100755 index 0000000..44a3608 --- /dev/null +++ b/tests/install_db_cli_test.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$PLUGIN_DIR/scripts/install_db.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -x "$SCRIPT" ] || fail "scripts/install_db.sh is missing or not executable" +[ ! -e "$PLUGIN_DIR/scripts/install-alt-mariadb.sh" ] || fail "old install-alt-mariadb.sh still exists" + +grep -Fq 'apply_cli_args "$@"' "$SCRIPT" \ + || fail "install_db.sh does not call CLI argument parser" +grep -Fq 'MARIADB_VERSION="$1"' "$SCRIPT" \ + || fail "install_db.sh does not accept MariaDB version as first argument" +grep -Fq 'PORT="$2"' "$SCRIPT" \ + || fail "install_db.sh does not accept port as second argument" +grep -Fq 'MARIADB_DIR="/usr/local/mariadb-$MARIADB_VERSION"' "$SCRIPT" \ + || fail "install_db.sh does not derive MariaDB directory from chosen version" +grep -Fq 'validate_port' "$SCRIPT" \ + || fail "install_db.sh does not validate the chosen port" +grep -Fq 'rerun_plugin_install_if_available' "$SCRIPT" \ + || fail "install_db.sh does not refresh plugin install.sh after DB install" +grep -Fq 'SKIP_PLUGIN_INSTALL_REFRESH' "$SCRIPT" \ + || fail "install_db.sh does not allow explicitly skipping plugin refresh" +grep -Fq '[[ $# -eq 0 ]]' "$SCRIPT" \ + || fail "install_db.sh does not explicitly show usage when called without arguments" + +if grep -Fq 'install-alt-mariadb.sh' "$SCRIPT"; then + fail "install_db.sh still references old install-alt-mariadb.sh name" +fi + +set +e +NO_ARGS_OUTPUT="$("$SCRIPT" 2>&1)" +NO_ARGS_STATUS=$? +set -e + +[ "$NO_ARGS_STATUS" -eq 2 ] || fail "install_db.sh without arguments should exit with status 2, got $NO_ARGS_STATUS" +printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq 'Użycie:' \ + || fail "install_db.sh without arguments does not print usage" +printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq ' ' \ + || fail "install_db.sh usage does not show required version and port" +if printf '%s\n' "$NO_ARGS_OUTPUT" | grep -Fq 'Ten skrypt musi zostać uruchomiony jako root'; then + fail "install_db.sh without arguments reaches root check instead of usage" +fi + +echo "install_db_cli_test: OK" diff --git a/tests/mysql_common_runtime_test.sh b/tests/mysql_common_runtime_test.sh new file mode 100644 index 0000000..4ab5aad --- /dev/null +++ b/tests/mysql_common_runtime_test.sh @@ -0,0 +1,75 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +COMMON_FILE="$PLUGIN_DIR/scripts/setup/mysql_common.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR/mariadb/bin" +touch "$TMP_DIR/mariadb/bin/mariadb" "$TMP_DIR/mariadb/bin/mariadb-dump" "$TMP_DIR/mariadb/bin/mariadb-admin" "$TMP_DIR/mariadb/bin/mariadb-upgrade" +chmod 755 "$TMP_DIR/mariadb/bin/"* + +cat > "$TMP_DIR/alt-mariadb.env" < "$TMP_DIR/alt-mysql.conf" < "$TMP_DIR/alt-my.cnf" < "$TMP_DIR/alt-mariadb.cnf" <&2 + exit 1 +} + +assert_contains() { + local file="$1" + local needle="$2" + grep -Fq "$needle" "$file" || fail "Expected $file to contain: $needle" +} + +assert_not_contains() { + local file="$1" + local needle="$2" + if grep -Fq "$needle" "$file"; then + fail "Expected $file not to contain: $needle" + fi +} + +assert_line_count() { + local expected="$1" + local file="$2" + local needle="$3" + local actual + actual="$(grep -Fxc "$needle" "$file" || true)" + [ "$actual" = "$expected" ] || fail "Expected $expected occurrences of '$needle' in $file, got $actual" +} + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +mkdir -p "$tmpdir/bin" "$tmpdir/csf" + +cat > "$tmpdir/bin/mysql" <<'MYSQL' +#!/bin/bash +set -euo pipefail +printf '%s\n' \ + "198.51.100.10" \ + "203.0.113.20" \ + "localhost" \ + "bad-host-name" +MYSQL +chmod 755 "$tmpdir/bin/mysql" + +cat > "$tmpdir/alt-mysql.conf" < "$tmpdir/alt-mariadb.cnf" <<'EOF' +[client] +port=33033 + +[mariadb] +port=33033 +bind-address=127.0.0.1 +EOF + +cat > "$tmpdir/csf/csf.allow" <<'EOF' +# csf.allow +EOF + +cat > "$tmpdir/csf/csf.conf" <<'EOF' +TCP_IN = "22,33030:33035,80,33033,443" +TCP6_IN = "22,33033:33033" +TCP_OUT = "22,80,33033,443" +EOF + +MYSQL_HOST_SYNC_ASSUME_ROOT=1 \ +MYSQL_HOST_SYNC_PLUGIN_SETTINGS="$tmpdir/missing-plugin-settings.conf" \ +MYSQL_HOST_SYNC_SKIP_CSF_RELOAD=1 \ +MYSQL_HOST_SYNC_SKIP_SERVICE_RESTART=1 \ +MYSQL_PLUGIN_CLIENT_BIN_DIR="$tmpdir/bin" \ +MYSQL_PLUGIN_CREDENTIALS_FILE="$tmpdir/alt-mysql.conf" \ +MYSQL_HOST_SYNC_INSTANCE_CNF="$tmpdir/alt-mariadb.cnf" \ +MYSQL_HOST_SYNC_CSF_DIR="$tmpdir/csf" \ +MYSQL_HOST_SYNC_CSF_ALLOW="$tmpdir/csf/csf.allow" \ +MYSQL_HOST_SYNC_CSF_CONF="$tmpdir/csf/csf.conf" \ +MYSQL_HOST_SYNC_MANAGED_ALLOW="$tmpdir/csf/alt-mysql-plugin.allow" \ +bash "$SYNC_SCRIPT" >/tmp/mysql_host_sync_test.out + +assert_contains "$tmpdir/alt-mariadb.cnf" "bind-address=0.0.0.0" +assert_contains "$tmpdir/csf/csf.allow" "Include $tmpdir/csf/alt-mysql-plugin.allow" +assert_contains "$tmpdir/csf/alt-mysql-plugin.allow" "tcp|in|d=33033|s=198.51.100.10" +assert_contains "$tmpdir/csf/alt-mysql-plugin.allow" "tcp|in|d=33033|s=203.0.113.20" +assert_not_contains "$tmpdir/csf/alt-mysql-plugin.allow" "localhost" +assert_not_contains "$tmpdir/csf/alt-mysql-plugin.allow" "bad-host-name" +assert_contains "$tmpdir/csf/csf.conf" 'TCP_IN = "22,33030:33032,33034:33035,80,443"' +assert_contains "$tmpdir/csf/csf.conf" 'TCP6_IN = "22"' +assert_contains "$tmpdir/csf/csf.conf" 'TCP_OUT = "22,80,33033,443"' + +MYSQL_HOST_SYNC_ASSUME_ROOT=1 \ +MYSQL_HOST_SYNC_PLUGIN_SETTINGS="$tmpdir/missing-plugin-settings.conf" \ +MYSQL_HOST_SYNC_SKIP_CSF_RELOAD=1 \ +MYSQL_HOST_SYNC_SKIP_SERVICE_RESTART=1 \ +MYSQL_PLUGIN_CLIENT_BIN_DIR="$tmpdir/bin" \ +MYSQL_PLUGIN_CREDENTIALS_FILE="$tmpdir/alt-mysql.conf" \ +MYSQL_HOST_SYNC_INSTANCE_CNF="$tmpdir/alt-mariadb.cnf" \ +MYSQL_HOST_SYNC_CSF_DIR="$tmpdir/csf" \ +MYSQL_HOST_SYNC_CSF_ALLOW="$tmpdir/csf/csf.allow" \ +MYSQL_HOST_SYNC_CSF_CONF="$tmpdir/csf/csf.conf" \ +MYSQL_HOST_SYNC_MANAGED_ALLOW="$tmpdir/csf/alt-mysql-plugin.allow" \ +bash "$SYNC_SCRIPT" >/tmp/mysql_host_sync_test_second.out + +assert_line_count 1 "$tmpdir/csf/csf.allow" "Include $tmpdir/csf/alt-mysql-plugin.allow" + +echo "mysql_host_sync_test: OK" diff --git a/tests/mysql_upgrade_rollback_test.sh b/tests/mysql_upgrade_rollback_test.sh new file mode 100644 index 0000000..08ef103 --- /dev/null +++ b/tests/mysql_upgrade_rollback_test.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$PLUGIN_DIR/scripts/setup/mysql_upgrade.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +grep -Fq "rollback_upgrade()" "$SCRIPT" \ + || fail "mysql_upgrade.sh does not define rollback_upgrade" +grep -Fq "UPGRADE_COMMITTED=1" "$SCRIPT" \ + || fail "mysql_upgrade.sh does not mark successful upgrades as committed" +grep -Fq 'trap '\''code=$?; rollback_upgrade "$code"'\'' EXIT' "$SCRIPT" \ + || fail "mysql_upgrade.sh does not run rollback on failed exit" +grep -Fq "RESTORED_PREVIOUS_DIR" "$SCRIPT" \ + || fail "mysql_upgrade.sh does not restore previous binary directory" +grep -Fq "RESTORED_INSTANCE_CNF" "$SCRIPT" \ + || fail "mysql_upgrade.sh does not restore previous instance config" + +echo "mysql_upgrade_rollback_test: OK" diff --git a/tests/package_archive_test.sh b/tests/package_archive_test.sh new file mode 100644 index 0000000..242d075 --- /dev/null +++ b/tests/package_archive_test.sh @@ -0,0 +1,104 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +ARCHIVE="${1:-$(cd "$PLUGIN_DIR/.." && pwd)/1.2.10/alt-mysql.tar.gz}" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ -s "$ARCHIVE" ] || fail "archive does not exist or is empty: $ARCHIVE" + +LIST="$(tar -tzf "$ARCHIVE")" +printf '%s\n' "$LIST" | grep -Eq '^(\./)?plugin\.conf$' \ + || fail "archive does not contain plugin.conf at tar root" +if printf '%s\n' "$LIST" | grep -Eq '^(\./)?alt-mysql/'; then + fail "archive contains parent alt-mysql directory" +fi +if printf '%s\n' "$LIST" | grep -Eiq 'postgres|postgresql|pgsql|phppgadmin'; then + fail "archive contains PostgreSQL/phpPgAdmin leftovers" +fi +if printf '%s\n' "$LIST" | grep -Eq '^(\./)?tests/'; then + fail "archive contains local test suite" +fi +if printf '%s\n' "$LIST" | grep -Eq '(^|/)\._[^/]+$|(^|/)\.DS_Store$|__MACOSX'; then + fail "archive contains macOS AppleDouble or Finder metadata" +fi +printf '%s\n' "$LIST" | grep -Eq '^(\./)?scripts/setup/phpmyadmin_private_install\.sh$' \ + || fail "archive does not contain private phpMyAdmin installer" +printf '%s\n' "$LIST" | grep -Eq '^(\./)?scripts/install_db\.sh$' \ + || fail "archive does not contain install_db.sh at scripts/" +if printf '%s\n' "$LIST" | grep -Eq '(^|/)install-alt-mariadb\.sh$'; then + fail "archive contains old install-alt-mariadb.sh" +fi + +CONF="$(tar -xOzf "$ARCHIVE" ./plugin.conf 2>/dev/null || tar -xOzf "$ARCHIVE" plugin.conf)" +printf '%s\n' "$CONF" | grep -Fxq "name=alt-mysql" || fail "archive plugin name is not alt-mysql" +printf '%s\n' "$CONF" | grep -Fxq "id=alt-mysql" || fail "archive plugin id is not alt-mysql" +printf '%s\n' "$CONF" | grep -Fxq "version=1.2.10" || fail "archive plugin version is not 1.2.10" + +SETTINGS="$(tar -xOzf "$ARCHIVE" ./plugin-settings.conf 2>/dev/null || tar -xOzf "$ARCHIVE" plugin-settings.conf)" +printf '%s\n' "$SETTINGS" | grep -Fxq "PHPMYADMIN_INSTALL_DIR=/var/www/html/alt-mysql-phpmyadmin" \ + || fail "archive settings do not point to private phpMyAdmin install dir" +printf '%s\n' "$SETTINGS" | grep -Fxq "PHPMYADMIN_URL_PATH=/alt-mysql-phpmyadmin" \ + || fail "archive settings do not point to private phpMyAdmin URL" +printf '%s\n' "$SETTINGS" | grep -Fxq "always_enabled=1" \ + || fail "archive settings do not enable always_enabled by default" + +INSTALL_SCRIPT="$(tar -xOzf "$ARCHIVE" ./scripts/install.sh 2>/dev/null || tar -xOzf "$ARCHIVE" scripts/install.sh)" +printf '%s\n' "$INSTALL_SCRIPT" | grep -Fq 'always_enabled()' \ + || fail "archive install script does not read always_enabled" +printf '%s\n' "$INSTALL_SCRIPT" | grep -Fq 'if always_enabled; then' \ + || fail "archive install script does not hide custom package fields when always_enabled is on" + +DB_INSTALLER="$(tar -xOzf "$ARCHIVE" ./scripts/install_db.sh 2>/dev/null || tar -xOzf "$ARCHIVE" scripts/install_db.sh)" +printf '%s\n' "$DB_INSTALLER" | grep -Fq 'apply_cli_args "$@"' \ + || fail "archive install_db.sh does not apply positional arguments" +printf '%s\n' "$DB_INSTALLER" | grep -Fq 'MARIADB_VERSION="$1"' \ + || fail "archive install_db.sh does not accept MariaDB version as first argument" +printf '%s\n' "$DB_INSTALLER" | grep -Fq 'PORT="$2"' \ + || fail "archive install_db.sh does not accept TCP port as second argument" +printf '%s\n' "$DB_INSTALLER" | grep -Fq 'rerun_plugin_install_if_available' \ + || fail "archive install_db.sh does not refresh plugin install.sh after DB setup" +printf '%s\n' "$DB_INSTALLER" | grep -Fq 'SKIP_PLUGIN_INSTALL_REFRESH' \ + || fail "archive install_db.sh does not expose a test/maintenance escape hatch for plugin refresh" +printf '%s\n' "$DB_INSTALLER" | grep -Fq '[[ $# -eq 0 ]]' \ + || fail "archive install_db.sh does not show usage when called without arguments" +printf '%s\n' "$DB_INSTALLER" | grep -Fq ' ' \ + || fail "archive install_db.sh usage does not require version and port" + +INSTALLER="$(tar -xOzf "$ARCHIVE" ./scripts/setup/phpmyadmin_install.sh 2>/dev/null || tar -xOzf "$ARCHIVE" scripts/setup/phpmyadmin_install.sh)" +printf '%s\n' "$INSTALLER" | grep -Fq "\$cfg['Servers'][\$i]['SignonURL'] = da_mysql_phpmyadmin_signon_url(\$runtimeConfig);" \ + || fail "archive phpMyAdmin installer does not configure SignonURL" +printf '%s\n' "$INSTALLER" | grep -Fq 'SIGNON_URL_PAGE="${PHPMYADMIN_PRIVATE_DIR}/da_login.php"' \ + || fail "archive phpMyAdmin installer does not create SignonURL page" +printf '%s\n' "$INSTALLER" | grep -Fq 'DA_MYSQL_PHPMYADMIN_AUTH' \ + || fail "archive phpMyAdmin installer does not create ticket-based da_login.php" +printf '%s\n' "$INSTALLER" | grep -Fq 'da_mysql_signon_read_auth' \ + || fail "archive phpMyAdmin installer does not isolate phpMyAdmin and SSO sessions" + +OPEN_HANDLER="$(tar -xOzf "$ARCHIVE" ./exec/handlers/open_phpmyadmin.php 2>/dev/null || tar -xOzf "$ARCHIVE" exec/handlers/open_phpmyadmin.php)" +printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'phpmyadmin_health_check.sh' \ + || fail "archive open phpMyAdmin handler does not run health check" +printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'phpmyadmin_repair.sh' \ + || fail "archive open phpMyAdmin handler does not run repair before redirect" +printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'HTTP/1.1 302 Found' \ + || fail "archive open phpMyAdmin raw handler does not emit an HTTP redirect" +printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'Location: ' \ + || fail "archive open phpMyAdmin raw handler does not emit a Location header" +if printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'meta http-equiv="refresh"'; then + fail "archive open phpMyAdmin raw handler still uses meta refresh" +fi +if printf '%s\n' "$OPEN_HANDLER" | grep -Fq 'Set-Cookie: '; then + fail "archive open phpMyAdmin handler still tries to pass SSO through DirectAdmin PHP session cookie" +fi + +SSO_CLASS="$(tar -xOzf "$ARCHIVE" ./exec/lib/PhpMyAdminSso.php 2>/dev/null || tar -xOzf "$ARCHIVE" exec/lib/PhpMyAdminSso.php)" +printf '%s\n' "$SSO_CLASS" | grep -Fq 'writeLoginTicket' \ + || fail "archive phpMyAdmin SSO class does not write login tickets" +printf '%s\n' "$SSO_CLASS" | grep -Fq '/da_login.php?' \ + || fail "archive phpMyAdmin SSO class does not target da_login.php ticket flow" + +echo "package_archive_test: OK" diff --git a/tests/phpmyadmin_install_test.sh b/tests/phpmyadmin_install_test.sh new file mode 100644 index 0000000..671cdef --- /dev/null +++ b/tests/phpmyadmin_install_test.sh @@ -0,0 +1,149 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$PLUGIN_DIR/scripts/setup/phpmyadmin_install.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +grep -Fq 'PHPMYADMIN_VERSION="${PHPMYADMIN_VERSION:-5.2.3}"' "$SCRIPT" \ + || fail "phpMyAdmin default version is not 5.2.3" +grep -Fq 'PHPMYADMIN_TARBALL_URL="${PHPMYADMIN_TARBALL_URL:-https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.tar.gz}"' "$SCRIPT" \ + || fail "phpMyAdmin tarball URL is not the explicit 5.2.3 URL" + +mkdir -p "$TMP_DIR/phpMyAdmin" +cat > "$TMP_DIR/phpMyAdmin/config.inc.php" <<'PHP' + "$TMP_DIR/phpMyAdmin-source/index.php" <<'PHP' +/dev/null + +PRIVATE_DIR="$TMP_DIR/alt-mysql-phpmyadmin" +CONFIG_INC="$PRIVATE_DIR/config.inc.php" +SIGNON_SCRIPT="$PRIVATE_DIR/da_signon.php" +SIGNON_URL_PAGE="$PRIVATE_DIR/da_login.php" +TICKET_DIR="$PRIVATE_DIR/runtime/tickets" +SESSION_DIR="$TMP_DIR/sessions" + +[ -r "$PRIVATE_DIR/index.php" ] || fail "private phpMyAdmin copy was not installed" +[ -r "$CONFIG_INC" ] || fail "private phpMyAdmin config was not created" +[ -r "$SIGNON_SCRIPT" ] || fail "signon script was not created" +[ -r "$SIGNON_URL_PAGE" ] || fail "phpMyAdmin SignonURL page was not created" +[ -d "$TICKET_DIR" ] || fail "phpMyAdmin ticket directory was not created" +if grep -Fq "config.mysql.inc.php" "$TMP_DIR/phpMyAdmin/config.inc.php"; then + fail "installer modified existing CustomBuild phpMyAdmin config" +fi + +SERVER_JSON="$(php -r ' + $_SERVER["HTTPS"] = "on"; + $_SERVER["HTTP_HOST"] = "panel.example.test"; + $cfg=[]; + include $argv[1]; + echo json_encode($cfg["Servers"][1] ?? []); +' "$CONFIG_INC")" +php -r ' + $server = json_decode($argv[1], true); + if (($server["host"] ?? "") !== "127.0.0.1") { + fwrite(STDERR, "host mismatch\n"); + exit(2); + } + if ((string)($server["port"] ?? "") !== "33033") { + fwrite(STDERR, "port mismatch\n"); + exit(3); + } + if (($server["socket"] ?? "not-empty") !== "") { + fwrite(STDERR, "socket mismatch\n"); + exit(4); + } + if (($server["SignonURL"] ?? "") !== "https://panel.example.test/alt-mysql-phpmyadmin/da_login.php") { + fwrite(STDERR, "SignonURL mismatch\n"); + exit(5); + } + if (($server["LogoutURL"] ?? "") !== "https://panel.example.test/alt-mysql-phpmyadmin/da_login.php") { + fwrite(STDERR, "LogoutURL mismatch\n"); + exit(6); + } +' "$SERVER_JSON" || fail "phpMyAdmin config does not target alt-mariadb TCP endpoint" +grep -Fq 'return [$username, $password];' "$SIGNON_SCRIPT" \ + || fail "signon script should return username/password only" +php -d display_errors=1 -r 'set_error_handler(static function($errno, $errstr) { fwrite(STDERR, $errstr . "\n"); exit(9); }); $cfg=[]; include $argv[1]; include $argv[2];' "$CONFIG_INC" "$SIGNON_SCRIPT" \ + || fail "phpMyAdmin config and signon script cannot be loaded in one PHP process" + +mkdir -p "$SESSION_DIR" +cat > "$TICKET_DIR/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.json" <<'JSON' +{"version":1,"expires_at":4102444800,"auth":{"user":"da_tmp_phpmyadmin_test","password":"secret","db":"user_db"},"route":"/database/structure","db":"user_db"} +JSON +php -d display_errors=1 -d session.save_path="$SESSION_DIR" -r ' + $_GET["ticket"] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "tickettest"; + $_SERVER["HTTPS"] = "on"; + $_SERVER["HTTP_HOST"] = "panel.example.test"; + include $argv[1]; +' "$SIGNON_URL_PAGE" >/tmp/altmysql-da-login-test.out 2>/tmp/altmysql-da-login-test.err \ + || fail "da_login.php did not accept a valid ticket" +[ ! -e "$TICKET_DIR/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.json" ] \ + || fail "da_login.php did not consume the SSO ticket" +php -d session.save_path="$SESSION_DIR" -r ' + session_name("DA_MYSQL_PHPMYADMIN"); + session_id("tickettest"); + session_start(); + $auth = $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] ?? []; + if (($auth["user"] ?? "") !== "da_tmp_phpmyadmin_test") { + fwrite(STDERR, "session user mismatch\n"); + exit(2); + } + if (($auth["password"] ?? "") !== "secret") { + fwrite(STDERR, "session password mismatch\n"); + exit(3); + } + if (($auth["db"] ?? "") !== "user_db") { + fwrite(STDERR, "session db mismatch\n"); + exit(4); + } +' || fail "da_login.php did not create the phpMyAdmin signon session" +php -d session.save_path="$SESSION_DIR" -r ' + session_name("DA_MYSQL_PHPMYADMIN"); + session_id("tickettest"); + session_start(); + $_SESSION["DA_MYSQL_PHPMYADMIN_AUTH"] = [ + "user" => "da_tmp_phpmyadmin_test", + "password" => "secret", + "db" => "user_db", + ]; + session_write_close(); + + session_name("phpmyadmin"); + session_id("pmaactive"); + session_start(); + $_SESSION["unrelated"] = "active phpMyAdmin session"; + $_COOKIE["DA_MYSQL_PHPMYADMIN"] = "tickettest"; + include $argv[1]; + [$username, $password] = get_login_credentials("ignored"); + if ($username !== "da_tmp_phpmyadmin_test") { + fwrite(STDERR, "signon user mismatch with active phpMyAdmin session\n"); + exit(2); + } + if ($password !== "secret") { + fwrite(STDERR, "signon password mismatch with active phpMyAdmin session\n"); + exit(3); + } +' "$SIGNON_SCRIPT" || fail "signon script cannot read SSO session while phpMyAdmin session is active" +PHPMYADMIN_PRIVATE_DIR="$PRIVATE_DIR" bash "$PLUGIN_DIR/scripts/setup/phpmyadmin_health_check.sh" >/dev/null \ + || fail "phpMyAdmin health check should pass for SignonURL-capable config" + +echo "phpmyadmin_install_test: OK" diff --git a/tests/plugin_identity_test.sh b/tests/plugin_identity_test.sh new file mode 100644 index 0000000..c721d72 --- /dev/null +++ b/tests/plugin_identity_test.sh @@ -0,0 +1,52 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +[ "$(basename "$PLUGIN_DIR")" = "alt-mysql" ] || fail "plugin directory name is not alt-mysql" +grep -Fxq "name=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf name is not alt-mysql" +grep -Fxq "id=alt-mysql" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf id is not alt-mysql" +grep -Fxq "version=1.2.10" "$PLUGIN_DIR/plugin.conf" || fail "plugin.conf version is not 1.2.10" +grep -Fq "return '/CMD_PLUGINS/alt-mysql';" "$PLUGIN_DIR/exec/lib/AppContext.php" \ + || fail "AppContext base URL is not alt-mysql" +grep -Fq "\$pluginId = 'alt-mysql';" "$PLUGIN_DIR/exec/lib/DirectAdminUser.php" \ + || fail "DirectAdmin plugin allow/deny id is not alt-mysql" + +[ -x "$PLUGIN_DIR/scripts/install_db.sh" ] || fail "install_db.sh is not executable in scripts/" +[ ! -e "$PLUGIN_DIR/scripts/install-alt-mariadb.sh" ] || fail "install-alt-mariadb.sh still exists in plugin scripts/" +[ ! -e "$PLUGIN_DIR/../mysql-installer/install_db.sh" ] || fail "install_db.sh exists outside plugin scripts/" +[ ! -e "$PLUGIN_DIR/../mysql-installer/install-alt-mariadb.sh" ] || fail "install-alt-mariadb.sh still exists outside plugin scripts/" + +BAD_REFS="$( + old_plugin_path="/usr/local/directadmin/plugins/"'mysql' + old_cmd_path="/CMD_PLUGINS/"'mysql' + find "$PLUGIN_DIR" -type f \ + ! -path '*/tests/plugin_identity_test.sh' \ + ! -path '*/data/*' \ + -exec grep -HnE "$old_plugin_path|$old_cmd_path" {} + || true +)" +[ -z "$BAD_REFS" ] || { + printf '%s\n' "$BAD_REFS" >&2 + fail "old DirectAdmin plugin path references remain" +} + +LEGACY_REFS="$( + legacy_sudoers="/etc/sudoers.d/directadmin-"'mysql'"-plugin" + legacy_cron="/etc/cron.d/"'mysql'"-jobs" + legacy_csf="/etc/csf/"'mysql'"-plugin.allow" + find "$PLUGIN_DIR" -type f \ + ! -path '*/tests/plugin_identity_test.sh' \ + ! -path '*/data/*' \ + -exec grep -HnE "$legacy_sudoers|$legacy_cron|$legacy_csf" {} + || true +)" +[ -z "$LEGACY_REFS" ] || { + printf '%s\n' "$LEGACY_REFS" >&2 + fail "legacy mysql plugin artifact references remain" +} + +echo "plugin_identity_test: OK" diff --git a/tests/restore_rollback_safety_test.sh b/tests/restore_rollback_safety_test.sh new file mode 100644 index 0000000..75eb6f2 --- /dev/null +++ b/tests/restore_rollback_safety_test.sh @@ -0,0 +1,27 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_script_has_restore_rollback() { + local file="$1" + local label="$2" + + grep -Fq "backup_alt_database_for_rollback" "$file" \ + || fail "$label does not create a rollback dump before overwrite" + grep -Fq "restore_alt_database_from_rollback" "$file" \ + || fail "$label does not restore rollback dump after failed import" + grep -Fq "trap cleanup_restore_rollback" "$file" \ + || fail "$label does not cleanup rollback dump" +} + +assert_script_has_restore_rollback "$PLUGIN_DIR/scripts/restore_job.sh" "restore_job.sh" +assert_script_has_restore_rollback "$PLUGIN_DIR/scripts/da-integration/alt_mysql_restore_payload.sh" "alt_mysql_restore_payload.sh" +assert_script_has_restore_rollback "$PLUGIN_DIR/scripts/da-integration/da_restore_native_staging_to_alt_mysql.sh" "da_restore_native_staging_to_alt_mysql.sh" + +echo "restore_rollback_safety_test: OK" diff --git a/tests/roundcube_repair_config_test.sh b/tests/roundcube_repair_config_test.sh new file mode 100644 index 0000000..cf5480c --- /dev/null +++ b/tests/roundcube_repair_config_test.sh @@ -0,0 +1,71 @@ +#!/bin/bash +set -euo pipefail + +PLUGIN_DIR="$(cd "$(dirname "$0")/.." && pwd)" +SCRIPT="$PLUGIN_DIR/scripts/setup/roundcube_repair_config.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "$TMP_DIR/mariadb/bin" "$TMP_DIR/roundcube" +cat > "$TMP_DIR/roundcube/config.inc.php" <<'PHP' + "$TMP_DIR/alt-mariadb.env" < "$TMP_DIR/alt-mysql.conf" < "$TMP_DIR/plugin-settings.conf" </dev/null + +grep -Fq "BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE" "$TMP_DIR/roundcube/config.inc.php" \ + || fail "managed block missing" +grep -Fq "127.0.0.1:4407/roundcube" "$TMP_DIR/roundcube/config.inc.php" \ + || fail "alt endpoint not written" +grep -Fq "BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE" "$TMP_DIR/custombuild/custom/roundcube/config.inc.php" \ + || fail "managed block missing from CustomBuild Roundcube config" +grep -Fq "127.0.0.1:4407/roundcube" "$TMP_DIR/custombuild/custom/roundcube/config.inc.php" \ + || fail "alt endpoint not written to CustomBuild Roundcube config" +grep -Fq "password=" "$TMP_DIR/alt-roundcube.conf" || fail "password file not created" + +ROUNDCUBE_SETTINGS_FILE="$TMP_DIR/plugin-settings.conf" bash "$SCRIPT" >/dev/null +COUNT="$(grep -c "BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE" "$TMP_DIR/roundcube/config.inc.php")" +[ "$COUNT" = "1" ] || fail "managed block is not idempotent" +CUSTOM_COUNT="$(grep -c "BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE" "$TMP_DIR/custombuild/custom/roundcube/config.inc.php")" +[ "$CUSTOM_COUNT" = "1" ] || fail "CustomBuild managed block is not idempotent" + +echo "roundcube_repair_config_test: OK" diff --git a/tests/settings_remote_hosts_test.php b/tests/settings_remote_hosts_test.php new file mode 100644 index 0000000..8a48bee --- /dev/null +++ b/tests/settings_remote_hosts_test.php @@ -0,0 +1,36 @@ +allowRemoteHosts(), 'allow_remote_hosts=1 should enable remote hosts'); +assert_true( + $settings->modifyServerConfiguration(), + 'allow_remote_hosts=1 should enable server synchronization even when modify_server_configuration=0' +); + +echo "settings_remote_hosts_test: OK\n"; diff --git a/user/assign_database.html b/user/assign_database.html new file mode 100755 index 0000000..355a4c7 --- /dev/null +++ b/user/assign_database.html @@ -0,0 +1,6 @@ +#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini +