Files
2026-07-04 15:48:19 +02:00

334 lines
12 KiB
PHP

<?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;
}
}