Import alt-mysql plugin
This commit is contained in:
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user