Import alt-mysql plugin

This commit is contained in:
Marek Miklewicz
2026-07-04 15:48:19 +02:00
commit d71fcf9ace
101 changed files with 18635 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
._*
__MACOSX/
error.log
data/*
assets/adminer/*
+5
View File
@@ -0,0 +1,5 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
header('Location: /CMD_PLUGINS/alt-mysql/index.html', true, 302);
exit;
+156
View File
@@ -0,0 +1,156 @@
<?php
declare(strict_types=1);
if (!defined('IN_DA_PLUGIN') || IN_DA_PLUGIN !== true) {
http_response_code(403);
echo 'Forbidden';
exit;
}
if (!defined('PLUGIN_ACTION')) {
http_response_code(400);
echo 'Missing PLUGIN_ACTION';
exit;
}
define('PLUGIN_ROOT', dirname(__DIR__));
define('PLUGIN_EXEC_DIR', __DIR__);
define('PLUGIN_HANDLERS_DIR', __DIR__ . '/handlers');
$pluginErrorLog = PLUGIN_ROOT . '/error.log';
@ini_set('log_errors', '1');
@ini_set('error_log', $pluginErrorLog);
if (!is_file($pluginErrorLog)) {
@file_put_contents($pluginErrorLog, '');
}
register_shutdown_function(static function () use ($pluginErrorLog): void {
$last = error_get_last();
if ($last === null) {
return;
}
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
if (!in_array((int)$last['type'], $fatalTypes, true)) {
return;
}
$msg = sprintf(
"[%s] FATAL type=%d message=%s file=%s line=%d\n",
date('c'),
(int)$last['type'],
(string)$last['message'],
(string)$last['file'],
(int)$last['line']
);
@error_log($msg, 3, $pluginErrorLog);
});
try {
autoload_plugin_libs();
Http::bootstrapGlobals();
$settings = Settings::load(PLUGIN_ROOT . '/plugin-settings.conf');
PhpMyAdminRuntimeConfig::sync($settings, $pluginErrorLog);
$daUser = DirectAdminUser::fromEnvironment($settings);
if (!$daUser->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 '<!doctype html><html><head><meta charset="utf-8"><title>Błąd pluginu</title></head><body>';
echo '<h1>Błąd pluginu MySQL</h1>';
echo '<p>' . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</p>';
echo '<p>Sprawdź konfigurację i uprawnienia pluginu.</p>';
echo '</body></html>';
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 '<!doctype html><html lang="' . htmlspecialchars($langCode, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '"><head><meta charset="utf-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<title>' . htmlspecialchars($title, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</title>';
echo '<style>' . $css . '</style>';
echo '</head><body>';
echo '<div class="wrap">';
echo '<section class="main-section">';
echo '<div class="alert alert-err">' . htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</div>';
echo '</section>';
echo '</div>';
echo '</body></html>';
}
+88
View File
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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 = '<option value="">-- wybierz bazę --</option>';
foreach ($dbNames as $dbName) {
$dbOptions .= '<option value="' . AppContext::e($dbName) . '">' . AppContext::e($dbName) . '</option>';
}
$roleOptions = '<option value="">-- wybierz użytkownika --</option>';
foreach ($roleNames as $roleName) {
$roleOptions .= '<option value="' . AppContext::e($roleName) . '">' . AppContext::e($roleName) . '</option>';
}
$privCheckboxes = '';
foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) {
$checked = ($code === 'ALL') ? ' checked' : '';
$privCheckboxes .= '<label class="inline"><input type="checkbox" name="privileges[]" value="' . AppContext::e($code) . '"' . $checked . '> ' . AppContext::e($label) . '</label>';
}
$body = '';
$body .= '<form method="post">';
$body .= $ctx->csrfField('assign_database_submit');
$body .= '<div class="row">';
$body .= '<div><label>Baza danych</label><select name="db_name" required>' . $dbOptions . '</select></div>';
$body .= '<div><label>Użytkownik MySQL</label><select name="role_name" required>' . $roleOptions . '</select></div>';
$body .= '</div>';
$body .= '<div class="panel"><h3>Uprawnienia</h3><div class="privileges-group" data-privileges-group>' . $privCheckboxes . '</div></div>';
$body .= '<div class="actions"><button class="primary" type="submit">Przypisz bazę</button></div>';
$body .= '</form>';
$ctx->renderPage('Przypisywanie Bazy do Użytkownika', $body, $ok, $errors);
};
+563
View File
@@ -0,0 +1,563 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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 .= '<option value="' . AppContext::e($roleName) . '"' . $sel . '>' . AppContext::e($roleName) . '</option>';
}
$assignedDbs = [];
$grantableDbs = $allDbs;
if ($selectedRole !== '') {
$assignedDbs = $ctx->mysql->roleAssignedDatabases($selectedRole, $daUser);
$grantableDbs = array_values(array_diff($allDbs, $assignedDbs));
}
$dbOptions = '<option value="">-- wybierz bazę danych --</option>';
foreach ($grantableDbs as $dbName) {
$dbOptions .= '<option value="' . AppContext::e($dbName) . '">' . AppContext::e($dbName) . '</option>';
}
$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 .= '<tr>';
$dbRows .= '<td>' . AppContext::e($dbName) . '</td>';
$dbRows .= '<td><span class="badge">' . AppContext::e($label) . '</span></td>';
$dbRows .= '<td class="actions">';
$dbRows .= '<a class="btn" href="' . AppContext::e($ctx->url('modify_privileges.html', ['db' => $dbName, 'role' => $selectedRole])) . '">Zarządzaj</a>';
$dbRows .= '<a class="btn" href="' . AppContext::e($ctx->url('modify_privileges.html', ['db' => $dbName, 'role' => $selectedRole])) . '#przywileje">Przywileje</a>';
$dbRows .= '<form method="post" style="display:inline-flex;gap:6px;margin:0">';
$dbRows .= $ctx->csrfField('user_manage_submit');
$dbRows .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$dbRows .= '<input type="hidden" name="db_name" value="' . AppContext::e($dbName) . '">';
$dbRows .= '<input type="hidden" name="form_action" value="revoke_db">';
$dbRows .= '<button class="btn danger" type="submit">Odbierz dostęp</button>';
$dbRows .= '</form>';
$dbRows .= '</td>';
$dbRows .= '</tr>';
}
if ($dbRows === '') {
$dbRows = '<tr><td colspan="3" class="muted">Brak przypisanych baz danych.</td></tr>';
}
$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 .= '<tr>';
$hostsRows .= '<td>' . AppContext::e($host) . '</td>';
$hostsRows .= '<td>' . AppContext::e($note) . '</td>';
$hostsRows .= '<td class="actions">';
if (!isset($fixedHosts[$host])) {
$hostsRows .= '<form method="post" style="display:inline-flex;gap:6px;margin:0">';
$hostsRows .= $ctx->csrfField('user_manage_submit');
$hostsRows .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$hostsRows .= '<input type="hidden" name="host" value="' . AppContext::e($host) . '">';
$hostsRows .= '<input type="hidden" name="form_action" value="remove_host">';
$hostsRows .= '<button class="btn danger" type="submit">Usuń</button>';
$hostsRows .= '</form>';
} else {
$hostsRows .= '<span class="muted">host stały</span>';
}
$hostsRows .= '</td>';
$hostsRows .= '</tr>';
}
if ($hostsRows === '') {
$hostsRows = '<tr><td colspan="3" class="muted">Brak dozwolonych hostów.</td></tr>';
}
}
$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 .= '<li><code>' . AppContext::e($assignedDbName) . '</code></li>';
}
$assignedDbsHtml .= '<div><strong>Przypisane bazy danych:</strong></div>';
$assignedDbsHtml .= '<div><ul class="list-clean">' . $assignedItems . '</ul></div>';
}
$createdUserCard .= '<section class="success-credentials">';
$createdUserCard .= '<div class="left">✓</div>';
$createdUserCard .= '<div class="right">';
$createdUserCard .= '<h4>Dane dostępowe użytkownika MySQL</h4>';
$createdUserCard .= '<div class="kv">';
$createdUserCard .= '<div><strong>Nazwa hosta:</strong></div><div><code>' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')</code></div>';
$createdUserCard .= '<div><strong>Nazwa użytkownika:</strong></div><div><code>' . AppContext::e($createdRoleName) . '</code></div>';
$createdUserCard .= '<div><strong>Hasło:</strong></div><div><code>' . AppContext::e($createdRolePassword) . '</code></div>';
$createdUserCard .= $assignedDbsHtml;
$createdUserCard .= '</div></div></section>';
}
$passwordChangedCard = '';
if ($passwordChangedFor !== '' && $changedPassword !== '') {
$passwordChangedCard .= '<section class="success-credentials">';
$passwordChangedCard .= '<div class="left">✓</div>';
$passwordChangedCard .= '<div class="right">';
$passwordChangedCard .= '<h4>Hasło zostało zmienione</h4>';
$passwordChangedCard .= '<div class="kv">';
$passwordChangedCard .= '<div><strong>Nazwa hosta:</strong></div><div><code>' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')</code></div>';
$passwordChangedCard .= '<div><strong>Nazwa użytkownika:</strong></div><div><code>' . AppContext::e($passwordChangedFor) . '</code></div>';
$passwordChangedCard .= '<div><strong>Hasło:</strong></div><div><code>' . AppContext::e($changedPassword) . '</code></div>';
$passwordChangedCard .= '</div></div></section>';
}
$body = '';
$body .= $createdUserCard;
$body .= $passwordChangedCard;
$body .= '<section class="panel">';
$body .= '<h3>Zarządzaj użytkownikami</h3>';
$body .= '<div class="actions" style="justify-content:space-between;align-items:center">';
$body .= '<div class="actions" style="margin-top:0">';
$body .= '<a class="btn primary" href="' . AppContext::e($ctx->url('change_password.html', ['add_user' => 1])) . '#add-user-form">Dodaj użytkownika</a>';
if ($showCreateUserForm) {
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('change_password.html')) . '">Anuluj</a>';
}
$body .= '</div>';
if ($selectedRole !== '') {
$body .= '<div class="actions" style="margin-top:0">';
$body .= '<form method="get" action="' . AppContext::e($ctx->url('change_password.html')) . '" class="actions" style="margin-top:0">';
$body .= '<label style="margin:0">Użytkownik: <select name="role" style="width:auto;display:inline-block;margin-left:8px">' . $roleOptions . '</select></label>';
$body .= '<button class="btn" type="submit">Przełącz</button>';
$body .= '</form>';
$body .= '<a class="btn danger-fill" href="' . AppContext::e($ctx->url('change_password.html', ['role' => $selectedRole, 'delete_role' => $selectedRole])) . '">Usuń użytkownika</a>';
$body .= '</div>';
} else {
$body .= '<span class="muted">Brak użytkowników MySQL. Dodaj pierwszego użytkownika.</span>';
}
$body .= '</div>';
$body .= '<p class="section-text">Szczegółowe informacje o użytkowniku.</p>';
if ($selectedRole !== '') {
$body .= '<table><tbody>';
$body .= '<tr><td><strong>Nazwa użytkownika</strong><br>' . AppContext::e($selectedRole) . '</td><td><strong>Bazy danych</strong><br>' . AppContext::e((string)count($assignedDbs)) . '</td>';
if ($showRemoteHosts) {
$body .= '<td><strong>Dozwolone hosty</strong><br>' . AppContext::e((string)$hostsCount) . '</td>';
} else {
$fixedHostSummary = implode(', ', array_keys($fixedHosts));
$body .= '<td><strong>Dozwolone hosty</strong><br>' . AppContext::e($fixedHostSummary) . '</td>';
}
$body .= '</tr></tbody></table>';
} else {
$body .= '<p class="muted">Aby zarządzać użytkownikami najpierw utwórz użytkownika MySQL.</p>';
}
$body .= '</section>';
if ($deletePromptRole !== '') {
$cancelUrl = $ctx->url('change_password.html', $selectedRole !== '' ? ['role' => $selectedRole] : []);
$body .= '<section class="panel" style="margin-bottom:14px;border-color:#f0b4a7;background:#fff5f3">';
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Czy na pewno chcesz usunąć użytkownika <strong>' . AppContext::e($deletePromptRole) . '</strong>?</div>';
if (count($deletePromptAssignedDbs) === 1) {
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Uwaga użytkownik <strong>' . AppContext::e($deletePromptRole) . '</strong> jest przypisany do następującej bazy danych <strong>' . AppContext::e($deletePromptAssignedDbs[0]) . '</strong>.</div>';
} elseif (count($deletePromptAssignedDbs) > 1) {
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Uwaga użytkownik <strong>' . AppContext::e($deletePromptRole) . '</strong> jest przypisany do następujących baz danych:</div>';
$body .= '<ul class="list-clean" style="margin:0 0 12px 18px">';
foreach ($deletePromptAssignedDbs as $assignedDbName) {
$body .= '<li>' . AppContext::e($assignedDbName) . '</li>';
}
$body .= '</ul>';
}
$body .= '<form method="post">';
$body .= $ctx->csrfField('delete_user_submit');
$body .= '<input type="hidden" name="form_action" value="delete_user_confirm">';
$body .= '<input type="hidden" name="delete_role" value="' . AppContext::e($deletePromptRole) . '">';
$body .= '<div class="actions">';
$body .= '<button class="danger-fill" type="submit">Potwierdź usunięcie użytkownika</button>';
$body .= '<a class="btn" href="' . AppContext::e($cancelUrl) . '">Anuluj</a>';
$body .= '</div>';
$body .= '</form>';
$body .= '</section>';
}
if ($showCreateUserForm) {
$body .= '<section class="panel" id="add-user-form" style="margin-top:14px">';
$body .= '<h3>Utwórz nowego użytkownika MySQL</h3>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('create_user_inline_submit');
$body .= '<input type="hidden" name="form_action" value="create_user_inline">';
$body .= '<div class="row">';
$body .= '<div><label>Nazwa użytkownika MySQL</label><div class="input-prefix"><span class="prefix">' . AppContext::e($prefix) . '</span><input type="text" name="create_role_name" value="' . AppContext::e($createRoleSuffixInput) . '" autocomplete="off" required></div></div>';
$body .= '<div><label>Hasło użytkownika</label>';
$body .= '<div class="input-with-tools"><input type="password" id="create-role-password" name="create_password" value="' . AppContext::e($createPasswordInput) . '" autocomplete="new-password" required>';
$body .= '<span class="field-tools">';
$body .= '<button type="button" class="tool-btn" data-generate-target="create-role-password" title="Generuj hasło" aria-label="Generuj hasło"><svg viewBox="0 0 24 24"><path d="M20 7v5h-5"></path><path d="M20 12a8 8 0 1 1-2.34-5.66L20 7"></path></svg></button>';
$body .= '<button type="button" class="tool-btn" data-toggle-target="create-role-password" title="Pokaż lub ukryj hasło" aria-label="Pokaż lub ukryj hasło"><svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"></path><circle cx="12" cy="12" r="3"></circle></svg></button>';
$body .= '</span></div>';
$body .= '</div>';
$body .= '</div>';
$body .= '<details class="details-advanced">';
$body .= '<summary>Przypisz do baz danych (opcjonalnie)</summary>';
if (empty($allDbs)) {
$body .= '<p class="muted">Brak baz do przypisania.</p>';
} else {
$body .= '<div class="db-choice-grid">';
foreach ($allDbs as $dbName) {
$checked = isset($createSelectedDbSet[$dbName]) ? ' checked' : '';
$body .= '<label class="db-choice-card"><input type="checkbox" name="create_user_databases[]" value="' . AppContext::e($dbName) . '"' . $checked . '><span>' . AppContext::e($dbName) . '</span></label>';
}
$body .= '</div>';
}
$body .= '<p class="muted" style="margin-top:8px">Możesz pozostawić użytkownika bez przypisania do baz i nadać dostęp później.</p>';
$body .= '</details>';
$body .= '<div class="actions"><button class="primary" type="submit">Utwórz użytkownika</button></div>';
$body .= '</form>';
$body .= '</section>';
}
if ($selectedRole !== '') {
$body .= '<section class="panel" style="margin-top:14px">';
$body .= '<h3>Zarządzanie hasłami</h3>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('user_manage_submit');
$body .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$body .= '<input type="hidden" name="form_action" value="change_password">';
$body .= '<label>Nowe hasło</label>';
$body .= '<div class="input-with-tools"><input type="password" id="change-role-password" name="password" placeholder="Wpisz nowe hasło" autocomplete="new-password" required>';
$body .= '<span class="field-tools">';
$body .= '<button type="button" class="tool-btn" data-generate-target="change-role-password" title="Generuj hasło" aria-label="Generuj hasło"><svg viewBox="0 0 24 24"><path d="M20 7v5h-5"></path><path d="M20 12a8 8 0 1 1-2.34-5.66L20 7"></path></svg></button>';
$body .= '<button type="button" class="tool-btn" data-toggle-target="change-role-password" title="Pokaż lub ukryj hasło" aria-label="Pokaż lub ukryj hasło"><svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"></path><circle cx="12" cy="12" r="3"></circle></svg></button>';
$body .= '</span></div>';
$body .= '<div class="actions"><button class="primary" type="submit">Zmień hasło</button></div>';
$body .= '</form>';
$body .= '</section>';
$body .= '<section class="panel" style="margin-top:14px">';
$body .= '<h3>Dostęp do baz danych</h3>';
$body .= '<table><thead><tr><th>Nazwa bazy danych</th><th>Przywileje</th><th>Akcje</th></tr></thead><tbody>' . $dbRows . '</tbody></table>';
$body .= '<form method="post" style="margin-top:10px">';
$body .= $ctx->csrfField('user_manage_submit');
$body .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$body .= '<input type="hidden" name="form_action" value="grant_db">';
$body .= '<label>Przyznaj dostęp do kolejnej bazy danych</label>';
$body .= '<select name="db_name">' . $dbOptions . '</select>';
$body .= '<input type="hidden" name="privileges[]" value="ALL">';
$body .= '<div class="actions"><button class="btn" type="submit">+ Przyznaj pełny dostęp</button></div>';
$body .= '</form>';
$body .= '</section>';
if ($showRemoteHosts) {
$fixedHostsHelp = '`localhost` jest dodawany automatycznie.';
if (count($fixedHosts) > 1) {
$fixedHostsHelp .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.';
}
$body .= '<section class="panel" style="margin-top:14px">';
$body .= '<h3>Dozwolone hosty</h3>';
$body .= '<p class="section-text">Lista źródłowych adresów IP, które mogą łączyć się z bazą przy użyciu poświadczeń użytkownika. ' . $fixedHostsHelp . '</p>';
$body .= '<table><thead><tr><th>Host</th><th>Komentarz</th><th>Akcje</th></tr></thead><tbody>' . $hostsRows . '</tbody></table>';
$body .= '<form method="post" style="margin-top:10px">';
$body .= $ctx->csrfField('user_manage_submit');
$body .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$body .= '<input type="hidden" name="form_action" value="add_host">';
$body .= '<div class="row">';
$body .= '<div><label>Zezwalaj na dostęp z</label><input type="text" name="host" placeholder="203.0.113.10"></div>';
$body .= '<div><label>Komentarz</label><input type="text" name="host_note" placeholder="np. biuro / serwer aplikacji"></div>';
$body .= '</div>';
$body .= '<div class="actions"><button class="btn" type="submit">+ Dodaj host</button></div>';
$body .= '</form>';
$body .= '</section>';
}
}
$ctx->renderPage('Zarządzaj użytkownikami', $body, $ok, $errors);
};
+19
View File
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
if ($ctx->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'));
};
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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 .= '<label class="inline"><input type="checkbox" name="databases[]" value="' . AppContext::e($dbName) . '"> ' . AppContext::e($dbName) . '</label>';
}
if ($dbCheckboxes === '') {
$dbCheckboxes = '<p class="muted">Brak baz do przypisania.</p>';
}
$privCheckboxes = '';
foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) {
$checked = ($code === 'ALL') ? ' checked' : '';
$privCheckboxes .= '<label class="inline"><input type="checkbox" name="privileges[]" value="' . AppContext::e($code) . '"' . $checked . '> ' . AppContext::e($label) . '</label>';
}
$remoteFields = '';
if ($showRemoteHosts) {
$remoteFields .= '<div class="row">';
$remoteFields .= '<div><label>Dodatkowy host IPv4</label><input type="text" name="remote_host" placeholder="203.0.113.10"></div>';
$remoteFields .= '<div><label>Komentarz hosta</label><input type="text" name="remote_comment" placeholder="np. Laptop administratora"></div>';
$remoteFields .= '</div>';
} else {
$remoteFields .= '<p class="muted">Hosty zdalne są wyłączone w konfiguracji pluginu (`allow_remote_hosts=0`).</p>';
}
$body = '';
$body .= '<form method="post">';
$body .= $ctx->csrfField('create_user_submit');
$body .= '<div class="row">';
$body .= '<div><label>Nazwa użytkownika MySQL</label><input type="text" name="role_name" placeholder="' . AppContext::e($prefix) . 'app_user" required></div>';
$body .= '<div><label>Hasło</label><input type="password" name="password" placeholder="min. 12 znaków" required></div>';
$body .= '</div>';
$defaultAccessText = '`localhost` jest dodawany automatycznie.';
if (count($fixedHosts) > 1) {
$defaultAccessText .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.';
}
$body .= '<div class="row">';
$body .= '<div><label>Domyślny dostęp</label><p class="muted">' . $defaultAccessText . '</p></div>';
$body .= '<div><label>Przypisz do baz</label><div>' . $dbCheckboxes . '</div></div>';
$body .= '</div>';
$body .= $remoteFields;
$body .= '<div class="panel"><h3>Uprawnienia dla przypisywanych baz</h3><div class="privileges-group" data-privileges-group>' . $privCheckboxes . '</div></div>';
$body .= '<div class="actions"><button class="primary" type="submit">Utwórz użytkownika</button></div>';
$body .= '</form>';
$ctx->renderPage('Tworzenie Użytkownika MySQL', $body, $ok, $errors);
};
+170
View File
@@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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 .= '<tr>';
$rows .= '<td><input type="checkbox" name="databases[]" value="' . AppContext::e($dbName) . '"></td>';
$rows .= '<td>' . AppContext::e($dbName) . '</td>';
$rows .= '<td>' . AppContext::e(implode(', ', $users)) . '</td>';
$rows .= '</tr>';
}
if ($rows === '') {
$rows = '<tr><td colspan="3" class="muted">Brak baz do usunięcia.</td></tr>';
}
$body = '';
$body .= '<form method="post">';
$body .= $ctx->csrfField('select_delete_databases');
$body .= '<table><thead><tr><th></th><th>Baza</th><th>Przypisani użytkownicy</th></tr></thead><tbody>' . $rows . '</tbody></table>';
$body .= '<div class="actions"><button class="danger" type="submit">Usuń zaznaczone bazy</button></div>';
$body .= '</form>';
$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 .= '<tr>';
$confirmRows .= '<td>' . AppContext::e($dbName) . '<input type="hidden" name="databases[]" value="' . AppContext::e($dbName) . '"></td>';
$confirmRows .= '<td>' . AppContext::e(implode(', ', $users)) . '</td>';
$confirmRows .= '</tr>';
} catch (Throwable $e) {
$errors[] = $rawDb . ': ' . $e->getMessage();
}
}
if ($confirmRows === '') {
$ctx->renderPage('Usuwanie Baz Danych', '<p class="muted">Brak poprawnych baz do usunięcia.</p>', $ok, $errors);
return;
}
$body = '';
$body .= '<p>Potwierdź usunięcie baz danych. Operacja jest nieodwracalna.</p>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('delete_databases_confirm');
$body .= '<input type="hidden" name="confirm_delete" value="1">';
$body .= '<table><thead><tr><th>Baza</th><th>Przypisani użytkownicy</th></tr></thead><tbody>' . $confirmRows . '</tbody></table>';
$body .= '<p><label class="inline"><input type="checkbox" name="delete_related_users" value="1"> Usuń także użytkowników przypisanych do usuwanych baz (jeżeli po usunięciu baz nie mają już innych przypisań)</label></p>';
$body .= '<div class="actions">';
$body .= '<button class="danger" type="submit">Potwierdź usunięcie</button>';
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('delete_databases.html')) . '">Anuluj</a>';
$body .= '</div>';
$body .= '</form>';
$ctx->renderPage('Potwierdzenie Usuwania Baz Danych', $body, $ok, $errors);
};
+158
View File
@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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 .= '<tr>';
$rows .= '<td><input type="checkbox" name="roles[]" value="' . AppContext::e($roleName) . '"></td>';
$rows .= '<td>' . AppContext::e($roleName) . '</td>';
$rows .= '<td>' . AppContext::e(implode(', ', $assigned)) . '</td>';
$rows .= '<td>' . AppContext::e(implode(', ', $owned)) . '</td>';
$rows .= '</tr>';
}
if ($rows === '') {
$rows = '<tr><td colspan="4" class="muted">Brak użytkowników do usunięcia.</td></tr>';
}
$body = '';
$body .= '<form method="post">';
$body .= $ctx->csrfField('select_delete_users');
$body .= '<table><thead><tr><th></th><th>Użytkownik</th><th>Przypisane bazy</th><th>Bazy, których jest właścicielem</th></tr></thead><tbody>' . $rows . '</tbody></table>';
$body .= '<div class="actions"><button class="danger" type="submit">Usuń zaznaczonych użytkowników</button></div>';
$body .= '</form>';
$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 .= '<tr>';
$confirmRows .= '<td>' . AppContext::e($role) . '<input type="hidden" name="roles[]" value="' . AppContext::e($role) . '"></td>';
$confirmRows .= '<td>' . AppContext::e(implode(', ', $assigned)) . '</td>';
$confirmRows .= '<td>' . AppContext::e(implode(', ', $owned)) . '</td>';
$confirmRows .= '</tr>';
} catch (Throwable $e) {
$errors[] = $rawRole . ': ' . $e->getMessage();
}
}
if ($confirmRows === '') {
$ctx->renderPage('Usuwanie Użytkowników', '<p class="muted">Brak poprawnych użytkowników do usunięcia.</p>', $ok, $errors);
return;
}
$body = '';
$body .= '<p>Potwierdź usunięcie zaznaczonych użytkowników MySQL. Operacja jest nieodwracalna.</p>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('delete_users_confirm');
$body .= '<input type="hidden" name="confirm_delete" value="1">';
$body .= '<table><thead><tr><th>Użytkownik</th><th>Przypisane bazy</th><th>Bazy, których jest właścicielem</th></tr></thead><tbody>';
$body .= $confirmRows;
$body .= '</tbody></table>';
$body .= '<div class="actions">';
$body .= '<button class="danger" type="submit">Potwierdź usunięcie</button>';
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('delete_users.html')) . '">Anuluj</a>';
$body .= '</div>';
$body .= '</form>';
$ctx->renderPage('Potwierdzenie Usuwania Użytkowników', $body, $ok, $errors);
};
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$pluginErrorLog = PLUGIN_ROOT . '/error.log';
$rawMode = defined('DA_MYSQL_RAW_MODE') && DA_MYSQL_RAW_MODE === true;
$log = static function (string $message) use ($pluginErrorLog, $ctx): void {
$entry = sprintf(
"[%s] DOWNLOAD_DIRECT action=%s user=%s remote=%s method=%s uri=%s %s\n",
date('c'),
$ctx->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;
}
};
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$queue = new BackupQueueService($ctx->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';
};
+792
View File
@@ -0,0 +1,792 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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<string,string> $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 .= '<tr>';
$dbRows .= '<td><strong>' . AppContext::e($name) . '</strong></td>';
if ($showDbSize) {
$dbRows .= '<td>' . AppContext::e($size) . '</td>';
}
$dbRows .= '<td>' . AppContext::e((string)count($users)) . '</td>';
if ($showTableCount) {
$dbRows .= '<td>' . AppContext::e((string)($tableCounts[$name] ?? 0)) . '</td>';
}
$dbRows .= '<td class="actions">';
if ($ctx->settings->enableAdminer()) {
$dbRows .= '<form method="post" target="_top" action="' . AppContext::e($ctx->url('open_phpmyadmin.raw')) . '" style="display:inline-flex;gap:6px;margin:0">';
$dbRows .= $ctx->csrfField('open_phpmyadmin_submit');
$dbRows .= '<input type="hidden" name="adminer_db" value="' . AppContext::e($name) . '">';
$dbRows .= '<button class="btn adminer-login" type="submit">Zaloguj do bazy</button>';
$dbRows .= '</form>';
}
if ($backupEnabled) {
$locked = $backupQueue->isDbLocked($name);
if (!$locked) {
$dbRows .= '<form method="post" style="display:inline-flex;gap:6px;margin:0">';
$dbRows .= $ctx->csrfField('queue_backup_submit');
$dbRows .= '<input type="hidden" name="form_action" value="queue_backup">';
$dbRows .= '<input type="hidden" name="backup_db" value="' . AppContext::e($name) . '">';
$dbRows .= '<button class="btn" type="submit">Wykonaj Backup</button>';
$dbRows .= '</form>';
} else {
$dbRows .= '<button class="btn" type="button" disabled title="Na tej bazie trwa już operacja backup/restore">Wykonaj Backup</button>';
}
}
$dbRows .= '<a class="btn" href="' . AppContext::e($ctx->url('modify_privileges.html', ['db' => $name])) . '">Zarządzaj</a>';
$dbRows .= '<a class="btn danger-fill" href="' . AppContext::e($ctx->url('index.html', ['delete_db' => $name])) . '">Usuń bazę</a>';
$dbRows .= '</td>';
$dbRows .= '</tr>';
}
if ($dbRows === '') {
$colspan = 3 + ($showDbSize ? 1 : 0) + ($showTableCount ? 1 : 0);
$dbRows = '<tr><td colspan="' . AppContext::e((string)$colspan) . '" class="muted">Nie znaleziono baz danych...</td></tr>';
}
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 .= '<tr>';
$deletePromptRows .= '<td><input type="checkbox" class="delete-role-checkbox" name="delete_roles[]" value="' . AppContext::e($roleName) . '"' . $isChecked . '></td>';
$deletePromptRows .= '<td>' . AppContext::e($roleName) . '</td>';
if (empty($assignedDbs)) {
$deletePromptRows .= '<td class="muted">Brak przypisań</td>';
} else {
$dbList = '';
foreach ($assignedDbs as $dbName) {
$dbList .= '<li>' . AppContext::e($dbName) . '</li>';
}
$deletePromptRows .= '<td><ul class="list-clean">' . $dbList . '</ul></td>';
}
$deletePromptRows .= '</tr>';
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 = '<option value="">-- wybierz istniejącego użytkownika --</option>';
$postedExistingRole = $ctx->postString('existing_role');
foreach ($roleNames as $roleName) {
$selected = ($postedExistingRole === $roleName) ? ' selected' : '';
$roleOptions .= '<option value="' . AppContext::e($roleName) . '"' . $selected . '>' . AppContext::e($roleName) . '</option>';
}
$privCheckboxes = '';
foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) {
$checked = ($code === 'ALL') ? ' checked' : '';
$privCheckboxes .= '<label class="inline"><input type="checkbox" name="privileges[]" value="' . AppContext::e($code) . '"' . $checked . '> ' . AppContext::e($label) . '</label>';
}
$successCard = '';
if ($generatedDb !== '' && $generatedRole !== '') {
$passwordLine = $generatedPassword !== ''
? '<div><strong>Hasło:</strong></div><div><code>' . AppContext::e($generatedPassword) . '</code></div>'
: '';
$successCard .= '<section class="success-credentials">';
$successCard .= '<div class="left">✓</div>';
$successCard .= '<div class="right">';
$successCard .= '<h4>Dane dostępowe do bazy danych</h4>';
$successCard .= '<div class="kv">';
$successCard .= '<div><strong>Nazwa hosta:</strong></div><div><code>' . AppContext::e($endpointHost) . ' (Port: ' . AppContext::e($endpointPort) . ')</code></div>';
$successCard .= '<div><strong>Baza danych:</strong></div><div><code>' . AppContext::e($generatedDb) . '</code></div>';
$successCard .= '<div><strong>Nazwa użytkownika:</strong></div><div><code>' . AppContext::e($generatedRole) . '</code></div>';
$successCard .= $passwordLine;
$successCard .= '</div></div></section>';
}
$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 .= '<section class="panel" style="margin-bottom:14px;border-color:#f0b4a7;background:#fff5f3">';
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Czy na pewno chcesz usunąć backup bazy danych - <strong>' . AppContext::e($deleteBackupDbName) . '</strong>?</div>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('delete_backup_confirm_submit');
$body .= '<input type="hidden" name="form_action" value="delete_backup_confirm">';
$body .= '<input type="hidden" name="backup_job_id" value="' . AppContext::e($deleteBackupJobId) . '">';
$body .= '<div class="actions">';
$body .= '<button class="danger" type="submit">Potwierdź usunięcie backupu</button>';
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">Anuluj</a>';
$body .= '</div>';
$body .= '</form>';
$body .= '</section>';
} catch (Throwable $e) {
$errors[] = $e->getMessage();
}
}
if ($deletePromptDb !== '') {
$body .= '<section class="panel" style="margin-bottom:14px;border-color:#f0b4a7;background:#fff5f3">';
if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') {
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Czy na pewno chcesz usunąć następującą bazę danych - <strong>' . AppContext::e($deletePromptDb) . '</strong> i przypisanego do niej użytkownika <strong>' . AppContext::e($deletePromptSingleRoleName) . '</strong>.</div>';
} else {
$body .= '<div class="alert alert-err" style="margin:0 0 10px">Czy na pewno chcesz usunąć następującą bazę danych - <strong>' . AppContext::e($deletePromptDb) . '</strong>.</div>';
}
$body .= '<form method="post">';
$body .= $ctx->csrfField('delete_database_submit');
$body .= '<input type="hidden" name="form_action" value="delete_database_confirm">';
$body .= '<input type="hidden" name="delete_db" value="' . AppContext::e($deletePromptDb) . '">';
if ($deletePromptSingleRoleMode && $deletePromptSingleRoleName !== '') {
$body .= '<div class="actions">';
$body .= '<button class="danger-fill" type="submit" name="delete_mode" value="with_user">Tak</button>';
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">Nie</a>';
$body .= '<button class="btn warn" type="submit" name="delete_mode" value="db_only">Usuń tylko bazę danych</button>';
$body .= '</div>';
} else {
if ($deletePromptRows !== '') {
$body .= '<p class="section-text">Do bazy danych ' . AppContext::e($deletePromptDb) . ' przypisani są użytkownicy, z poniższej listy wybierz użytkowników, których chcesz usunąć.</p>';
$body .= '<table><thead><tr>';
$body .= '<th data-select-all-for="delete-role-checkbox" title="Kliknij, aby zaznaczyć lub odznaczyć wszystkich użytkowników">Wybierz</th>';
$body .= '<th>Nazwa użytkownika</th>';
$body .= '<th>Przypisane Bazy danych</th>';
$body .= '</tr></thead><tbody>' . $deletePromptRows . '</tbody></table>';
} else {
$body .= '<p class="muted">Do wskazanej bazy nie są przypisani użytkownicy MySQL.</p>';
}
if (!empty($deletePromptWarnings)) {
foreach (array_values(array_unique($deletePromptWarnings)) as $warning) {
$body .= '<div class="alert alert-err">' . AppContext::e($warning) . '</div>';
}
}
$body .= '<div class="actions">';
$body .= '<button class="danger" type="submit">Potwierdź usunięcie bazy</button>';
$body .= '<a class="btn" href="' . AppContext::e($ctx->url('index.html')) . '">Anuluj</a>';
$body .= '</div>';
}
$body .= '</form>';
$body .= '</section>';
}
$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 .= '<section class="panel">';
$body .= '<h3 class="section-title-center">Lista baz danych</h3>';
$body .= '<div class="actions" style="justify-content:space-between;align-items:center;margin-bottom:8px">';
if ($serverVersion !== '') {
$body .= '<span class="muted">' . AppContext::e($ctx->t('Wersja serwera MySQL')) . ': <strong>' . AppContext::e($serverVersion) . '</strong></span>';
}
$body .= '<span class="muted">Ilość baz danych <strong>' . AppContext::e((string)$dbCount) . '/' . AppContext::e($limitText) . '</strong></span>';
$body .= '</div>';
$header = '<th>Nazwa bazy danych</th>';
if ($showDbSize) {
$header .= '<th>Rozmiar</th>';
}
$header .= '<th>Użytkownicy</th>';
if ($showTableCount) {
$header .= '<th>Liczba Tabel</th>';
}
$header .= '<th>Akcje</th>';
$body .= '<table><thead><tr>' . $header . '</tr></thead><tbody>' . $dbRows . '</tbody></table>';
$body .= '</section>';
$body .= '<section class="panel" style="margin-top:14px">';
$body .= '<h3>Utwórz bazę danych</h3>';
$body .= '<p class="section-text">Tryb prosty tworzy użytkownika o tej samej nazwie co baza i automatycznie generuje hasło.</p>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('create_database_submit');
$body .= '<input type="hidden" name="form_action" value="create_database">';
$body .= '<input type="hidden" id="create-db-mode" name="creation_mode" value="' . ($advancedOpen ? 'advanced' : 'simple') . '">';
$body .= '<input type="hidden" name="role_mode" value="new">';
$body .= '<div><label>Nazwa bazy danych</label>';
$body .= '<div class="input-prefix"><span class="prefix">' . AppContext::e($prefix) . '</span><input type="text" name="db_suffix" value="' . AppContext::e($dbSuffixValue) . '" required></div>';
$body .= '</div>';
$body .= '<p class="muted" style="margin-top:6px">Użytkownik o tej samej nazwie i bezpiecznym haśle zostanie wygenerowany automatycznie.</p>';
$body .= '<details class="details-advanced" id="create-db-advanced"' . ($advancedOpen ? ' open' : '') . '>';
$body .= '<summary>Tryb zaawansowany</summary>';
$body .= '<div class="row" style="margin-top:10px">';
$body .= '<div>';
$roleModeCurrent = strtolower($ctx->postString('role_mode', 'new'));
$body .= '<label class="inline"><input type="radio" id="role-mode-new" name="role_mode" value="new"' . ($roleModeCurrent !== 'existing' ? ' checked' : '') . '> Nowy użytkownik</label>';
$body .= '<label class="inline"><input type="radio" id="role-mode-existing" name="role_mode" value="existing"' . ($roleModeCurrent === 'existing' ? ' checked' : '') . '> Istniejący użytkownik</label>';
$body .= '</div>';
$body .= '<div><label>Istniejący użytkownik</label><select id="existing-role-name" name="existing_role">' . $roleOptions . '</select></div>';
$body .= '</div>';
$body .= '<div class="row">';
$body .= '<div><label>Nazwa użytkownika MySQL</label><div class="input-prefix"><span class="prefix">' . AppContext::e($prefix) . '</span><input type="text" id="advanced-role-name" name="role_name" value="' . AppContext::e($roleSuffixValue) . '" autocomplete="off"></div></div>';
$body .= '<div><label>Hasło użytkownika</label>';
$body .= '<div class="input-with-tools"><input type="password" id="advanced-role-password" name="password" value="' . AppContext::e($ctx->postString('password')) . '" autocomplete="new-password">';
$body .= '<span class="field-tools">';
$body .= '<button type="button" class="tool-btn" data-generate-target="advanced-role-password" title="Generuj hasło" aria-label="Generuj hasło"><svg viewBox="0 0 24 24"><path d="M20 7v5h-5"></path><path d="M20 12a8 8 0 1 1-2.34-5.66L20 7"></path></svg></button>';
$body .= '<button type="button" class="tool-btn" data-toggle-target="advanced-role-password" title="Pokaż lub ukryj hasło" aria-label="Pokaż lub ukryj hasło"><svg viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"></path><circle cx="12" cy="12" r="3"></circle></svg></button>';
$body .= '</span></div>';
$body .= '<p class="muted" style="margin-top:6px">W trybie zaawansowanym hasło jest wymagane.</p></div>';
$body .= '</div>';
if ($showRemoteHosts) {
$defaultHostHelp = '`localhost` jest dodawany automatycznie.';
if (count($fixedHosts) > 1) {
$defaultHostHelp .= ' Przy zdalnym serwerze MySQL plugin dodaje tez adres IP serwera DirectAdmin.';
}
$body .= '<div class="row">';
$body .= '<div><label>Dodatkowy host IPv4 (opcjonalnie)</label><input type="text" name="remote_host" placeholder="203.0.113.10"></div>';
$body .= '<div><label>Komentarz hosta (opcjonalnie)</label><input type="text" name="remote_comment" placeholder="np. serwer aplikacji"></div>';
$body .= '</div>';
$body .= '<p class="muted" style="margin-top:6px">' . $defaultHostHelp . '</p>';
}
$body .= '<div><label>Uprawnienia początkowe</label><div class="privileges-group" data-privileges-group>' . $privCheckboxes . '</div></div>';
$body .= '</details>';
$body .= '<div class="actions" style="justify-content:flex-end"><button class="primary" type="submit">UTWÓRZ</button></div>';
$body .= '</form>';
$body .= '</section>';
if (!empty($overlayHtmlById)) {
$body .= implode('', array_values($overlayHtmlById));
}
$ctx->renderPage('Zarządzanie bazami danych MySQL', $body, $ok, $errors);
};
+162
View File
@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
if (!$ctx->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 = '<option value="">-- wybierz użytkownika --</option>';
foreach ($roleNames as $roleName) {
$sel = ($roleName === $selectedRole) ? ' selected' : '';
$roleOptions .= '<option value="' . AppContext::e($roleName) . '"' . $sel . '>' . AppContext::e($roleName) . '</option>';
}
$hostRows = '';
if (empty($currentEntries)) {
$hostRows = '<tr><td colspan="3" class="muted">Brak zapisanych hostów.</td></tr>';
} else {
foreach ($currentEntries as $entry) {
$host = $entry['host'];
$note = $entry['note'];
$canRemove = !isset($fixedHosts[$host]);
$hostRows .= '<tr>';
$hostRows .= '<td>' . AppContext::e($host) . '</td>';
$hostRows .= '<td>' . AppContext::e($note) . '</td>';
if ($canRemove) {
$hostRows .= '<td><label class="inline"><input type="checkbox" name="remove_hosts[]" value="' . AppContext::e($host) . '"> usuń</label></td>';
} else {
$hostRows .= '<td><span class="muted">host stały</span></td>';
}
$hostRows .= '</tr>';
}
}
$body = '';
$body .= '<form method="post">';
$body .= $ctx->csrfField('manage_hosts_submit');
$body .= '<div class="row">';
$body .= '<div><label>Użytkownik MySQL</label><select name="role_name" required>' . $roleOptions . '</select></div>';
$body .= '<div><label>Dodaj IPv4</label><input type="text" name="add_host" placeholder="198.51.100.12"></div>';
$body .= '</div>';
$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 .= '<div class="row">';
$body .= '<div><label>Komentarz do nowego hosta</label><input type="text" name="add_comment" placeholder="np. serwer aplikacyjny PROD"></div>';
$body .= '<div><label>Informacja</label><p class="muted">' . $infoText . '</p></div>';
$body .= '</div>';
$body .= '<div class="panel"><h3>Aktualne hosty</h3>';
$body .= '<table><thead><tr><th>Host</th><th>Komentarz</th><th>Akcja</th></tr></thead><tbody>' . $hostRows . '</tbody></table>';
$body .= '</div>';
$body .= '<div class="actions"><button class="primary" type="submit">Zapisz hosty</button></div>';
$body .= '</form>';
$ctx->renderPage('Hosty Zdalne Użytkownika', $body, $ok, $errors);
};
+202
View File
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$ok = [];
$errors = [];
$daUser = $ctx->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', '<div class="panel"><p class="muted">Brak baz danych do zarządzania.</p></div>', $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 .= '<option value="' . AppContext::e($dbName) . '"' . $sel . '>' . AppContext::e($dbName) . '</option>';
}
$assignableRoles = array_values(array_diff($roleNames, $dbUsers));
$assignOptions = '<option value="">-- wybierz nowego użytkownika --</option>';
foreach ($assignableRoles as $role) {
$assignOptions .= '<option value="' . AppContext::e($role) . '">' . AppContext::e($role) . '</option>';
}
$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 .= '<tr>';
$usersRows .= '<td>' . AppContext::e($role) . '</td>';
$usersRows .= '<td><span class="badge">' . AppContext::e($label) . '</span></td>';
$usersRows .= '<td class="actions">';
$usersRows .= '<a class="btn" href="' . AppContext::e($ctx->url('change_password.html', ['role' => $role])) . '">Zarządzaj</a>';
$usersRows .= '<a class="btn" href="' . AppContext::e($ctx->url('modify_privileges.html', ['db' => $selectedDb, 'role' => $role])) . '#przywileje">Przywileje</a>';
$usersRows .= '<form method="post" style="display:inline-flex;gap:6px;margin:0">';
$usersRows .= $ctx->csrfField('modify_privileges_submit');
$usersRows .= '<input type="hidden" name="db_name" value="' . AppContext::e($selectedDb) . '">';
$usersRows .= '<input type="hidden" name="role_name" value="' . AppContext::e($role) . '">';
$usersRows .= '<input type="hidden" name="form_action" value="revoke_user">';
$usersRows .= '<button class="btn danger" type="submit">Odbierz dostęp</button>';
$usersRows .= '</form>';
$usersRows .= '</td>';
$usersRows .= '</tr>';
}
if ($usersRows === '') {
$usersRows = '<tr><td colspan="3" class="muted">Brak użytkowników z dostępem do bazy.</td></tr>';
}
$privCheckboxes = '';
foreach (NamePolicy::PRIVILEGE_LABELS as $code => $label) {
$checked = in_array($code, $currentProfile, true) ? ' checked' : '';
$privCheckboxes .= '<label class="inline"><input type="checkbox" name="privileges[]" value="' . AppContext::e($code) . '"' . $checked . '> ' . AppContext::e($label) . '</label>';
}
$body = '';
$body .= '<section class="panel">';
$body .= '<h3>Zarządzaj bazą danych</h3>';
$body .= '<form method="get" action="' . AppContext::e($ctx->url('modify_privileges.html')) . '" class="actions" style="justify-content:flex-end">';
$body .= '<label style="margin:0">Baza: <select name="db" style="width:auto;display:inline-block;margin-left:8px">' . $dbOptions . '</select></label>';
$body .= '<button class="btn" type="submit">Przełącz</button>';
$body .= '</form>';
$body .= '<p class="section-text">Szczegółowe informacje o bazie danych.</p>';
$body .= '<table><tbody>';
$body .= '<tr><td><strong>Nazwa bazy danych</strong><br>' . AppContext::e($dbInfo['name']) . '</td><td><strong>Domyślne kodowanie znaków</strong><br>' . AppContext::e($dbInfo['encoding']) . '</td><td><strong>Domyślne porównywanie znaków</strong><br>' . AppContext::e($dbInfo['collation']) . '</td></tr>';
$body .= '<tr><td><strong>Rozmiar</strong><br>' . AppContext::e($dbInfo['size']) . '</td><td><strong>Użytkownicy</strong><br>' . AppContext::e((string)count($dbUsers)) . '</td><td><strong>Liczba Tabel</strong><br>' . AppContext::e((string)$dbStats['tables']) . '</td></tr>';
$body .= '<tr><td><strong>Widoki</strong><br>' . AppContext::e((string)$dbStats['views']) . '</td><td><strong>Wyzwalacze</strong><br>' . AppContext::e((string)$dbStats['triggers']) . '</td><td><strong>Rutyny i procedury</strong><br>' . AppContext::e((string)$dbStats['routines']) . '</td></tr>';
$body .= '</tbody></table>';
$body .= '</section>';
$body .= '<section class="panel" style="margin-top:14px">';
$body .= '<h3>Dostęp użytkownika</h3>';
$body .= '<p class="section-text">Lista użytkowników, którzy mają dostęp do tej bazy danych wraz z podsumowaniem uprawnień.</p>';
$body .= '<table><thead><tr><th>Nazwa użytkownika</th><th>Przywileje</th><th>Akcje</th></tr></thead><tbody>' . $usersRows . '</tbody></table>';
$body .= '<form method="post" style="margin-top:10px">';
$body .= $ctx->csrfField('modify_privileges_submit');
$body .= '<input type="hidden" name="db_name" value="' . AppContext::e($selectedDb) . '">';
$body .= '<input type="hidden" name="form_action" value="grant_user">';
$body .= '<label>Przyznaj dostęp kolejnemu użytkownikowi</label>';
$body .= '<select name="role_name">' . $assignOptions . '</select>';
$body .= '<input type="hidden" name="grant_privileges[]" value="ALL">';
$body .= '<div class="actions"><button class="btn" type="submit">+ Przyznaj pełny dostęp</button></div>';
$body .= '</form>';
$body .= '</section>';
if ($selectedRole !== '') {
$body .= '<section class="panel" id="przywileje" style="margin-top:14px">';
$body .= '<h3>Przywileje użytkownika: ' . AppContext::e($selectedRole) . '</h3>';
$body .= '<form method="post">';
$body .= $ctx->csrfField('modify_privileges_submit');
$body .= '<input type="hidden" name="db_name" value="' . AppContext::e($selectedDb) . '">';
$body .= '<input type="hidden" name="role_name" value="' . AppContext::e($selectedRole) . '">';
$body .= '<input type="hidden" name="form_action" value="save_privileges">';
$body .= '<div class="privileges-group" data-privileges-group>' . $privCheckboxes . '</div>';
$body .= '<div class="actions"><button class="primary" type="submit">Zapisz zmiany</button></div>';
$body .= '</form>';
$body .= '</section>';
}
$ctx->renderPage('Zarządzaj bazą danych', $body, $ok, $errors);
};
+135
View File
@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
return static function (AppContext $ctx): void {
$rawMode = defined('DA_MYSQL_RAW_MODE') && DA_MYSQL_RAW_MODE === true;
$ensurePhpMyAdminReady = static function (): void {
$healthScript = PLUGIN_ROOT . '/scripts/setup/phpmyadmin_health_check.sh';
$repairScript = PLUGIN_ROOT . '/scripts/setup/phpmyadmin_repair.sh';
$runScript = static function (string $script, string &$output): int {
if (!is_file($script)) {
$output = 'Brak skryptu: ' . $script;
return 127;
}
$lines = [];
$code = 0;
exec('/bin/bash ' . escapeshellarg($script) . ' 2>&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;
}
};
File diff suppressed because it is too large Load Diff
+333
View File
@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
final class AppContext
{
public DirectAdminUser $daUser;
public Settings $settings;
public MySQLService $mysql;
private CsrfGuard $csrf;
private Lang $lang;
public function __construct(DirectAdminUser $daUser, Settings $settings, MySQLService $mysql, CsrfGuard $csrf, Lang $lang)
{
$this->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<int,string>
*/
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<string,string|int|float> $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 '<input type="hidden" name="csrf_ts" value="' . $ts . '">' .
'<input type="hidden" name="csrf_sid" value="' . $sid . '">' .
'<input type="hidden" name="csrf_token" value="' . $token . '">';
}
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 .= '<div class="alert alert-ok">' . self::e($msg) . '</div>';
}
foreach ($errorMessages as $msg) {
$alerts .= '<div class="alert alert-err">' . self::e($msg) . '</div>';
}
$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 '<!doctype html><html lang="' . self::e($langCode) . '"><head><meta charset="utf-8">';
echo '<meta name="viewport" content="width=device-width, initial-scale=1">';
echo '<title>' . $safeTitle . '</title>';
echo '<style>' . $this->styles() . '</style>';
echo '</head><body>';
echo '<div class="wrap">';
echo '<div class="breadcrumbs">' . self::e($this->t('Pulpit')) . ' <span>&gt;</span> ' . self::e($this->t('Bazy danych')) . '</div>';
$headerClass = in_array($this->action(), ['index', 'upload_backup'], true) ? ' class="header-center"' : '';
echo '<header' . $headerClass . '><h1>' . $safeTitle . '</h1></header>';
echo '<div class="top-actions">' . $topActions . '</div>';
echo '<section class="main-section">' . $alerts . $body . '</section>';
echo '<section class="logs-section">' . $logs . '</section>';
echo '</div>';
$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 '<script>' . $jsConfig . '</script>';
echo '<script>' . $this->scripts() . '</script>';
echo '</body></html>';
}
public function renderFatal(string $message, int $code = 400): void
{
http_response_code($code);
$body = '<p>' . self::e($this->t($message)) . '</p>';
$body .= '<p><a href="' . self::e($this->url('index.html')) . '">' . self::e($this->t('Powrót')) . '</a></p>';
$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[] = '<a class="btn amber' . ($this->action() === 'index' ? ' active' : '') . '" href="' . self::e($this->url('index.html')) . '">' . self::e($this->t('BAZY DANYCH')) . '</a>';
$links[] = '<a class="btn amber' . ($this->action() === 'change_password' ? ' active' : '') . '" href="' . self::e($this->url('change_password.html')) . '">' . self::e($this->t('UŻYTKOWNICY BAZ DANYCH')) . '</a>';
if ($this->settings->enableUploadBackup()) {
$links[] = '<a class="btn amber' . ($this->action() === 'upload_backup' ? ' active' : '') . '" href="' . self::e($this->url('upload_backup.html')) . '">' . self::e($this->t('BACKUPY BAZ DANYCH')) . '</a>';
}
if ($this->settings->enableAdminer()) {
$links[] = '<form method="post" target="_top" action="' . self::e($this->url('open_phpmyadmin.raw')) . '" style="display:inline-flex;align-items:center;">' .
$this->csrfField('open_phpmyadmin_submit') .
'<button class="btn amber' . ($this->action() === 'open_phpmyadmin' ? ' active' : '') . '" type="submit">' . self::e($this->t('PHPMYADMIN')) . '</button>' .
'</form>';
}
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<string,string>
*/
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;
}
}
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
final class CsrfGuard
{
private string $secret;
private string $username;
private string $sessionId;
public function __construct(string $secret, string $username, string $sessionId)
{
$this->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);
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
declare(strict_types=1);
final class DirectAdminUser
{
private string $username;
private string $prefix;
/** @var array<string,string> */
private array $userConf;
/** @var array<string,string> */
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<string,string>
*/
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<string,string> $userConf
* @return array<string,string>
*/
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));
}
}
+536
View File
@@ -0,0 +1,536 @@
<?php
declare(strict_types=1);
final class FilePicker
{
public static function styles(): string
{
return <<<'CSS'
.br-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.br-modal {
width: min(560px, 92vw);
background: #ffffff;
border-radius: 12px;
padding: 16px;
border: 2px solid #d8dde6;
box-shadow: 0 20px 40px rgba(15, 23, 42, 0.25);
}
.br-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.br-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.br-modal-title {
font-weight: 700;
font-size: 16px;
}
.br-modal-body {
max-height: 60vh;
overflow: auto;
}
.br-muted { color: #64748b; }
.br-alert {
border-radius: 8px;
padding: 10px 12px;
margin: 8px 0;
border: 1px solid transparent;
}
.br-alert-error { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
.br-picker-path {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.br-picker-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid #d8dde6;
border-radius: 10px;
max-height: 45vh;
overflow: auto;
}
.br-picker-create {
margin-top: 10px;
display: flex;
gap: 8px;
}
.br-picker-create input {
flex: 1;
padding: 6px 8px;
border: 1px solid #d8dde6;
border-radius: 8px;
}
.br-picker-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid #d8dde6;
}
.br-picker-list li:last-child {
border-bottom: none;
}
.br-picker-list li:hover {
background: #f8fafc;
}
.br-picker-list li.active {
background: #e0f2fe;
}
.br-btn {
background: #b77200;
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn:hover { background: #9a6200; }
.br-btn-ghost {
background: transparent;
color: #1f2d3d;
border: 1px solid #d8dde6;
}
.br-btn-ghost:hover { background: #eef2f7; }
.br-btn-small {
padding: 6px 10px;
font-size: 12px;
}
.br-icon-btn {
border: 1px solid #d8dde6;
background: #fff;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.br-icon-btn:hover {
background: #f8fafc;
}
CSS;
}
public static function scripts(): string
{
return <<<'JS'
var pickerTarget = null;
var pickerPath = '';
var pickerBase = '';
var pickerMode = 'dir';
var pickerSelected = '';
var pickerUrl = '';
var pickerRoot = '';
function openPicker(inputId, mode) {
var modal = document.getElementById('br-picker');
if (!modal) { return; }
var target = document.getElementById(inputId);
if (!target) { return; }
pickerTarget = target;
pickerMode = mode === 'file' ? 'file' : 'dir';
pickerSelected = '';
pickerUrl = modal.getAttribute('data-picker-url') || window.location.href;
pickerRoot = modal.getAttribute('data-picker-root') || '';
var initial = (target.value || '').trim();
if (initial === '') {
initial = pickerRoot || '';
}
var createRow = document.getElementById('br-picker-create');
if (createRow) {
createRow.style.display = (pickerMode === 'dir') ? 'flex' : 'none';
}
loadPicker(initial);
modal.style.display = 'flex';
}
function closePicker() {
var modal = document.getElementById('br-picker');
if (modal) { modal.style.display = 'none'; }
}
function pickerSetError(msg) {
var el = document.getElementById('br-picker-error');
if (!el) { return; }
if (msg) {
el.textContent = msg;
el.style.display = 'block';
} else {
el.textContent = '';
el.style.display = 'none';
}
}
function renderPickerList(items) {
var list = document.getElementById('br-picker-list');
if (!list) { return; }
list.innerHTML = '';
if (!items || !items.length) {
var empty = document.createElement('li');
empty.textContent = pickerMode === 'file' ? tr('Brak plików .sql/.gz') : tr('Brak katalogów');
empty.className = 'br-muted';
list.appendChild(empty);
return;
}
items.forEach(function (entry) {
var li = document.createElement('li');
if (entry.type === 'dir') {
li.textContent = '📁 ' + entry.name + '/';
li.addEventListener('click', function () {
pickerSelected = '';
loadPicker(pickerPath.replace(/\/+$/, '') + '/' + entry.name);
});
} else {
var full = pickerPath.replace(/\/+$/, '') + '/' + entry.name;
li.textContent = '📄 ' + entry.name;
li.addEventListener('click', function () {
pickerSelected = full;
var active = list.querySelectorAll('li.active');
for (var i = 0; i < active.length; i += 1) {
active[i].classList.remove('active');
}
li.classList.add('active');
});
if (pickerSelected === full) {
li.classList.add('active');
}
}
list.appendChild(li);
});
}
function extractPickerJson(text) {
var marker = '__DA_MYSQL_PICKER_JSON__';
var start = text.indexOf(marker);
if (start === -1) { return null; }
start += marker.length;
var end = text.indexOf(marker, start);
if (end === -1) { return null; }
var jsonText = text.substring(start, end).trim();
try { return JSON.parse(jsonText); } catch (e) { return null; }
}
function loadPicker(path) {
pickerSetError('');
var url = pickerUrl + '?action=dir_list&mode=' + encodeURIComponent(pickerMode) + '&path=' + encodeURIComponent(path || '');
fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (text) {
var data = extractPickerJson(text || '');
if (!data || !data.ok) {
pickerSetError(tr('Nie udało się wczytać katalogów.'));
return;
}
pickerPath = data.path || '';
pickerBase = data.base || '';
if (data.selected) {
pickerSelected = data.selected;
}
var current = document.getElementById('br-picker-current');
if (current) { current.textContent = pickerPath; }
renderPickerList(data.items || []);
})
.catch(function () {
pickerSetError(tr('Nie udało się wczytać katalogów.'));
});
}
function pickerGoUp() {
if (!pickerPath) { return; }
if (pickerBase && pickerPath === pickerBase) { return; }
var up = pickerPath.replace(/\/+$/, '');
up = up.substring(0, up.lastIndexOf('/')) || '/';
loadPicker(up);
}
function createPickerDir() {
var input = document.getElementById('br-picker-new');
if (!input) { return; }
var name = (input.value || '').trim();
if (name === '') {
pickerSetError(tr('Podaj nazwę katalogu.'));
return;
}
pickerSetError('');
var url = pickerUrl + '?action=dir_create&path=' + encodeURIComponent(pickerPath || '') + '&name=' + encodeURIComponent(name);
fetch(url, { credentials: 'same-origin' })
.then(function (r) { return r.text(); })
.then(function (text) {
var data = extractPickerJson(text || '');
if (!data || !data.ok) {
pickerSetError(tr('Nie udało się utworzyć katalogu.'));
return;
}
input.value = '';
loadPicker(pickerPath);
})
.catch(function () {
pickerSetError(tr('Nie udało się utworzyć katalogu.'));
});
}
function selectPickerPath() {
if (!pickerTarget) { closePicker(); return; }
if (pickerMode === 'file' && pickerSelected) {
pickerTarget.value = pickerSelected || '';
} else if (pickerMode === 'dir') {
pickerTarget.value = pickerPath || '';
}
try {
pickerTarget.dispatchEvent(new Event('input', { bubbles: true }));
pickerTarget.dispatchEvent(new Event('change', { bubbles: true }));
} catch (e) {
// ignore dispatch errors on older browsers
}
if (typeof window.__restoreFileSync === 'function') {
window.__restoreFileSync();
}
closePicker();
}
function bindFilePicker() {
window.openPicker = openPicker;
window.closePicker = closePicker;
window.pickerGoUp = pickerGoUp;
window.createPickerDir = createPickerDir;
window.selectPickerPath = selectPickerPath;
}
JS;
}
public static function renderOverlay(AppContext $ctx, string $overlayId, string $rootPath, string $listUrl, string $inputId = 'source-file-path'): string
{
$safeRoot = AppContext::e($rootPath);
$safeUrl = AppContext::e($listUrl);
$title = $ctx->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 .= '<div id="br-picker" class="br-overlay" style="display:none;" data-picker-url="' . $safeUrl . '" data-picker-root="' . $safeRoot . '">';
$html .= '<div class="br-modal">';
$html .= '<div class="br-modal-header"><div class="br-modal-title">' . AppContext::e($title) . '</div><button class="br-btn br-btn-ghost br-btn-small" type="button" onclick="closePicker();">' . AppContext::e($close) . '</button></div>';
$html .= '<div class="br-modal-body">';
$html .= '<div class="br-picker-path"><button class="br-btn br-btn-ghost br-btn-small" type="button" onclick="pickerGoUp();">⟵ ' . AppContext::e($up) . '</button><span id="br-picker-current" class="br-muted"></span></div>';
$html .= '<div id="br-picker-error" class="br-alert br-alert-error" style="display:none;"></div>';
$html .= '<ul id="br-picker-list" class="br-picker-list"></ul>';
$html .= '<div id="br-picker-create" class="br-picker-create" style="display:none;">';
$html .= '<input id="br-picker-new" type="text" placeholder="' . AppContext::e($newDir) . '" />';
$html .= '<button class="br-btn br-btn-ghost br-btn-small" type="button" onclick="createPickerDir();">' . AppContext::e($create) . '</button>';
$html .= '</div>';
$html .= '</div>';
$html .= '<div class="br-modal-actions">';
$html .= '<button class="br-btn br-btn-ghost" type="button" onclick="closePicker();">' . AppContext::e($cancel) . '</button>';
$html .= '<button class="br-btn" type="button" onclick="selectPickerPath();">' . AppContext::e($choose) . '</button>';
$html .= '</div>';
$html .= '</div></div>';
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<string,mixed> $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];
}
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
final class Http
{
public static function bootstrapGlobals(): void
{
if (PHP_SAPI !== 'cli') {
return;
}
$query = getenv('QUERY_STRING') ?: '';
$method = strtoupper((string)(getenv('REQUEST_METHOD') ?: 'GET'));
$_GET = [];
if ($query !== '') {
parse_str($query, $_GET);
}
$queryAction = strtolower(trim((string)($_GET['action'] ?? '')));
$_POST = [];
if ($method === 'POST') {
$rawPost = (string)(getenv('POST') ?: '');
if ($rawPost === '') {
$rawPost = (string)(getenv('HTTP_POST') ?: '');
}
$contentType = strtolower((string)(getenv('CONTENT_TYPE') ?: ''));
$isUrlEncoded = ($contentType === '' || str_contains($contentType, 'application/x-www-form-urlencoded'));
if ($rawPost === '' && $isUrlEncoded) {
if ($queryAction !== 'upload_blob') {
$stdin = @file_get_contents('php://stdin');
if (is_string($stdin) && $stdin !== '') {
$rawPost = $stdin;
}
}
}
if ($rawPost !== '') {
parse_str($rawPost, $_POST);
}
}
$_REQUEST = array_merge($_GET, $_POST);
$_SERVER['REQUEST_METHOD'] = $method;
$_SERVER['QUERY_STRING'] = $query;
$knownServerVars = [
'USER',
'USERNAME',
'HOME',
'LANGUAGE',
'SESSION_ID',
'REQUEST_URI',
'HTTP_HOST',
'HTTPS',
'HTTP_USER_AGENT',
'HTTP_X_FORWARDED_PROTO',
'HTTP_FRONT_END_HTTPS',
'REQUEST_SCHEME',
'SERVER_NAME',
'SERVER_PORT',
'REMOTE_ADDR',
];
foreach ($knownServerVars as $key) {
if (!isset($_SERVER[$key])) {
$value = getenv($key);
if ($value !== false) {
$_SERVER[$key] = $value;
}
}
}
}
public static function method(): string
{
return strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
}
public static function isPost(): bool
{
return self::method() === 'POST';
}
public static function getString(array $source, string $key, string $default = ''): string
{
if (!isset($source[$key])) {
return $default;
}
$value = $source[$key];
if (is_array($value)) {
return $default;
}
return trim((string)$value);
}
public static function getArray(array $source, string $key): array
{
if (!isset($source[$key])) {
return [];
}
$value = $source[$key];
if (!is_array($value)) {
return [];
}
return $value;
}
public static function server(string $key, string $default = ''): string
{
$value = $_SERVER[$key] ?? null;
if ($value === null) {
return $default;
}
return trim((string)$value);
}
public static function redirect(string $url): void
{
header('Location: ' . $url, true, 302);
exit;
}
}
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
final class Lang
{
/** @var array<string,string> */
private array $map;
/**
* @param array<string,string> $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<string,string|int|float> $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);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
final class LocalizedException extends RuntimeException
{
/** @var array<string,string|int|float> */
private array $vars;
/**
* @param array<string,string|int|float> $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<string,string|int|float>
*/
public function vars(): array
{
return $this->vars;
}
}
File diff suppressed because it is too large Load Diff
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
final class NamePolicy
{
public const MAX_IDENTIFIER_LEN = 64;
public const MAX_HOST_COMMENT_LEN = 180;
/**
* @var array<string,string>
*/
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<int,string> $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;
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
final class PhpMyAdminRuntimeConfig
{
private const FILE_NAME = 'config.json';
public static function sync(Settings $settings, string $pluginErrorLog = ''): void
{
$runtimeDir = rtrim($settings->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);
}
}
+216
View File
@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
final class PhpMyAdminSso
{
private Settings $settings;
private DirectAdminUser $daUser;
private MySQLService $db;
public function __construct(Settings $settings, DirectAdminUser $daUser, MySQLService $db)
{
$this->settings = $settings;
$this->daUser = $daUser;
$this->db = $db;
}
/**
* @return array{target:string,auth:array<string,string|int>}
*/
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<string,string|int> $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');
}
}
+472
View File
@@ -0,0 +1,472 @@
<?php
declare(strict_types=1);
final class Settings
{
/** @var array<string,string> */
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);
}
}
+1
View File
@@ -0,0 +1 @@
<a href="/CMD_PLUGINS/alt-mysql/index.html" target="_top"><img src="/CMD_PLUGINS/alt-mysql/images/user_icon.svg" width="16" height="16" alt="MySQL"/></a>
+1
View File
@@ -0,0 +1 @@
<a href="/CMD_PLUGINS/alt-mysql/index.html" target="_top">Bazy danych MySQL</a>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 4167 4167" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Layer2">
<g transform="matrix(7.71871,0,0,7.71871,1094.66,1656.5)">
<path d="M89.153,145.785C82.627,138.975 79.663,129.503 81.02,119.795C82.92,106.203 82.219,94.365 81.842,88.005C81.789,87.115 81.742,86.335 81.715,85.72C84.788,82.995 99.029,75.365 109.185,77.692C113.819,78.753 116.643,81.909 117.817,87.337C123.893,115.44 118.621,127.153 114.385,136.566C113.512,138.505 112.687,140.338 111.983,142.234L111.437,143.7C110.055,147.406 108.769,150.852 107.972,154.124C101.034,154.104 94.285,151.14 89.153,145.784L89.153,145.785ZM108.076,81.525C105.396,81.152 102.969,81.497 101.741,82.427C101.051,82.95 100.837,83.556 100.779,83.973C100.625,85.078 101.399,86.3 101.875,86.93C103.221,88.714 105.187,89.94 107.133,90.21C107.415,90.249 107.696,90.269 107.975,90.268C111.22,90.268 114.171,87.741 114.431,85.876C114.756,83.54 111.365,81.983 108.076,81.526L108.076,81.525ZM66.674,175.778C60.774,182.872 56.7,181.512 55.36,181.066C46.63,178.154 36.5,159.702 27.569,130.442C19.841,105.124 15.325,79.665 14.967,72.526C13.839,49.948 19.312,34.213 31.235,25.757C50.639,11.997 82.541,20.233 95.36,24.41C95.176,24.592 94.984,24.762 94.802,24.947C73.766,46.191 74.265,82.487 74.317,84.706C74.315,85.562 74.387,86.774 74.485,88.441C74.847,94.546 75.521,105.908 73.721,118.775C72.049,130.732 75.735,142.435 83.832,150.884C84.663,151.75 85.536,152.573 86.449,153.352C82.845,157.212 75.012,165.748 66.674,175.778ZM90.218,183.684C88.192,183.178 86.37,182.299 85.301,181.57C86.194,181.15 87.783,180.578 90.539,180.01C103.876,177.265 105.936,175.327 110.434,169.616C111.465,168.306 112.634,166.822 114.253,165.014L114.255,165.012C116.666,162.312 117.769,162.77 119.769,163.6C121.39,164.27 122.969,166.302 123.609,168.538C123.912,169.594 124.252,171.598 123.139,173.158C113.743,186.314 100.051,186.145 90.218,183.684ZM228.121,170.701C223.861,172.678 216.732,174.161 209.96,174.48C202.48,174.83 198.672,173.642 197.776,172.911C197.356,164.267 200.573,163.364 203.978,162.408C204.513,162.258 205.035,162.111 205.539,161.935C205.852,162.19 206.196,162.442 206.571,162.691C212.583,166.659 223.306,167.087 238.445,163.962L238.611,163.929C236.569,165.838 233.075,168.4 228.121,170.701ZM186.052,14.758C207.386,15.229 224.262,23.21 236.21,38.478C245.374,50.189 235.283,103.476 206.07,149.447C205.776,149.073 205.481,148.701 205.184,148.33L204.814,147.868C212.363,135.401 210.887,123.066 209.573,112.13C209.033,107.642 208.523,103.403 208.653,99.421C208.787,95.201 209.345,91.581 209.885,88.081C210.548,83.768 211.223,79.305 211.037,74.044C211.176,73.492 211.232,72.84 211.159,72.066C210.684,67.021 204.924,51.922 193.184,38.256C186.762,30.781 177.397,22.416 164.61,16.774C170.11,15.634 177.631,14.571 186.052,14.758ZM203.696,76.545C203.635,80.192 203.133,83.503 202.601,86.959C202.028,90.676 201.436,94.519 201.287,99.184C201.14,103.724 201.707,108.444 202.255,113.009C203.363,122.229 204.5,131.721 200.099,141.087C199.369,139.791 198.718,138.452 198.149,137.078C197.602,135.752 196.414,133.622 194.769,130.674C188.37,119.198 173.385,92.324 181.056,81.358C183.341,78.094 189.14,74.738 203.696,76.545ZM196.86,81.599C196.604,79.768 193.346,79.246 190.254,79.676C187.166,80.106 184.172,81.5 184.422,83.335C184.622,84.762 187.199,87.198 190.249,87.198C190.507,87.198 190.767,87.18 191.029,87.144C193.065,86.862 194.559,85.569 195.269,84.824C196.349,83.688 196.975,82.422 196.86,81.599ZM160.017,248.636C143.701,252.132 137.924,243.807 134.117,234.29C131.66,228.146 130.452,200.44 131.309,169.843C131.32,169.436 131.262,169.043 131.15,168.673C131.05,167.942 130.898,167.22 130.694,166.511C129.42,162.059 126.315,158.335 122.59,156.791C121.11,156.178 118.394,155.053 115.13,155.888C115.826,153.02 117.033,149.781 118.342,146.274L118.891,144.799C119.509,143.136 120.285,141.413 121.105,139.589C125.538,129.741 131.609,116.252 125.02,85.779C122.552,74.365 114.31,68.791 101.816,70.086C94.326,70.861 87.473,73.883 84.055,75.616C83.32,75.988 82.648,76.348 82.02,76.698C82.974,65.198 86.578,43.706 100.06,30.108C108.549,21.548 119.854,17.32 133.628,17.548C160.768,17.992 178.172,31.92 187.994,43.527C196.458,53.528 201.041,63.603 202.87,69.037C189.115,67.638 179.76,70.353 175.018,77.133C164.701,91.881 180.662,120.505 188.333,134.262C189.74,136.783 190.954,138.962 191.336,139.888C193.834,145.942 197.068,149.984 199.429,152.934C200.153,153.838 200.855,154.715 201.389,155.481C197.223,156.682 189.74,159.457 190.422,173.328C189.872,180.288 185.961,212.874 183.974,224.387C181.351,239.597 175.754,245.262 160.017,248.637L160.017,248.636Z" style="fill:rgb(90,93,157);"/>
</g>
<g transform="matrix(184.22,0,0,144.123,-127.311,-71.8256)">
<path d="M17.389,14.125C17.652,13.993 17.896,13.851 18.118,13.7C18.497,13.442 18.809,13.165 19.026,12.864C19.22,12.595 19.335,12.309 19.335,12C19.335,11.685 19.543,11.43 19.8,11.43L20.2,11.43C20.457,11.43 20.665,11.685 20.665,12L20.665,18C20.665,18.028 20.664,18.056 20.663,18.085L20.665,18.083L20.665,24.083C20.665,24.141 20.658,24.197 20.645,24.249C20.528,25.234 19.89,26.186 18.795,26.933C17.262,27.978 14.791,28.677 12,28.677C9.209,28.677 6.738,27.978 5.205,26.933C4.11,26.186 3.472,25.234 3.356,24.249C3.342,24.197 3.335,24.141 3.335,24.083L3.335,6C3.335,4.89 3.987,3.805 5.205,2.974C6.738,1.929 9.209,1.23 12,1.23C14.791,1.23 17.262,1.929 18.795,2.974C20.013,3.805 20.665,4.89 20.665,6C20.665,7.11 20.013,8.195 18.795,9.026C18.073,9.518 17.143,9.934 16.069,10.235L16.069,8.652C16.866,8.402 17.561,8.079 18.118,7.7C18.497,7.442 18.809,7.165 19.026,6.864C19.22,6.595 19.335,6.309 19.335,6C19.335,5.691 19.22,5.405 19.026,5.136C18.809,4.835 18.497,4.558 18.118,4.3C16.739,3.36 14.51,2.77 12,2.77C9.49,2.77 7.261,3.36 5.882,4.3C5.503,4.558 5.191,4.835 4.974,5.136C4.78,5.405 4.665,5.691 4.665,6C4.665,6.309 4.78,6.595 4.974,6.864C5.191,7.165 5.503,7.442 5.882,7.7C6.612,8.198 7.581,8.598 8.704,8.864L8.704,10.427C7.302,10.123 6.095,9.633 5.205,9.026C5.01,8.893 4.83,8.754 4.665,8.61L4.665,12C4.665,12.309 4.78,12.595 4.974,12.864C5.191,13.165 5.503,13.442 5.882,13.7C6.136,13.873 6.418,14.034 6.725,14.181L6.725,15.825C6.152,15.592 5.641,15.323 5.205,15.026C5.01,14.893 4.83,14.754 4.665,14.61L4.665,18C4.665,18.309 4.78,18.595 4.974,18.864C5.191,19.165 5.503,19.442 5.882,19.7C6.136,19.873 6.418,20.034 6.725,20.181L6.725,21.825C6.152,21.592 5.641,21.323 5.205,21.026C5.01,20.893 4.83,20.754 4.665,20.61L4.665,23.907C4.665,24.216 4.78,24.502 4.974,24.771C5.191,25.072 5.503,25.349 5.882,25.607C7.261,26.547 9.49,27.138 12,27.138C14.51,27.138 16.739,26.547 18.118,25.607C18.497,25.349 18.809,25.072 19.026,24.771C19.22,24.502 19.335,24.216 19.335,23.907L19.335,20.61C19.17,20.754 18.99,20.893 18.795,21.026C18.388,21.303 17.916,21.556 17.389,21.778L17.389,20.125C17.652,19.993 17.896,19.851 18.118,19.7C18.497,19.442 18.809,19.165 19.026,18.864C19.22,18.595 19.335,18.309 19.335,18L19.335,14.61C19.17,14.754 18.99,14.893 18.795,15.026C18.388,15.303 17.916,15.556 17.389,15.778L17.389,14.125Z" style="fill:rgb(97,189,246);"/>
</g>
<g transform="matrix(1.21985,0,0,1.21985,-370.358,-97.3027)">
<path d="M1649.35,1278.68L1649.35,765.363L1870.92,765.363C1909.02,765.363 1942.35,772.882 1970.93,787.921C1999.5,802.959 2021.72,824.097 2037.6,851.333C2053.47,878.57 2061.41,910.402 2061.41,946.828C2061.41,983.589 2053.26,1015.42 2036.97,1042.32C2020.68,1069.23 1997.87,1089.95 1968.55,1104.48C1939.22,1119.02 1905.01,1126.29 1865.91,1126.29L1733.57,1126.29L1733.57,1018.01L1837.84,1018.01C1854.21,1018.01 1868.2,1015.13 1879.82,1009.36C1891.43,1003.6 1900.37,995.37 1906.64,984.675C1912.9,973.981 1916.04,961.366 1916.04,946.828C1916.04,932.124 1912.9,919.55 1906.64,909.107C1900.37,898.663 1891.43,890.643 1879.82,885.045C1868.2,879.447 1854.21,876.648 1837.84,876.648L1788.71,876.648L1788.71,1278.68L1649.35,1278.68Z" style="fill:rgb(255,160,8);fill-rule:nonzero;"/>
<path d="M2292,1431.07C2253.4,1431.07 2220.36,1425.6 2192.87,1414.65C2165.38,1403.71 2144.08,1388.67 2128.96,1369.54C2113.84,1350.4 2105.36,1328.47 2103.52,1303.74L2236.86,1303.74C2238.2,1311.93 2241.54,1318.53 2246.89,1323.55C2252.23,1328.56 2259.12,1332.19 2267.56,1334.45C2276,1336.7 2285.48,1337.83 2296.01,1337.83C2313.89,1337.83 2328.72,1333.49 2340.5,1324.8C2352.28,1316.11 2358.17,1300.4 2358.17,1277.68L2358.17,1212.51L2354.16,1212.51C2349.15,1225.54 2341.46,1236.61 2331.1,1245.72C2320.74,1254.83 2308.29,1261.76 2293.76,1266.52C2279.22,1271.29 2263.26,1273.67 2245.88,1273.67C2218.48,1273.67 2193.29,1267.32 2170.31,1254.62C2147.34,1241.92 2128.96,1221.78 2115.17,1194.21C2101.39,1166.64 2094.49,1130.63 2094.49,1086.19C2094.49,1039.73 2101.72,1001.97 2116.17,972.895C2130.63,943.821 2149.34,922.516 2172.32,908.981C2195.29,895.447 2219.48,888.679 2244.88,888.679C2263.93,888.679 2280.64,891.979 2295.01,898.58C2309.38,905.18 2321.41,913.952 2331.1,924.897C2340.79,935.842 2348.14,947.831 2353.16,960.864L2356.16,960.864L2356.16,893.692L2494.52,893.692L2494.52,1277.68C2494.52,1310.26 2486.12,1337.96 2469.33,1360.77C2452.54,1383.57 2428.93,1400.99 2398.52,1413.02C2368.11,1425.06 2332.61,1431.07 2292,1431.07ZM2297.01,1175.41C2309.71,1175.41 2320.62,1171.86 2329.72,1164.76C2338.83,1157.66 2345.85,1147.47 2350.78,1134.18C2355.7,1120.9 2358.17,1104.9 2358.17,1086.19C2358.17,1067.14 2355.7,1050.8 2350.78,1037.18C2345.85,1023.57 2338.83,1013.12 2329.72,1005.86C2320.62,998.586 2309.71,994.952 2297.01,994.952C2284.31,994.952 2273.49,998.586 2264.55,1005.86C2255.62,1013.12 2248.76,1023.57 2244,1037.18C2239.24,1050.8 2236.86,1067.14 2236.86,1086.19C2236.86,1105.24 2239.24,1121.4 2244,1134.68C2248.76,1147.97 2255.62,1158.08 2264.55,1165.01C2273.49,1171.95 2284.31,1175.41 2297.01,1175.41Z" style="fill:rgb(255,160,8);fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.9 KiB

+462
View File
@@ -0,0 +1,462 @@
<?php
return [
'Pulpit' => '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: <code>mysql_enabled</code> oraz ustaw limity.' => 'Enable the plugin via custom package items: <code>mysql_enabled</code> 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 ',
];
+50
View File
@@ -0,0 +1,50 @@
<?php
$en = include __DIR__ . '/en.php';
function pl_normalize_text(string $text): string
{
static $replacements = null;
if ($replacements === null) {
$replacements = [
'Pulpit' => '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;
+976
View File
@@ -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=<shared secret>
database=mysql
```
`/usr/local/directadmin/conf/alt-my.cnf`
```ini
[client]
user="da_admin"
password="<shared secret>"
```
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/<instance-id>.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/<MARIADB_VERSION>.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/<version>.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.
+10
View File
@@ -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
+93
View File
@@ -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
+8
View File
@@ -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
+238
View File
@@ -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
@@ -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 "$@"
@@ -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" <<JSON
{
"format": "da-alt-mysql-v1",
"created_at": "$(date -u '+%Y-%m-%dT%H:%M:%SZ')",
"da_user": $(json_string "$da_user"),
"mariadb_version": $(json_string "$version"),
"source": "alt-mariadb",
"source_instance": {
"service": $(json_string "$MYSQL_ALT_SERVICE_NAME"),
"basedir": $(json_string "$MYSQL_ALT_BASEDIR"),
"datadir": $(json_string "$MYSQL_ALT_DATADIR"),
"port": $(json_string "$MYSQL_ALT_PORT"),
"socket": $(json_string "$MYSQL_ALT_SOCKET")
},
"databases": [
$db_json
]
}
JSON
}
main() {
local da_user backup_root payload_dir db_dir grants_dir metadata_dir
local db db_file rel_file sha db_entries entry prefix where
da_user="$(detect_da_user "${1:-}")"
backup_root="$(detect_backup_root)"
payload_dir="$backup_root/backup/alt_mysql"
db_dir="$payload_dir/databases"
grants_dir="$payload_dir/grants"
metadata_dir="$payload_dir/metadata"
log "backup hook env: user=$da_user pwd=$(pwd -P) file=${file:-} filename=${filename:-} root=$backup_root"
mysql_load_alt_credentials
mysql_ensure_metadata_schema
if [ "$DRY_RUN" -eq 1 ]; then
printf 'Would write alt-mysql payload for %s into %s\n' "$da_user" "$payload_dir"
list_user_databases "$da_user"
return 0
fi
rm -rf "$payload_dir"
mkdir -p "$db_dir" "$grants_dir" "$metadata_dir"
db_entries=""
while IFS= read -r db; do
[ -n "$db" ] || continue
db_file="$db_dir/${db}.sql.gz"
mysqldump_exec \
--single-transaction \
--quick \
--skip-lock-tables \
--routines \
--triggers \
--events \
--default-character-set=utf8mb4 \
"$db" | gzip -9 > "$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 "$@"
@@ -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 "$@"
@@ -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
@@ -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 "$@"
+249
View File
@@ -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" <<SUDO
# DirectAdmin alt-mysql plugin
diradmin ALL=(root) NOPASSWD: /usr/local/directadmin/plugins/alt-mysql/scripts/setup/mysql_host_sync.sh
SUDO
chmod 440 "$SUDOERS_FILE"
}
bootstrap_worker_cron() {
if [ "$IS_ROOT" -ne 1 ]; then
echo "mysql: warning: uruchom jako root, aby dodać cron workera backup/restore" >&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' </dev/urandom | head -c 64 > "$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."
+872
View File
@@ -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 <<EOF
Użycie:
$0 <MARIADB_VERSION> <PORT>
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" <<EOF
[client]
port=$PORT
socket=$SOCKET
[mariadb]
user=$SYSTEM_USER
basedir=$MARIADB_DIR
datadir=$DATADIR
port=$PORT
socket=$SOCKET
pid-file=$RUN_DIR/alt-mariadb.pid
bind-address=$BIND_ADDRESS
skip-name-resolve
log-error=$LOG_DIR/alt-mariadb.log
plugin-dir=$MARIADB_DIR/lib/plugin
tmpdir=/tmp
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
max_connections=100
innodb_buffer_pool_size=512M
innodb_log_file_size=256M
local-infile=0
symbolic-links=0
EOF
log "Zapisano konfigurację alternatywnej instancji w $INSTANCE_CNF z prawem odczytu dla grupy $SYSTEM_GROUP."
}
credential_group() {
if getent group diradmin >/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" <<EOF
host=
passwd=$ALT_ADMIN_PASS
port=$PORT
socket=$SOCKET
user=$ALT_ADMIN_USER
EOF
write_file_atomic "$ALT_MY_CNF" "$credential_mode" root "$group_name" <<EOF
[client]
user="$cnf_user"
password="$cnf_pass"
protocol="socket"
socket="$cnf_socket"
EOF
log "Zapisano pliki poświadczeń DirectAdmin dla alternatywnej instancji bez wyświetlania hasła."
}
sh_single_quote() {
local value="$1"
value="${value//\'/\'\\\'\'}"
printf "'%s'" "$value"
}
write_alt_metadata_env() {
local group_name=""
local metadata_mode="0600"
group_name="$(credential_group)"
if [[ "$group_name" == "diradmin" ]]; then
metadata_mode="0640"
fi
backup_existing_file "$ALT_METADATA_ENV"
write_file_atomic "$ALT_METADATA_ENV" "$metadata_mode" root "$group_name" <<EOF
# Metadata alternatywnej instancji MariaDB zarządzanej przez install_db.sh.
# Ten plik jest kontraktem dla pluginu DirectAdmin i nie jest natywną konfiguracją MySQL DirectAdmina.
SERVICE_NAME=$(sh_single_quote "$SERVICE_NAME")
MARIADB_VERSION=$(sh_single_quote "$MARIADB_VERSION")
MARIADB_RELEASE=$(sh_single_quote "${RESOLVED_MARIADB_RELEASE:-$MARIADB_VERSION}")
MARIADB_DIR=$(sh_single_quote "$MARIADB_DIR")
DATADIR=$(sh_single_quote "$DATADIR")
PORT=$(sh_single_quote "$PORT")
SOCKET=$(sh_single_quote "$SOCKET")
ALT_MYSQL_CONF=$(sh_single_quote "$ALT_MYSQL_CONF")
ALT_MY_CNF=$(sh_single_quote "$ALT_MY_CNF")
INSTANCE_CNF=$(sh_single_quote "$INSTANCE_CNF")
SERVICE_FILE=$(sh_single_quote "$SERVICE_FILE")
LOG_DIR=$(sh_single_quote "$LOG_DIR")
RUN_DIR=$(sh_single_quote "$RUN_DIR")
SYSTEM_USER=$(sh_single_quote "$SYSTEM_USER")
SYSTEM_GROUP=$(sh_single_quote "$SYSTEM_GROUP")
BIND_ADDRESS=$(sh_single_quote "$BIND_ADDRESS")
EOF
log "Zapisano metadane alternatywnej instancji w $ALT_METADATA_ENV."
}
datadir_is_initialized() {
[[ -d "$DATADIR/mysql" ]] && [[ -f "$DATADIR/mysql/user.frm" || -f "$DATADIR/mysql/global_priv.frm" || -f "$DATADIR/mysql/global_priv.ibd" ]]
}
initialize_datadir() {
local install_db=""
if datadir_is_initialized; then
if [[ "$FORCE_REINIT" != "yes" ]]; then
log "Katalog danych wygląda na zainicjalizowany, pomijam inicjalizację."
return 0
fi
die "FORCE_REINIT=\"yes\" wymaga ręcznego opróżnienia lub zabezpieczenia katalogu $DATADIR. Skrypt nie usuwa danych automatycznie."
fi
if [[ -n "$(find "$DATADIR" -mindepth 1 -maxdepth 1 -print -quit)" ]] && [[ "$FORCE_REINIT" != "yes" ]]; then
die "Katalog $DATADIR zawiera pliki, ale nie wygląda na zainicjalizowany katalog MariaDB. Przerwano dla bezpieczeństwa."
fi
if [[ -x "$MARIADB_DIR/scripts/mariadb-install-db" ]]; then
install_db="$MARIADB_DIR/scripts/mariadb-install-db"
elif [[ -x "$MARIADB_DIR/bin/mariadb-install-db" ]]; then
install_db="$MARIADB_DIR/bin/mariadb-install-db"
else
die "Nie znaleziono mariadb-install-db w rozpakowanym katalogu MariaDB."
fi
log "Inicjalizuję katalog danych alternatywnej instancji MariaDB."
if ! "$install_db" \
--defaults-file="$INSTANCE_CNF" \
--basedir="$MARIADB_DIR" \
--datadir="$DATADIR" \
--user="$SYSTEM_USER" \
--auth-root-authentication-method=normal; then
warn "Inicjalizacja z auth-root-authentication-method=normal nie powiodła się, próbuję wariantu zgodności."
"$install_db" \
--defaults-file="$INSTANCE_CNF" \
--basedir="$MARIADB_DIR" \
--datadir="$DATADIR" \
--user="$SYSTEM_USER"
fi
chown -R "$SYSTEM_USER:$SYSTEM_GROUP" "$DATADIR"
}
write_systemd_service() {
if [[ -e "$SERVICE_FILE" && "$FORCE_SERVICE_REWRITE" != "yes" ]]; then
die "Plik usługi $SERVICE_FILE już istnieje. Ustaw FORCE_SERVICE_REWRITE=\"yes\", aby go odtworzyć po wykonaniu kopii."
fi
backup_existing_file "$SERVICE_FILE"
write_file_atomic "$SERVICE_FILE" 0644 root root <<EOF
[Unit]
Description=Alternatywna instancja MariaDB
After=network.target
[Service]
Type=simple
User=$SYSTEM_USER
Group=$SYSTEM_GROUP
RuntimeDirectory=alt-mariadb
RuntimeDirectoryMode=0750
ExecStart=$MARIADB_DIR/bin/mariadbd --defaults-file=$INSTANCE_CNF
Restart=on-failure
RestartSec=5
LimitNOFILE=65535
PrivateTmp=false
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
log "Zapisano i przeładowano usługę systemd $SERVICE_NAME."
}
print_service_diagnostics() {
warn "Usługa $SERVICE_NAME nie uruchomiła się poprawnie. Poniżej ostatnie wpisy diagnostyczne."
if [[ -f "$LOG_DIR/alt-mariadb.log" ]]; then
warn "Ostatnie linie z $LOG_DIR/alt-mariadb.log:"
tail -n 80 "$LOG_DIR/alt-mariadb.log" >&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 <<EOF
Instalacja alternatywnej instancji MariaDB zakończona.
Wersja MariaDB: ${RESOLVED_MARIADB_RELEASE:-$MARIADB_VERSION}
Katalog programu: $MARIADB_DIR
Katalog danych: $DATADIR
Port TCP: $PORT
Socket: $SOCKET
Konfiguracja instancji: $INSTANCE_CNF
Usługa systemd: $SERVICE_NAME
Plik usługi: $SERVICE_FILE
Logi: $LOG_DIR/alt-mariadb.log
Plik DirectAdmin alt-mysql.conf: $ALT_MYSQL_CONF
Plik klienta alt-my.cnf: $ALT_MY_CNF
Plik metadanych pluginu: $ALT_METADATA_ENV
Przykładowe polecenia testowe:
systemctl status $SERVICE_NAME --no-pager
$MARIADB_DIR/bin/mariadb --defaults-extra-file=$ALT_MY_CNF -e "SELECT VERSION();"
$MARIADB_DIR/bin/mariadb --defaults-extra-file=$ALT_MY_CNF --protocol=TCP --host=127.0.0.1 --port=$PORT -e "SELECT VERSION();"
Hasło administratora nie zostało wypisane. Znajduje się wyłącznie w chronionych plikach poświadczeń.
EOF
}
main() {
apply_cli_args "$@"
validate_version
validate_port
require_root
detect_os
load_da_credentials
validate_paths
install_dependencies
resolve_download_url
download_tarball
extract_tarball
prepare_user_and_dirs
write_instance_config
write_da_style_credential_files
write_alt_metadata_env
initialize_datadir
write_systemd_service
start_service
create_admin_user
run_final_tests
rerun_plugin_install_if_available
print_summary
}
main "$@"
+300
View File
@@ -0,0 +1,300 @@
#!/bin/bash
set -Eeuo pipefail
ERROR_MSG=""
RESTORE_OK=0
DA_USER=""
DB_NAME=""
JOB_ID=""
LOG_FILE=""
RESTORE_MODE=""
SOURCE_DB_NAME=""
SOURCE_FILE=""
RESTORE_ROLLBACK_FILE=""
RESTORE_ROLLBACK_CREATED=0
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 ] && [ "$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
+32
View File
@@ -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"
+126
View File
@@ -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" <<EOF
#!/bin/bash
# $MARKER
set -euo pipefail
PLUGIN_DIR='$PLUGIN_DIR'
LOG_DIR="\$PLUGIN_DIR/data/logs"
LOG_FILE="\$LOG_DIR/custombuild-${app}-repair.log"
mkdir -p "\$LOG_DIR" 2>/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
+124
View File
@@ -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
+52
View File
@@ -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 <plik.sql|plik.sql.gz> [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."
+99
View File
@@ -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."
+489
View File
@@ -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"
}
+376
View File
@@ -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 <<EOF
mysql: zsynchronizowano zdalne hosty MariaDB.
Port: $MYSQL_PORT
Bind address: $bind_address
Plik CSF include: $managed_allow
Liczba hostów: ${#hosts[@]}
EOF
}
main "$@"
+365
View File
@@ -0,0 +1,365 @@
#!/bin/bash
set -euo pipefail
# Upgrade alternatywnej instancji MariaDB instalowanej z oficjalnego tarballa.
TARGET_MARIADB_VERSION="${TARGET_MARIADB_VERSION:-11.8}"
MARIADB_DIR="${MARIADB_DIR:-/usr/local/mariadb-$TARGET_MARIADB_VERSION}"
DATADIR="${DATADIR:-/var/lib/mariadb_data}"
SERVICE_NAME="${SERVICE_NAME:-alt-mariadb}"
INSTANCE_CNF="${INSTANCE_CNF:-/etc/alt-mariadb.cnf}"
ALT_MY_CNF="${ALT_MY_CNF:-/usr/local/directadmin/conf/alt-my.cnf}"
ALT_MYSQL_CONF="${ALT_MYSQL_CONF:-/usr/local/directadmin/conf/alt-mysql.conf}"
ALT_METADATA_ENV="${ALT_METADATA_ENV:-/usr/local/directadmin/conf/alt-mariadb.env}"
DOWNLOAD_DIR="${DOWNLOAD_DIR:-/usr/local/src}"
MARIADB_TARBALL_URL="${MARIADB_TARBALL_URL:-}"
FORCE_UPGRADE="${FORCE_UPGRADE:-no}"
PORT="${PORT:-}"
SOCKET="${SOCKET:-}"
CURRENT_MARIADB_DIR=""
RESOLVED_MARIADB_RELEASE=""
DOWNLOAD_URL=""
TARBALL_PATH=""
TMP_DIR=""
TARGET_BACKUP_DIR=""
INSTANCE_CNF_BACKUP=""
UPGRADE_EXTRACTED=0
UPGRADE_COMMITTED=0
SERVICE_WAS_ACTIVE=0
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
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" <<EOF
# Metadata alternatywnej instancji MariaDB zarządzanej przez mysql_upgrade.sh.
# Ten plik jest kontraktem dla pluginu DirectAdmin i nie jest natywną konfiguracją MySQL DirectAdmina.
SERVICE_NAME=$(sh_single_quote "$SERVICE_NAME")
MARIADB_VERSION=$(sh_single_quote "$TARGET_MARIADB_VERSION")
MARIADB_RELEASE=$(sh_single_quote "${RESOLVED_MARIADB_RELEASE:-$TARGET_MARIADB_VERSION}")
MARIADB_DIR=$(sh_single_quote "$MARIADB_DIR")
DATADIR=$(sh_single_quote "$DATADIR")
PORT=$(sh_single_quote "$PORT")
SOCKET=$(sh_single_quote "$SOCKET")
ALT_MYSQL_CONF=$(sh_single_quote "$ALT_MYSQL_CONF")
ALT_MY_CNF=$(sh_single_quote "$ALT_MY_CNF")
INSTANCE_CNF=$(sh_single_quote "$INSTANCE_CNF")
EOF
install -m "$metadata_mode" -o root -g "$group_name" "$tmp_file" "$ALT_METADATA_ENV"
rm -f "$tmp_file"
log "Zaktualizowano metadane alternatywnej instancji: $ALT_METADATA_ENV."
}
print_summary() {
cat <<EOF
Upgrade alternatywnej instancji MariaDB zakończony.
Poprzedni katalog programu: $CURRENT_MARIADB_DIR
Nowy katalog programu: $MARIADB_DIR
Katalog danych: $DATADIR
Konfiguracja instancji: $INSTANCE_CNF
Usługa systemd: $SERVICE_NAME
Polecenie testowe:
$MARIADB_DIR/bin/mariadb --defaults-extra-file=$ALT_MY_CNF -e "SELECT VERSION();"
EOF
}
main() {
require_root
validate_version
detect_current_mariadb_dir
resolve_download_url
download_tarball
if systemctl is-active --quiet "$SERVICE_NAME"; then
SERVICE_WAS_ACTIVE=1
fi
systemctl stop "$SERVICE_NAME"
extract_tarball
rewrite_instance_basedir
restart_service
run_mariadb_upgrade
write_alt_metadata_env
UPGRADE_COMMITTED=1
print_summary
}
main "$@"
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -euo pipefail
PHPMYADMIN_DIR="${PHPMYADMIN_PRIVATE_DIR:-${PHPMYADMIN_DIR:-/var/www/html/alt-mysql-phpmyadmin}}"
CONFIG_INC="${PHPMYADMIN_DIR}/config.inc.php"
SIGNON_SCRIPT="${PHPMYADMIN_DIR}/da_signon.php"
SIGNON_URL_PAGE="${PHPMYADMIN_DIR}/da_login.php"
RUNTIME_CONFIG="${PHPMYADMIN_DIR}/runtime/config.json"
fail() {
printf 'phpMyAdmin alt-mysql health: %s\n' "$*" >&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"
+486
View File
@@ -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' </dev/urandom | head -c 64
printf '\n'
}
write_private_config() {
local secret
mkdir -p "$RUNTIME_DIR" "$TICKETS_DIR"
secret="$(generate_secret)"
printf '%s\n' "$secret" > "$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' '<!doctype html><title>Forbidden</title>' > "$RUNTIME_DIR/index.html"
printf '%s\n' '<!doctype html><title>Forbidden</title>' > "$TICKETS_DIR/index.html"
cat > "$CONFIG_INC" <<'PHP'
<?php
declare(strict_types=1);
const DA_MYSQL_PMA_DEFAULT_CONF = '/usr/local/directadmin/conf/alt-mysql.conf';
const DA_MYSQL_PMA_RUNTIME_CONFIG = __DIR__ . '/runtime/config.json';
const DA_MYSQL_PMA_BLOWFISH_SECRET_FILE = __DIR__ . '/runtime/blowfish_secret';
function da_mysql_phpmyadmin_runtime_config(): array
{
if (!is_readable(DA_MYSQL_PMA_RUNTIME_CONFIG)) {
return [];
}
$raw = file_get_contents(DA_MYSQL_PMA_RUNTIME_CONFIG);
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function da_mysql_phpmyadmin_parse_conf(string $path): array
{
$data = [
'host' => '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'
<?php
declare(strict_types=1);
const DA_MYSQL_SESSION = 'DA_MYSQL_PHPMYADMIN';
const DA_MYSQL_RUNTIME_CONFIG = __DIR__ . '/runtime/config.json';
function da_mysql_login_runtime_config(): array
{
if (!is_readable(DA_MYSQL_RUNTIME_CONFIG)) {
return [];
}
$raw = file_get_contents(DA_MYSQL_RUNTIME_CONFIG);
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function da_mysql_login_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_login_ticket_dir(array $runtimeConfig): string
{
$dir = trim((string)($runtimeConfig['ticket_dir'] ?? ''));
if ($dir !== '') {
return rtrim($dir, '/');
}
return __DIR__ . '/runtime/tickets';
}
function da_mysql_login_error(string $message): void
{
http_response_code(400);
header('Content-Type: text/html; charset=UTF-8');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
$safe = htmlspecialchars($message, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>alt-mysql phpMyAdmin</title></head><body>';
echo '<h1>alt-mysql phpMyAdmin</h1><p>' . $safe . '</p>';
echo '</body></html>';
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'
<?php
declare(strict_types=1);
const DA_MYSQL_SESSION = 'DA_MYSQL_PHPMYADMIN';
const DA_MYSQL_DEFAULT_CONF = '/usr/local/directadmin/conf/alt-mysql.conf';
const DA_MYSQL_RUNTIME_CONFIG = __DIR__ . '/runtime/config.json';
function da_mysql_runtime_config(): array
{
if (!is_readable(DA_MYSQL_RUNTIME_CONFIG)) {
return [];
}
$raw = file_get_contents(DA_MYSQL_RUNTIME_CONFIG);
if (!is_string($raw) || $raw === '') {
return [];
}
$decoded = json_decode($raw, true);
return is_array($decoded) ? $decoded : [];
}
function da_mysql_signon_session_auth(): array
{
$payload = $_SESSION['DA_MYSQL_PHPMYADMIN_AUTH'] ?? [];
return is_array($payload) ? $payload : [];
}
function da_mysql_signon_read_auth(): array
{
$previousActive = session_status() === PHP_SESSION_ACTIVE;
$previousName = session_name();
$previousId = $previousActive ? session_id() : '';
if ($previousActive && $previousName === DA_MYSQL_SESSION) {
return da_mysql_signon_session_auth();
}
if ($previousActive) {
session_write_close();
}
session_name(DA_MYSQL_SESSION);
session_id('');
$ssoSessionId = (string)($_COOKIE[DA_MYSQL_SESSION] ?? '');
if ($ssoSessionId !== '' && preg_match('/\A[A-Za-z0-9,-]+\z/', $ssoSessionId) === 1) {
session_id($ssoSessionId);
}
session_start();
$payload = da_mysql_signon_session_auth();
session_write_close();
if ($previousActive) {
session_name($previousName);
if ($previousId !== '') {
session_id($previousId);
}
session_start();
} elseif ($previousName !== '') {
session_name($previousName);
}
return $payload;
}
function get_login_credentials($user = ''): array
{
$payload = da_mysql_signon_read_auth();
$username = (string)($payload['user'] ?? '');
$password = (string)($payload['password'] ?? '');
return [$username, $password];
}
PHP
mkdir -p "$RUNTIME_DIR/tmp"
chown -R diradmin:diradmin "$RUNTIME_DIR" "$CONFIG_INC" "$SIGNON_SCRIPT" "$SIGNON_URL_PAGE" 2>/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}."
@@ -0,0 +1,5 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$SCRIPT_DIR/phpmyadmin_install.sh" "$@"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec "$SCRIPT_DIR/phpmyadmin_install.sh"
+36
View File
@@ -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."
+68
View File
@@ -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 <katalog_backupu> [--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
+186
View File
@@ -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/urandom | head -c 48
}
roundcube_load_or_create_password() {
local file user pass group_name tmp
file="$(roundcube_password_file)"
user="$(roundcube_db_user)"
if [ -r "$file" ]; then
pass="$(awk -F= '$1 == "password" || $1 == "passwd" { sub(/^[^=]*=/, ""); print; exit }' "$file")"
if [ -n "$pass" ]; then
printf '%s\n' "$pass"
return 0
fi
fi
pass="$(roundcube_generate_secret)"
group_name=""
if command -v getent >/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;
"
}
+38
View File
@@ -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"
@@ -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."
+79
View File
@@ -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" <<PHP
// BEGIN DIRECTADMIN MYSQL PLUGIN ROUNDCUBE
\$config['db_dsnw'] = '$dsn';
// END DIRECTADMIN MYSQL PLUGIN ROUNDCUBE
PHP
if [ -e "$target_file" ]; then
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
+106
View File
@@ -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."
+3
View File
@@ -0,0 +1,3 @@
#!/bin/bash
set -euo pipefail
"$(cd "$(dirname "$0")" && pwd)/install.sh"
+104
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
$pluginDir = dirname(__DIR__);
require_once $pluginDir . '/exec/lib/Settings.php';
require_once $pluginDir . '/exec/lib/DirectAdminUser.php';
function fail(string $message): void
{
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
fail($message);
}
}
function assert_same($expected, $actual, string $message): void
{
if ($expected !== $actual) {
fail($message . ' expected=' . var_export($expected, true) . ' actual=' . var_export($actual, true));
}
}
function load_settings(string $contents): Settings
{
$path = tempnam(sys_get_temp_dir(), 'altmysql-settings-');
if ($path === false) {
fail('cannot create temp settings file');
}
file_put_contents($path, $contents);
$settings = Settings::load($path);
unlink($path);
return $settings;
}
function make_da_user(Settings $settings, array $userConf, array $packageConf): DirectAdminUser
{
$class = new ReflectionClass(DirectAdminUser::class);
/** @var DirectAdminUser $user */
$user = $class->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";
+16
View File
@@ -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"
+42
View File
@@ -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"
+31
View File
@@ -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"
+35
View File
@@ -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"
+50
View File
@@ -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 '<MARIADB_VERSION> <PORT>' \
|| 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"
+75
View File
@@ -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" <<EOF
SERVICE_NAME='alt-mariadb-test'
MARIADB_DIR='$TMP_DIR/mariadb'
DATADIR='$TMP_DIR/data'
PORT='4407'
SOCKET='$TMP_DIR/data/mariadb.sock'
ALT_MYSQL_CONF='$TMP_DIR/alt-mysql.conf'
ALT_MY_CNF='$TMP_DIR/alt-my.cnf'
INSTANCE_CNF='$TMP_DIR/alt-mariadb.cnf'
EOF
cat > "$TMP_DIR/alt-mysql.conf" <<EOF
host=
passwd=secret
port=4407
socket=$TMP_DIR/data/mariadb.sock
user=da_admin
EOF
cat > "$TMP_DIR/alt-my.cnf" <<EOF
[client]
user="da_admin"
password="secret"
socket="$TMP_DIR/data/mariadb.sock"
EOF
cat > "$TMP_DIR/alt-mariadb.cnf" <<EOF
[mariadb]
basedir=$TMP_DIR/mariadb
datadir=$TMP_DIR/data
port=4407
socket=$TMP_DIR/data/mariadb.sock
EOF
MYSQL_PLUGIN_METADATA_FILE="$TMP_DIR/alt-mariadb.env"
MYSQL_PLUGIN_CREDENTIALS_FILE="$TMP_DIR/alt-mysql.conf"
MYSQL_PLUGIN_ALT_MY_CNF="$TMP_DIR/alt-my.cnf"
MYSQL_PLUGIN_ALT_INSTANCE_CNF="$TMP_DIR/alt-mariadb.cnf"
# shellcheck source=../scripts/setup/mysql_common.sh
source "$COMMON_FILE"
mysql_load_alt_runtime
[ "$MYSQL_ALT_SERVICE_NAME" = "alt-mariadb-test" ] || fail "service name not loaded"
[ "$MYSQL_ALT_BASEDIR" = "$TMP_DIR/mariadb" ] || fail "basedir not loaded"
[ "$MYSQL_ALT_DATADIR" = "$TMP_DIR/data" ] || fail "datadir not loaded"
[ "$MYSQL_ALT_PORT" = "4407" ] || fail "port not loaded"
[ "$MYSQL_ALT_SOCKET" = "$TMP_DIR/data/mariadb.sock" ] || fail "socket not loaded"
[ "$(mysql_alt_binary mysql)" = "$TMP_DIR/mariadb/bin/mariadb" ] || fail "mysql alias binary not detected"
[ "$(mysql_alt_binary mysqldump)" = "$TMP_DIR/mariadb/bin/mariadb-dump" ] || fail "mysqldump alias binary not detected"
mysql_load_alt_credentials
[ "$MYSQL_USER" = "da_admin" ] || fail "credential user not loaded"
[ "$MYSQL_PORT" = "4407" ] || fail "credential port not loaded"
[ "$MYSQL_SOCKET" = "$TMP_DIR/data/mariadb.sock" ] || fail "credential socket not loaded"
echo "mysql_common_runtime_test: OK"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SYNC_SCRIPT="$SCRIPT_DIR/scripts/setup/mysql_host_sync.sh"
fail() {
echo "FAIL: $*" >&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" <<EOF
host=localhost
port=33033
user=root
password=secret
database=mysql
socket=$tmpdir/mysql.sock
EOF
cat > "$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"
+23
View File
@@ -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"
+104
View File
@@ -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 '<MARIADB_VERSION> <PORT>' \
|| 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"
+149
View File
@@ -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'
<?php
$cfg = [];
PHP
mkdir -p "$TMP_DIR/phpMyAdmin-source"
cat > "$TMP_DIR/phpMyAdmin-source/index.php" <<'PHP'
<?php
echo 'private phpMyAdmin';
PHP
PHPMYADMIN_EXISTING_DIR="$TMP_DIR/phpMyAdmin" \
PHPMYADMIN_PRIVATE_DIR="$TMP_DIR/alt-mysql-phpmyadmin" \
PHPMYADMIN_SOURCE_DIR="$TMP_DIR/phpMyAdmin-source" \
PHPMYADMIN_INSTALL_ASSUME_ROOT=1 \
bash "$SCRIPT" >/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"
+52
View File
@@ -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"
+27
View File
@@ -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"
+71
View File
@@ -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'
<?php
$config = [];
PHP
cat > "$TMP_DIR/alt-mariadb.env" <<EOF
SERVICE_NAME='alt-mariadb-test'
MARIADB_DIR='$TMP_DIR/mariadb'
DATADIR='$TMP_DIR/data'
PORT='4407'
SOCKET='$TMP_DIR/data/mariadb.sock'
ALT_MYSQL_CONF='$TMP_DIR/alt-mysql.conf'
ALT_MY_CNF='$TMP_DIR/alt-my.cnf'
INSTANCE_CNF='$TMP_DIR/alt-mariadb.cnf'
EOF
cat > "$TMP_DIR/alt-mysql.conf" <<EOF
host=
passwd=secret
port=4407
socket=$TMP_DIR/data/mariadb.sock
user=da_admin
EOF
cat > "$TMP_DIR/plugin-settings.conf" <<EOF
MYSQL_PLUGIN_METADATA_FILE=$TMP_DIR/alt-mariadb.env
MYSQL_PLUGIN_CREDENTIALS_FILE=$TMP_DIR/alt-mysql.conf
MYSQL_PLUGIN_ALT_MY_CNF=$TMP_DIR/alt-my.cnf
ROUNDCUBE_ENABLED=true
ROUNDCUBE_DB_NAME=roundcube
ROUNDCUBE_DB_USER=roundcube
ROUNDCUBE_DB_PASSWORD_FILE=$TMP_DIR/alt-roundcube.conf
ROUNDCUBE_CONFIG_FILE=$TMP_DIR/roundcube/config.inc.php
ROUNDCUBE_CUSTOM_CONFIG_FILE=$TMP_DIR/custombuild/custom/roundcube/config.inc.php
ROUNDCUBE_WRITE_CUSTOMBUILD_CONFIG=true
ROUNDCUBE_ALT_DSN_MODE=tcp
EOF
ROUNDCUBE_SETTINGS_FILE="$TMP_DIR/plugin-settings.conf" bash "$SCRIPT" >/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"
+36
View File
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/exec/lib/Settings.php';
function assert_true(bool $condition, string $message): void
{
if (!$condition) {
fwrite(STDERR, "FAIL: {$message}\n");
exit(1);
}
}
$tmp = tempnam(sys_get_temp_dir(), 'mysql-plugin-settings-');
if ($tmp === false) {
fwrite(STDERR, "FAIL: cannot create temporary settings file\n");
exit(1);
}
file_put_contents($tmp, <<<CONF
allow_remote_hosts=1
modify_server_configuration=0
MYSQL_PLUGIN_ALLOW_REMOTE_HOSTS=0
MYSQL_PLUGIN_MODIFY_SERVER_CONFIGURATION=0
CONF);
$settings = Settings::load($tmp);
@unlink($tmp);
assert_true($settings->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";
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'download');
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', 'download');
require dirname(__DIR__) . '/exec/bootstrap.php';
+7
View File
@@ -0,0 +1,7 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('DA_MYSQL_RAW_MODE', true);
define('PLUGIN_ACTION', 'download');
require dirname(__DIR__) . '/exec/bootstrap.php';
+483
View File
@@ -0,0 +1,483 @@
:root {
--bg: #f5f8ff;
--panel: #ffffff;
--text: #1f2a37;
--muted: #5f6a7b;
--border: #d8dde6;
--amber: #b77200;
--amber-strong: #9a6200;
--danger: #d4491f;
--ok: #127b3b;
--ok-bg: #e8f7ec;
--ok-left: #147f3e;
--warn-bg: #fff4eb;
--shadow: 0 10px 28px rgba(15, 23, 42, 0.12);
}
* { box-sizing: border-box; }
html {
overflow-y: scroll;
scrollbar-gutter: stable;
}
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 16px;
overflow-y: scroll;
}
#content,
#content .container,
#content .container-fluid,
#content .main-content,
#content .page-content,
.main-content,
.page-content {
max-width: none !important;
width: 100% !important;
}
.wrap { width: 100%; margin: 0 auto; padding: 16px 20px; }
.breadcrumbs { color: #7f8896; font-size: 13px; margin-bottom: 8px; }
.breadcrumbs span { margin: 0 6px; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; }
header h1 { margin: 0; font-size: 38px; font-weight: 600; letter-spacing: -0.2px; }
header p { margin: 0; color: var(--amber-strong); font-size: 15px; }
.header-center { justify-content: center; text-align: center; }
.header-center h1 { width: 100%; text-align: center; }
.top-actions { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 12px; flex-wrap: wrap; }
.top-actions .btn { text-transform: uppercase; }
.dropzone {
border: 2px dashed #cfd6e3;
border-radius: 12px;
padding: 16px;
text-align: center;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.dropzone.is-dragover {
border-color: var(--amber-strong);
background: #fff7e6;
}
.dropzone.is-filled .dropzone-placeholder { display: none; }
.dropzone .dropzone-info { display: none; }
.dropzone.is-filled .dropzone-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.dropzone-title { font-weight: 700; color: #1f2d3d; }
.dropzone-subtitle { color: #6b7280; font-size: 14px; margin-top: 4px; }
.dropzone-input { display: none; }
.dropzone-meta { display: grid; gap: 4px; }
.main-section { display: block; }
.logs-section { margin-top: 16px; }
.logs-section:empty { display: none; }
.alert { padding: 12px 14px; border-radius: 10px; margin: 8px 0 12px; border: 1px solid; font-size: 15px; white-space: pre-line; }
.alert-ok { background: var(--ok-bg); border-color: #a8ddb8; color: #12562b; }
.alert-err { background: #fff0ed; border-color: #f3c0b4; color: #8b2b18; }
.grid { display: grid; gap: 12px; }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.panel { border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--panel); box-shadow: var(--shadow); margin-bottom: 14px; }
.panel h3 { margin: 0 0 10px; font-size: 22px; font-weight: 700; letter-spacing: -0.1px; }
.muted { color: var(--muted); }
form { margin: 0; }
label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 15px; }
input[type="text"], input[type="password"], select, textarea {
width: 100%; border: 1px solid #cdd4e0; border-radius: 6px; padding: 10px 12px; font-size: 15px; background: #fff;
}
.input-with-tools { position: relative; }
.input-with-tools input[type="text"], .input-with-tools input[type="password"] { padding-right: 94px; }
.field-tools { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); display: inline-flex; gap: 4px; }
.tool-btn {
border: 1px solid #cbd5e4;
background: #fff;
color: #4a5566;
width: 32px;
height: 28px;
border-radius: 6px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tool-btn:hover { background: #f6f8fb; border-color: #b9c7db; }
.tool-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
textarea { min-height: 90px; resize: vertical; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
.file-source-mode { margin: 0 0 12px; }
.file-source-options { display: flex; flex-wrap: wrap; gap: 8px; }
.file-source-option {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
background: #fff;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.file-source-option:hover { border-color: var(--amber); background: #fff7ec; }
.file-source-option input { margin: 0; accent-color: var(--amber); }
.file-source-panel { margin-bottom: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
button, .btn {
border: 1px solid #b7c6da; background: #fff; color: #1f2d3d; border-radius: 999px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; gap: 6px;
}
button.primary, .btn.primary { background: var(--amber); border-color: var(--amber); color: #fff; }
button.primary:hover, .btn.primary:hover { background: var(--amber-strong); border-color: var(--amber-strong); }
.btn.amber, button.amber { background: var(--amber); border-color: var(--amber); color: #fff; }
.btn.amber:hover, button.amber:hover { background: var(--amber-strong); border-color: var(--amber-strong); }
button.danger, .btn.danger { border-color: var(--danger); color: var(--danger); background: #fff; }
.btn.danger-fill, button.danger-fill { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.warn, button.warn { background: #f2c94c; border-color: #f2c94c; color: #000; }
.btn.success, button.success { background: var(--ok); border-color: var(--ok); color: #fff; }
.btn.success:hover, button.success:hover { background: #0f6a33; border-color: #0f6a33; }
.btn.adminer-login, button.adminer-login { background: #0b3c82; border-color: #0b3c82; color: #fff; }
.btn.adminer-login:hover, button.adminer-login:hover { background: #082d63; border-color: #082d63; }
.btn.disabled, button:disabled { opacity: 0.55; cursor: default; pointer-events: none; }
button:hover, .btn:hover { filter: brightness(0.99); }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 15px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: #fff; }
th, td { border-bottom: 1px solid var(--border); padding: 10px 12px; text-align: left; vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
th { background: #f6f8fb; color: #4a5566; font-weight: 600; }
.badge { display: inline-block; background: #fce2b7; color: #7b4b00; border: 1px solid #efc57f; border-radius: 5px; padding: 2px 8px; font-size: 13px; }
.inline { display: inline-flex; align-items: center; gap: 6px; margin-right: 10px; margin-bottom: 8px; font-weight: 500; }
.list-clean { margin: 0; padding-left: 18px; }
.section-title { font-size: 33px; margin: 14px 0 10px; font-weight: 700; }
.section-title-center { text-align: center; }
.section-text { color: var(--muted); margin: 0 0 12px; }
.success-credentials { border: 1px solid #bfdcc6; background: var(--ok-bg); border-radius: 12px; display: grid; grid-template-columns: 64px 1fr; overflow: hidden; margin-bottom: 14px; }
.success-credentials .left { background: var(--ok-left); display:flex; align-items:center; justify-content:center; color:#fff; font-size:26px; }
.success-credentials .right { padding: 14px 16px; }
.success-credentials h4 { margin: 0 0 8px; font-size: 24px; }
.kv { display: grid; grid-template-columns: 220px 1fr; gap: 6px 10px; font-size: 15px; }
.kv code { background: transparent; border: 0; padding: 0; font-size: 15px; color: #1d2530; }
.details-advanced { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; }
.details-advanced summary { cursor: pointer; color: #374357; font-weight: 600; padding: 6px 0; }
.input-prefix { display: grid; grid-template-columns: auto 1fr; }
.input-prefix .prefix { border: 1px solid #cdd4e0; border-right: 0; padding: 10px 12px; border-radius: 6px 0 0 6px; background: #f8fafd; color: #566176; }
.input-prefix input { border-radius: 0 6px 6px 0; }
.db-choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 6px; }
.db-choice-card {
border: 1px solid #cdd4e0;
border-radius: 10px;
padding: 8px 10px;
background: #f8fafc;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.db-choice-card:hover { border-color: #b7c6da; background: #fff; }
.db-choice-card:focus-within { border-color: var(--amber); box-shadow: 0 0 0 2px rgba(183, 114, 0, 0.15); background: #fff; }
.db-choice-card input { margin: 0; accent-color: var(--amber); }
.db-choice-card span { font-weight: 600; color: #2b3445; word-break: break-all; }
.db-choice-card input:disabled + span { color: #9aa3b2; }
.restore-target-per-row { min-width: 240px; }
td.actions form { display: inline-flex; }
.br-restore-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
}
.br-restore-calendar {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.br-restore-calendar h4 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 700;
}
.br-month-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.br-month-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: #3a4655;
cursor: pointer;
}
.br-month-btn:hover { border-color: var(--amber); }
.br-month-btn:disabled { opacity: 0.4; cursor: default; }
.br-month-label { font-weight: 600; color: #1f2a37; }
.br-calendar-grid-wrap { margin: 0; }
.br-calendar-grid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 0;
background: transparent;
border-radius: 0;
overflow: visible;
}
.br-calendar-grid th {
padding: 4px;
text-align: center;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--muted);
}
.br-calendar-grid td {
padding: 4px;
text-align: center;
border-bottom: 0;
}
.br-cal-day {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.br-cal-day:hover {
border-color: var(--amber);
background: #fff7ec;
}
.br-cal-day.is-selected {
background: var(--amber);
color: #fff;
border-color: var(--amber);
}
.br-cal-day-disabled {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: #a0a7b4;
}
.br-cal-empty { background: transparent; }
.br-restore-picker { min-width: 0; }
.br-user-backup-picker { min-width: 0; }
.br-date-label {
font-weight: 600;
color: #1f2a37;
margin: 6px 0 10px;
}
.br-time-picker { margin: 8px 0 14px; }
.br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); }
.br-time-list { display: flex; flex-wrap: wrap; gap: 8px; }
.br-time-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
cursor: pointer;
font-size: 13px;
}
.br-time-item input { margin: 0; accent-color: var(--amber); }
.privileges-group { display: flex; flex-wrap: wrap; gap: 4px 10px; }
body.overlay-open { overflow: hidden; }
.job-overlay { position: fixed; inset: 0; display: none; z-index: 9999; }
.job-overlay.open { display: block; }
.job-overlay-backdrop { position: absolute; inset: 0; background: rgba(24, 31, 44, 0.55); }
.job-overlay-card {
position: relative;
width: min(920px, calc(100vw - 32px));
max-height: calc(100vh - 50px);
overflow: auto;
margin: 24px auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
box-shadow: 0 16px 48px rgba(18, 28, 45, 0.35);
}
.job-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.job-overlay-head h3 { margin: 0; font-size: 22px; }
.job-overlay-head-actions { display: inline-flex; align-items: center; gap: 8px; }
.log-overlay .log-meta {
display: grid;
gap: 6px;
margin: 10px 0 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #f8fafc;
font-size: 13px;
}
.log-overlay .log-content {
margin: 0;
padding: 12px;
border-radius: 10px;
border: 1px solid #1f2a44;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre;
max-height: 50vh;
overflow: auto;
}
.br-text-danger { color: var(--danger); font-weight: 700; }
.br-modal-danger { border-color: var(--danger); }
.br-modal-danger h3 { color: var(--danger); }
.br-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.br-modal {
width: min(560px, 92vw);
background: #ffffff;
border-radius: 12px;
padding: 16px;
border: 2px solid var(--border);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
max-height: 90vh;
}
.br-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.br-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.br-modal-title {
font-weight: 700;
font-size: 16px;
}
.br-modal-body {
max-height: 60vh;
overflow: auto;
}
.br-muted { color: var(--muted); }
.br-alert {
border-radius: 8px;
padding: 10px 12px;
margin: 8px 0;
border: 1px solid transparent;
}
.br-alert-error { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
.br-picker-path {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.br-picker-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 10px;
max-height: 45vh;
overflow: auto;
}
.br-picker-create {
margin-top: 10px;
display: flex;
gap: 8px;
}
.br-picker-create input {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
}
.br-picker-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.br-picker-list li:last-child {
border-bottom: none;
}
.br-picker-list li:hover {
background: #f8fafc;
}
.br-picker-list li.active {
background: #d1fae5;
}
.br-btn {
background: var(--amber);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn:hover { background: var(--amber-strong); }
.br-btn-ghost {
background: transparent;
color: #1f2d3d;
border: 1px solid var(--border);
}
.br-btn-ghost:hover { background: #eef2f7; }
.br-btn-danger {
background: var(--danger);
border: 1px solid var(--danger);
color: #fff;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn-danger:hover { background: #c03a15; border-color: #c03a15; }
.br-btn-small {
padding: 6px 10px;
font-size: 12px;
}
.br-icon-btn {
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.br-icon-btn:hover {
background: #f8fafc;
}
@media (max-width: 900px) {
.wrap { padding: 10px; }
header h1 { font-size: 32px; }
.section-title { font-size: 28px; }
.kv { grid-template-columns: 1fr; }
.br-restore-layout { grid-template-columns: 1fr; }
}
+483
View File
@@ -0,0 +1,483 @@
:root {
--bg: #f4f6f8;
--panel: #ffffff;
--text: #1f2a37;
--muted: #6b7280;
--border: #d9dee5;
--amber: #b77200;
--amber-strong: #9a6200;
--danger: #d4491f;
--ok: #127b3b;
--ok-bg: #e8f7ec;
--ok-left: #147f3e;
--warn-bg: #fff4eb;
--shadow: 0 8px 24px rgba(31, 41, 55, 0.12);
}
* { box-sizing: border-box; }
html {
overflow-y: scroll;
scrollbar-gutter: stable;
}
body {
margin: 0;
font-family: "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 16px;
overflow-y: scroll;
}
#content,
#content .container,
#content .container-fluid,
#content .main-content,
#content .page-content,
.main-content,
.page-content {
max-width: none !important;
width: 100% !important;
}
.wrap { width: 100%; margin: 0 auto; padding: 16px 20px; }
.breadcrumbs { color: #7f8896; font-size: 13px; margin-bottom: 8px; }
.breadcrumbs span { margin: 0 6px; }
header { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; }
header h1 { margin: 0; font-size: 38px; font-weight: 600; letter-spacing: -0.2px; }
header p { margin: 0; color: var(--amber-strong); font-size: 15px; }
.header-center { justify-content: center; text-align: center; }
.header-center h1 { width: 100%; text-align: center; }
.top-actions { display: flex; gap: 8px; justify-content: flex-end; margin-bottom: 12px; flex-wrap: wrap; }
.top-actions .btn { text-transform: uppercase; }
.dropzone {
border: 2px dashed #cfd6e3;
border-radius: 12px;
padding: 16px;
text-align: center;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease;
}
.dropzone.is-dragover {
border-color: var(--amber-strong);
background: #fff7e6;
}
.dropzone.is-filled .dropzone-placeholder { display: none; }
.dropzone .dropzone-info { display: none; }
.dropzone.is-filled .dropzone-info {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.dropzone-title { font-weight: 700; color: #1f2d3d; }
.dropzone-subtitle { color: #6b7280; font-size: 14px; margin-top: 4px; }
.dropzone-input { display: none; }
.dropzone-meta { display: grid; gap: 4px; }
.main-section { display: block; }
.logs-section { margin-top: 16px; }
.logs-section:empty { display: none; }
.alert { padding: 12px 14px; border-radius: 10px; margin: 8px 0 12px; border: 1px solid; font-size: 15px; white-space: pre-line; }
.alert-ok { background: var(--ok-bg); border-color: #a8ddb8; color: #12562b; }
.alert-err { background: #fff0ed; border-color: #f3c0b4; color: #8b2b18; }
.grid { display: grid; gap: 12px; }
.grid-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
.panel { border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--panel); box-shadow: var(--shadow); margin-bottom: 14px; }
.panel h3 { margin: 0 0 10px; font-size: 22px; font-weight: 700; letter-spacing: -0.1px; }
.muted { color: var(--muted); }
form { margin: 0; }
label { display: block; margin-bottom: 6px; font-weight: 600; font-size: 15px; }
input[type="text"], input[type="password"], select, textarea {
width: 100%; border: 1px solid #cdd4e0; border-radius: 6px; padding: 10px 12px; font-size: 15px; background: #fff;
}
.input-with-tools { position: relative; }
.input-with-tools input[type="text"], .input-with-tools input[type="password"] { padding-right: 94px; }
.field-tools { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); display: inline-flex; gap: 4px; }
.tool-btn {
border: 1px solid #cbd5e4;
background: #fff;
color: #4a5566;
width: 32px;
height: 28px;
border-radius: 6px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.tool-btn:hover { background: #f6f8fb; border-color: #b9c7db; }
.tool-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
textarea { min-height: 90px; resize: vertical; }
.row { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; margin-bottom: 12px; }
.file-source-mode { margin: 0 0 12px; }
.file-source-options { display: flex; flex-wrap: wrap; gap: 8px; }
.file-source-option {
display: inline-flex;
align-items: center;
gap: 8px;
margin: 0;
border: 1px solid var(--border);
border-radius: 999px;
padding: 8px 12px;
background: #fff;
cursor: pointer;
font-weight: 600;
font-size: 14px;
}
.file-source-option:hover { border-color: var(--amber); background: #fff7ec; }
.file-source-option input { margin: 0; accent-color: var(--amber); }
.file-source-panel { margin-bottom: 12px; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; align-items: center; }
button, .btn {
border: 1px solid #b7c6da; background: #fff; color: #1f2d3d; border-radius: 999px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 13px; line-height: 1; display: inline-flex; align-items: center; gap: 6px;
}
button.primary, .btn.primary { background: var(--amber); border-color: var(--amber); color: #fff; }
button.primary:hover, .btn.primary:hover { background: var(--amber-strong); border-color: var(--amber-strong); }
.btn.amber, button.amber { background: var(--amber); border-color: var(--amber); color: #fff; }
.btn.amber:hover, button.amber:hover { background: var(--amber-strong); border-color: var(--amber-strong); }
button.danger, .btn.danger { border-color: var(--danger); color: var(--danger); background: #fff; }
.btn.danger-fill, button.danger-fill { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn.warn, button.warn { background: #f2c94c; border-color: #f2c94c; color: #000; }
.btn.success, button.success { background: var(--ok); border-color: var(--ok); color: #fff; }
.btn.success:hover, button.success:hover { background: #0f6a33; border-color: #0f6a33; }
.btn.adminer-login, button.adminer-login { background: #0b3c82; border-color: #0b3c82; color: #fff; }
.btn.adminer-login:hover, button.adminer-login:hover { background: #082d63; border-color: #082d63; }
.btn.disabled, button:disabled { opacity: 0.55; cursor: default; pointer-events: none; }
button:hover, .btn:hover { filter: brightness(0.99); }
table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 15px; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: #fff; }
th, td { border-bottom: 1px solid var(--border); padding: 10px 12px; text-align: left; vertical-align: top; }
tbody tr:last-child td { border-bottom: 0; }
th { background: #f6f8fb; color: #4a5566; font-weight: 600; }
.badge { display: inline-block; background: #fce2b7; color: #7b4b00; border: 1px solid #efc57f; border-radius: 5px; padding: 2px 8px; font-size: 13px; }
.inline { display: inline-flex; align-items: center; gap: 6px; margin-right: 10px; margin-bottom: 8px; font-weight: 500; }
.list-clean { margin: 0; padding-left: 18px; }
.section-title { font-size: 33px; margin: 14px 0 10px; font-weight: 700; }
.section-title-center { text-align: center; }
.section-text { color: var(--muted); margin: 0 0 12px; }
.success-credentials { border: 1px solid #bfdcc6; background: var(--ok-bg); border-radius: 12px; display: grid; grid-template-columns: 64px 1fr; overflow: hidden; margin-bottom: 14px; }
.success-credentials .left { background: var(--ok-left); display:flex; align-items:center; justify-content:center; color:#fff; font-size:26px; }
.success-credentials .right { padding: 14px 16px; }
.success-credentials h4 { margin: 0 0 8px; font-size: 24px; }
.kv { display: grid; grid-template-columns: 220px 1fr; gap: 6px 10px; font-size: 15px; }
.kv code { background: transparent; border: 0; padding: 0; font-size: 15px; color: #1d2530; }
.details-advanced { margin-top: 10px; border-top: 1px solid var(--border); padding-top: 10px; }
.details-advanced summary { cursor: pointer; color: #374357; font-weight: 600; padding: 6px 0; }
.input-prefix { display: grid; grid-template-columns: auto 1fr; }
.input-prefix .prefix { border: 1px solid #cdd4e0; border-right: 0; padding: 10px 12px; border-radius: 6px 0 0 6px; background: #f8fafd; color: #566176; }
.input-prefix input { border-radius: 0 6px 6px 0; }
.db-choice-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 8px; margin-top: 6px; }
.db-choice-card {
border: 1px solid #cdd4e0;
border-radius: 10px;
padding: 8px 10px;
background: #f8fafc;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.db-choice-card:hover { border-color: #b7c6da; background: #fff; }
.db-choice-card:focus-within { border-color: var(--amber); box-shadow: 0 0 0 2px rgba(183, 114, 0, 0.15); background: #fff; }
.db-choice-card input { margin: 0; accent-color: var(--amber); }
.db-choice-card span { font-weight: 600; color: #2b3445; word-break: break-all; }
.db-choice-card input:disabled + span { color: #9aa3b2; }
.restore-target-per-row { min-width: 240px; }
td.actions form { display: inline-flex; }
.br-restore-layout {
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
align-items: start;
}
.br-restore-calendar {
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
background: #f8fafc;
}
.br-restore-calendar h4 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 700;
}
.br-month-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.br-month-btn {
width: 32px;
height: 32px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
color: #3a4655;
cursor: pointer;
}
.br-month-btn:hover { border-color: var(--amber); }
.br-month-btn:disabled { opacity: 0.4; cursor: default; }
.br-month-label { font-weight: 600; color: #1f2a37; }
.br-calendar-grid-wrap { margin: 0; }
.br-calendar-grid {
width: 100%;
border-collapse: collapse;
font-size: 13px;
border: 0;
background: transparent;
border-radius: 0;
overflow: visible;
}
.br-calendar-grid th {
padding: 4px;
text-align: center;
border-bottom: 1px solid var(--border);
background: transparent;
color: var(--muted);
}
.br-calendar-grid td {
padding: 4px;
text-align: center;
border-bottom: 0;
}
.br-cal-day {
width: 30px;
height: 30px;
border-radius: 8px;
border: 1px solid var(--border);
background: #fff;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.br-cal-day:hover {
border-color: var(--amber);
background: #fff7ec;
}
.br-cal-day.is-selected {
background: var(--amber);
color: #fff;
border-color: var(--amber);
}
.br-cal-day-disabled {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: #a0a7b4;
}
.br-cal-empty { background: transparent; }
.br-restore-picker { min-width: 0; }
.br-user-backup-picker { min-width: 0; }
.br-date-label {
font-weight: 600;
color: #1f2a37;
margin: 6px 0 10px;
}
.br-time-picker { margin: 8px 0 14px; }
.br-time-title { font-weight: 600; margin-bottom: 6px; color: var(--muted); }
.br-time-list { display: flex; flex-wrap: wrap; gap: 8px; }
.br-time-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
cursor: pointer;
font-size: 13px;
}
.br-time-item input { margin: 0; accent-color: var(--amber); }
.privileges-group { display: flex; flex-wrap: wrap; gap: 4px 10px; }
body.overlay-open { overflow: hidden; }
.job-overlay { position: fixed; inset: 0; display: none; z-index: 9999; }
.job-overlay.open { display: block; }
.job-overlay-backdrop { position: absolute; inset: 0; background: rgba(24, 31, 44, 0.55); }
.job-overlay-card {
position: relative;
width: min(920px, calc(100vw - 32px));
max-height: calc(100vh - 50px);
overflow: auto;
margin: 24px auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
box-shadow: 0 16px 48px rgba(18, 28, 45, 0.35);
}
.job-overlay-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
.job-overlay-head h3 { margin: 0; font-size: 22px; }
.job-overlay-head-actions { display: inline-flex; align-items: center; gap: 8px; }
.log-overlay .log-meta {
display: grid;
gap: 6px;
margin: 10px 0 12px;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #f8fafc;
font-size: 13px;
}
.log-overlay .log-content {
margin: 0;
padding: 12px;
border-radius: 10px;
border: 1px solid #1f2a44;
background: #0f172a;
color: #e2e8f0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.4;
white-space: pre;
max-height: 50vh;
overflow: auto;
}
.br-text-danger { color: var(--danger); font-weight: 700; }
.br-modal-danger { border-color: var(--danger); }
.br-modal-danger h3 { color: var(--danger); }
.br-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.br-modal {
width: min(560px, 92vw);
background: #ffffff;
border-radius: 12px;
padding: 16px;
border: 2px solid var(--border);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
max-height: 90vh;
}
.br-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.br-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.br-modal-title {
font-weight: 700;
font-size: 16px;
}
.br-modal-body {
max-height: 60vh;
overflow: auto;
}
.br-muted { color: var(--muted); }
.br-alert {
border-radius: 8px;
padding: 10px 12px;
margin: 8px 0;
border: 1px solid transparent;
}
.br-alert-error { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
.br-picker-path {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
flex-wrap: wrap;
}
.br-picker-list {
list-style: none;
padding: 0;
margin: 0;
border: 1px solid var(--border);
border-radius: 10px;
max-height: 45vh;
overflow: auto;
}
.br-picker-create {
margin-top: 10px;
display: flex;
gap: 8px;
}
.br-picker-create input {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 8px;
}
.br-picker-list li {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border);
}
.br-picker-list li:last-child {
border-bottom: none;
}
.br-picker-list li:hover {
background: #f8fafc;
}
.br-picker-list li.active {
background: #e0f2fe;
}
.br-btn {
background: var(--amber);
color: #fff;
border: none;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn:hover { background: var(--amber-strong); }
.br-btn-ghost {
background: transparent;
color: #1f2d3d;
border: 1px solid var(--border);
}
.br-btn-ghost:hover { background: #eef2f7; }
.br-btn-danger {
background: var(--danger);
border: 1px solid var(--danger);
color: #fff;
border-radius: 6px;
padding: 8px 14px;
cursor: pointer;
font-weight: 600;
}
.br-btn-danger:hover { background: #c03a15; border-color: #c03a15; }
.br-btn-small {
padding: 6px 10px;
font-size: 12px;
}
.br-icon-btn {
border: 1px solid var(--border);
background: #fff;
border-radius: 8px;
padding: 4px 8px;
cursor: pointer;
font-size: 16px;
line-height: 1;
}
.br-icon-btn:hover {
background: #f8fafc;
}
@media (max-width: 900px) {
.wrap { padding: 10px; }
header h1 { font-size: 32px; }
.section-title { font-size: 28px; }
.kv { grid-template-columns: 1fr; }
.br-restore-layout { grid-template-columns: 1fr; }
}
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+7
View File
@@ -0,0 +1,7 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.raw'));
define('DA_MYSQL_RAW_MODE', true);
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+6
View File
@@ -0,0 +1,6 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.html'));
require dirname(__DIR__) . '/exec/bootstrap.php';
+7
View File
@@ -0,0 +1,7 @@
#!/usr/local/bin/php -nc/usr/local/directadmin/plugins/alt-mysql/php.ini
<?php
declare(strict_types=1);
define('IN_DA_PLUGIN', true);
define('PLUGIN_ACTION', basename(__FILE__, '.raw'));
define('DA_MYSQL_RAW_MODE', true);
require dirname(__DIR__) . '/exec/bootstrap.php';

Some files were not shown because too many files have changed in this diff Show More