fc41e1801a
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 26s
FolderPickerField: shows resolved placeholder values below input as badges (e.g. [HOME]=/home/user, [host]=example.com), plus full resolved path. Updates live as user types. BackupsController::start(): accept CSRF token from both GET and POST so the "Run Backup Now" link button on profile edit works without triggering "security token did not match" error.
457 lines
17 KiB
PHP
457 lines
17 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\Form\FormField;
|
|
use Joomla\CMS\Language\Text;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
class FolderPickerField extends FormField
|
|
{
|
|
protected $type = 'FolderPicker';
|
|
|
|
protected function getInput(): string
|
|
{
|
|
$value = htmlspecialchars($this->value ?: $this->default, ENT_QUOTES, 'UTF-8');
|
|
$id = htmlspecialchars($this->id, ENT_QUOTES, 'UTF-8');
|
|
$name = htmlspecialchars($this->name, ENT_QUOTES, 'UTF-8');
|
|
$jRoot = JPATH_ROOT;
|
|
|
|
// Resolve to absolute for display
|
|
$rawValue = $this->value ?: $this->default;
|
|
|
|
if ($rawValue && $rawValue[0] !== '/') {
|
|
$absPath = $jRoot . '/' . $rawValue;
|
|
} else {
|
|
$absPath = $rawValue;
|
|
}
|
|
|
|
// Build placeholder map for JS resolution
|
|
$hostname = preg_replace('/[^a-zA-Z0-9._-]/', '', $_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? php_uname('n'));
|
|
$siteName = '';
|
|
|
|
try {
|
|
$siteName = Factory::getApplication()->get('sitename', '');
|
|
} catch (\Throwable $e) {
|
|
// fallback
|
|
}
|
|
|
|
$sanitizedSiteName = preg_replace('/[^a-zA-Z0-9._-]/', '', str_replace(' ', '-', trim($siteName)));
|
|
|
|
$placeholders = [
|
|
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
|
|
'[HOME]' => BackupDirectory::getHomeDirectory(),
|
|
'[host]' => $hostname,
|
|
'[site_name]' => $sanitizedSiteName ?: 'joomla',
|
|
'[profile_id]' => '1',
|
|
'[profile_name]' => 'default',
|
|
'[type]' => 'full',
|
|
'[year]' => date('Y'),
|
|
'[month]' => date('m'),
|
|
'[day]' => date('d'),
|
|
'[date]' => date('Ymd'),
|
|
];
|
|
|
|
$placeholdersJson = json_encode($placeholders);
|
|
|
|
// Resolve placeholders for the status display
|
|
$resolvedPath = str_replace(array_keys($placeholders), array_values($placeholders), $absPath);
|
|
$hasPlaceholders = preg_match('/\[.+\]/', $absPath);
|
|
|
|
if ($hasPlaceholders) {
|
|
$exists = is_dir($resolvedPath);
|
|
$statusClass = $exists ? 'text-success' : 'text-info';
|
|
$statusIcon = $exists ? 'icon-publish' : 'icon-info-circle';
|
|
$statusText = Text::_('COM_MOKOJOOMBACKUP_FOLDER_PLACEHOLDER');
|
|
$resolvedSafe = htmlspecialchars($resolvedPath, ENT_QUOTES, 'UTF-8');
|
|
$statusDetail = "{$statusText}: <code>{$resolvedSafe}</code>";
|
|
} else {
|
|
$exists = is_dir($absPath);
|
|
$statusClass = $exists ? 'text-success' : 'text-danger';
|
|
$statusIcon = $exists ? 'icon-publish' : 'icon-unpublish';
|
|
$statusText = $exists
|
|
? Text::_('COM_MOKOJOOMBACKUP_FOLDER_EXISTS')
|
|
: Text::_('COM_MOKOJOOMBACKUP_FOLDER_NOT_FOUND');
|
|
$absPathSafe = htmlspecialchars($absPath, ENT_QUOTES, 'UTF-8');
|
|
$statusDetail = "{$statusText}: <code>{$absPathSafe}</code>";
|
|
}
|
|
|
|
return <<<HTML
|
|
<div class="input-group">
|
|
<input type="text" name="{$name}" id="{$id}" value="{$value}"
|
|
class="form-control" maxlength="512"
|
|
placeholder="[HOME]/backups or [DEFAULT_DIR]" />
|
|
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
|
|
<span class="icon-folder-open" aria-hidden="true"></span>
|
|
Browse
|
|
</button>
|
|
<button type="button" class="btn btn-outline-info" data-bs-toggle="modal" data-bs-target="#{$id}_helpModal" title="Available placeholders">
|
|
<span class="icon-question-circle" aria-hidden="true"></span>
|
|
</button>
|
|
</div>
|
|
<div class="mt-1 mb-1" id="{$id}_placeholders" style="display:flex; flex-wrap:wrap; gap:4px;">
|
|
<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[HOME]" title="Home directory">[HOME]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[DEFAULT_DIR]" title="Default backup dir">[DEFAULT_DIR]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[host]" title="Server hostname">[host]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[site_name]" title="Joomla site name">[site_name]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[date]" title="Date (Ymd)">[date]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_id]" title="Profile ID">[profile_id]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[profile_name]" title="Profile name">[profile_name]</button>
|
|
<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert" data-field="{$id}" data-ph="[type]" title="Backup type">[type]</button>
|
|
</div>
|
|
<div class="mt-1" id="{$id}_status">
|
|
<small class="{$statusClass}">
|
|
<span class="{$statusIcon}" aria-hidden="true"></span>
|
|
{$statusDetail}
|
|
</small>
|
|
</div>
|
|
<div class="mt-1" id="{$id}_resolved" style="font-size:0.8rem; line-height:1.6;">
|
|
</div>
|
|
<div id="{$id}_defaultwarn" class="alert alert-warning alert-sm mt-1 py-1 px-2" style="display:none; font-size:0.85rem;">
|
|
<span class="icon-warning-circle" aria-hidden="true"></span>
|
|
The default backup directory is inside the web root. Backup archives may be directly downloadable if <code>.htaccess</code> is not supported. For better security, use a path outside the web root.
|
|
</div>
|
|
<div class="modal fade" id="{$id}_helpModal" tabindex="-1" aria-labelledby="{$id}_helpLabel" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p>Use these placeholders in the backup directory path. They are resolved at backup time.</p>
|
|
<table class="table table-sm table-striped">
|
|
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
|
|
<tbody>
|
|
<tr><td><code>[HOME]</code></td><td>Home directory of the server user</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
|
|
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory (inside web root)</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
|
|
<tr><td><code>[host]</code></td><td>Server hostname</td><td><code>{$placeholders['[host]']}</code></td></tr>
|
|
<tr><td><code>[site_name]</code></td><td>Joomla site name</td><td><code>{$placeholders['[site_name]']}</code></td></tr>
|
|
<tr><td><code>[date]</code></td><td>Date (Ymd)</td><td><code>{$placeholders['[date]']}</code></td></tr>
|
|
<tr><td><code>[year]</code></td><td>Four-digit year</td><td><code>{$placeholders['[year]']}</code></td></tr>
|
|
<tr><td><code>[month]</code></td><td>Two-digit month</td><td><code>{$placeholders['[month]']}</code></td></tr>
|
|
<tr><td><code>[day]</code></td><td>Two-digit day</td><td><code>{$placeholders['[day]']}</code></td></tr>
|
|
<tr><td><code>[profile_id]</code></td><td>Backup profile ID</td><td><code>1</code></td></tr>
|
|
<tr><td><code>[profile_name]</code></td><td>Profile title</td><td><code>default</code></td></tr>
|
|
<tr><td><code>[type]</code></td><td>Backup type</td><td><code>full</code></td></tr>
|
|
</tbody>
|
|
</table>
|
|
<h6>Recommended Paths</h6>
|
|
<ul class="list-unstyled">
|
|
<li><code>[HOME]/backups</code> — Outside web root (recommended)</li>
|
|
<li><code>[HOME]/backups/[host]</code> — Per-site subdirectory</li>
|
|
<li><code>[DEFAULT_DIR]</code> — Inside web root (protected by .htaccess)</li>
|
|
</ul>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="{$id}_browser" class="card mt-2" style="display:none; max-height:300px; overflow-y:auto;">
|
|
<div class="card-body p-2">
|
|
<div id="{$id}_tree"></div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
/* Clickable placeholder insertion at cursor position */
|
|
document.querySelectorAll('.moko-ph-insert[data-field="{$id}"]').forEach(function(btn) {
|
|
btn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
var target = document.getElementById(this.getAttribute('data-field'));
|
|
var ph = this.getAttribute('data-ph');
|
|
if (!target) return;
|
|
var start = target.selectionStart || 0;
|
|
var end = target.selectionEnd || 0;
|
|
var val = target.value;
|
|
target.value = val.substring(0, start) + ph + val.substring(end);
|
|
/* Move cursor to after the inserted placeholder */
|
|
var newPos = start + ph.length;
|
|
target.setSelectionRange(newPos, newPos);
|
|
target.focus();
|
|
/* Trigger input event so status updates */
|
|
target.dispatchEvent(new Event('input', { bubbles: true }));
|
|
});
|
|
});
|
|
|
|
var fieldId = '{$id}';
|
|
var btn = document.getElementById(fieldId + '_btn');
|
|
var browser = document.getElementById(fieldId + '_browser');
|
|
var tree = document.getElementById(fieldId + '_tree');
|
|
var input = document.getElementById(fieldId);
|
|
var placeholders = {$placeholdersJson};
|
|
|
|
// Resolve placeholders in a path (forward: [site_name] -> actual value)
|
|
function resolve(path) {
|
|
for (var key in placeholders) {
|
|
path = path.split(key).join(placeholders[key]);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
// Reverse-replace actual values back to placeholders for portable storage
|
|
function unresolve(path) {
|
|
for (var key in placeholders) {
|
|
if (placeholders[key] && placeholders[key].length > 1) {
|
|
path = path.split(placeholders[key]).join(key);
|
|
}
|
|
}
|
|
return path;
|
|
}
|
|
|
|
var statusDiv = document.getElementById(fieldId + '_status');
|
|
var checkTimer = null;
|
|
|
|
function setStatus(cssClass, iconClass, message, codePath) {
|
|
while (statusDiv.firstChild) statusDiv.removeChild(statusDiv.firstChild);
|
|
var small = document.createElement('small');
|
|
small.className = cssClass;
|
|
var icon = document.createElement('span');
|
|
icon.className = iconClass;
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
small.appendChild(icon);
|
|
small.appendChild(document.createTextNode(' ' + message));
|
|
if (codePath) {
|
|
small.appendChild(document.createTextNode(': '));
|
|
var code = document.createElement('code');
|
|
code.textContent = codePath;
|
|
small.appendChild(code);
|
|
}
|
|
statusDiv.appendChild(small);
|
|
}
|
|
|
|
function setDefaultDirWarning() {
|
|
var warn = document.getElementById(fieldId + '_defaultwarn');
|
|
var val = input.value.trim();
|
|
var resolved = resolve(val);
|
|
var jRoot = placeholders['[DEFAULT_DIR]'].replace(/\/administrator\/components\/com_mokosuitebackup\/backups$/, '');
|
|
var isInsideWebRoot = resolved && resolved.indexOf(jRoot) === 0;
|
|
var isOldDefault = val === 'administrator/components/com_mokosuitebackup/backups' || val === 'administrator/components/com_mokojoombackup/backups' || val === '[DEFAULT_DIR]';
|
|
var showWarning = isOldDefault || isInsideWebRoot;
|
|
if (warn) warn.style.display = showWarning ? 'block' : 'none';
|
|
}
|
|
|
|
function checkDirPermissions() {
|
|
var val = input.value.trim();
|
|
if (!val) return;
|
|
|
|
setStatus('text-muted', 'icon-spinner icon-spin', 'Checking...', null);
|
|
setDefaultDirWarning();
|
|
|
|
var form = new URLSearchParams();
|
|
form.append('task', 'ajax.checkDir');
|
|
form.append('path', val);
|
|
var tokenName = Joomla.getOptions('csrf.token') || '';
|
|
if (tokenName) form.append(tokenName, '1');
|
|
|
|
fetch('index.php?option=com_mokosuitebackup&format=json', {
|
|
method: 'POST',
|
|
body: form,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
|
|
.then(function(data) {
|
|
if (data.error) {
|
|
setStatus('text-danger', 'icon-unpublish', data.message || 'Error', null);
|
|
return;
|
|
}
|
|
if (data.placeholder) {
|
|
setStatus('text-info', 'icon-info-circle', 'Uses placeholders (resolved at backup time)', data.resolved);
|
|
return;
|
|
}
|
|
if (data.writable) {
|
|
setStatus('text-success', 'icon-publish', 'Writable', data.resolved);
|
|
} else if (data.exists) {
|
|
setStatus('text-warning', 'icon-warning-circle', 'Exists but not writable', data.resolved);
|
|
} else {
|
|
setStatus('text-danger', 'icon-unpublish', 'Directory not found', data.resolved);
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
setStatus('text-danger', 'icon-unpublish', 'Check failed: ' + err.message, null);
|
|
});
|
|
}
|
|
|
|
/* Show which placeholders are in use and their resolved values */
|
|
var resolvedDiv = document.getElementById(fieldId + '_resolved');
|
|
|
|
function updateResolvedDisplay() {
|
|
while (resolvedDiv.firstChild) resolvedDiv.removeChild(resolvedDiv.firstChild);
|
|
var val = input.value || '';
|
|
var found = false;
|
|
|
|
for (var key in placeholders) {
|
|
if (val.indexOf(key) !== -1 && placeholders[key]) {
|
|
found = true;
|
|
var badge = document.createElement('span');
|
|
badge.className = 'badge bg-light text-dark border me-1 mb-1';
|
|
badge.style.fontSize = '0.75rem';
|
|
badge.style.fontFamily = 'monospace';
|
|
|
|
var keySpan = document.createElement('strong');
|
|
keySpan.textContent = key;
|
|
badge.appendChild(keySpan);
|
|
|
|
badge.appendChild(document.createTextNode(' = '));
|
|
|
|
var valSpan = document.createElement('span');
|
|
valSpan.className = 'text-primary';
|
|
valSpan.textContent = placeholders[key];
|
|
badge.appendChild(valSpan);
|
|
|
|
resolvedDiv.appendChild(badge);
|
|
}
|
|
}
|
|
|
|
if (found) {
|
|
var fullResolved = document.createElement('div');
|
|
fullResolved.className = 'mt-1';
|
|
var arrow = document.createElement('span');
|
|
arrow.className = 'text-muted';
|
|
arrow.textContent = 'Resolves to: ';
|
|
fullResolved.appendChild(arrow);
|
|
var code = document.createElement('code');
|
|
code.textContent = resolve(val);
|
|
fullResolved.appendChild(code);
|
|
resolvedDiv.appendChild(fullResolved);
|
|
}
|
|
}
|
|
|
|
input.addEventListener('input', function() {
|
|
clearTimeout(checkTimer);
|
|
updateResolvedDisplay();
|
|
checkTimer = setTimeout(checkDirPermissions, 400);
|
|
});
|
|
|
|
input.addEventListener('change', function() {
|
|
clearTimeout(checkTimer);
|
|
checkDirPermissions();
|
|
});
|
|
|
|
btn.addEventListener('click', function() {
|
|
if (browser.style.display !== 'none') {
|
|
browser.style.display = 'none';
|
|
return;
|
|
}
|
|
browser.style.display = 'block';
|
|
// Resolve placeholders before browsing so the server sees real paths
|
|
loadDir(resolve(input.value || '/'));
|
|
});
|
|
|
|
function loadDir(path) {
|
|
tree.textContent = 'Loading...';
|
|
|
|
var form = new URLSearchParams();
|
|
form.append('task', 'ajax.browseDir');
|
|
form.append('path', path);
|
|
|
|
var tokenName = Joomla.getOptions('csrf.token') || '';
|
|
if (tokenName) form.append(tokenName, '1');
|
|
|
|
fetch('index.php?option=com_mokosuitebackup&format=json', {
|
|
method: 'POST',
|
|
body: form,
|
|
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
|
})
|
|
.then(function(r) { if (!r.ok) throw new Error('Server error (HTTP ' + r.status + ')'); return r.json(); })
|
|
.then(function(data) {
|
|
if (data.error) {
|
|
tree.textContent = data.message || 'Error loading directory';
|
|
return;
|
|
}
|
|
renderTree(data, path);
|
|
})
|
|
.catch(function(err) {
|
|
tree.textContent = 'Error: ' + err.message;
|
|
});
|
|
}
|
|
|
|
function renderTree(data, path) {
|
|
while (tree.firstChild) tree.removeChild(tree.firstChild);
|
|
|
|
var list = document.createElement('div');
|
|
list.className = 'list-group list-group-flush';
|
|
|
|
if (data.parent) {
|
|
var up = document.createElement('a');
|
|
up.href = '#';
|
|
up.className = 'list-group-item list-group-item-action py-1';
|
|
var upIcon = document.createElement('span');
|
|
upIcon.className = 'icon-arrow-up-4';
|
|
upIcon.setAttribute('aria-hidden', 'true');
|
|
up.appendChild(upIcon);
|
|
up.appendChild(document.createTextNode(' ..'));
|
|
up.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
loadDir(data.parent);
|
|
});
|
|
list.appendChild(up);
|
|
}
|
|
|
|
(data.dirs || []).forEach(function(dir) {
|
|
var item = document.createElement('a');
|
|
item.href = '#';
|
|
item.className = 'list-group-item list-group-item-action py-1';
|
|
var icon = document.createElement('span');
|
|
icon.className = 'icon-folder';
|
|
icon.setAttribute('aria-hidden', 'true');
|
|
item.appendChild(icon);
|
|
item.appendChild(document.createTextNode(' ' + dir.name));
|
|
item.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
// Store with placeholders reversed back in
|
|
input.value = unresolve(dir.path);
|
|
checkDirPermissions();
|
|
loadDir(dir.path);
|
|
});
|
|
item.addEventListener('dblclick', function(e) {
|
|
e.preventDefault();
|
|
input.value = unresolve(dir.path);
|
|
checkDirPermissions();
|
|
browser.style.display = 'none';
|
|
});
|
|
list.appendChild(item);
|
|
});
|
|
|
|
tree.appendChild(list);
|
|
|
|
var info = document.createElement('div');
|
|
info.className = 'mt-2 p-1';
|
|
var small = document.createElement('small');
|
|
small.className = 'text-muted';
|
|
small.textContent = 'Current: ' + (data.current || path);
|
|
info.appendChild(small);
|
|
|
|
// Show what will be stored (with placeholders)
|
|
var stored = document.createElement('small');
|
|
stored.className = 'text-info d-block';
|
|
stored.textContent = 'Stored as: ' + unresolve(data.current || path);
|
|
info.appendChild(stored);
|
|
|
|
tree.appendChild(info);
|
|
}
|
|
|
|
// Run initial check on page load
|
|
setDefaultDirWarning();
|
|
updateResolvedDisplay();
|
|
checkDirPermissions();
|
|
})();
|
|
</script>
|
|
HTML;
|
|
}
|
|
}
|