456e744d81
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Branch Policy (pull_request) Failing after 4s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 14s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 26s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 9m59s
Both Backup Directory and Archive Name Format fields now show clickable placeholder tags below the input. Clicking a tag inserts the placeholder at the current cursor position using selectionStart/End. - FolderPickerField: pills for [HOME], [DEFAULT_DIR], [host], [site_name], [date], [profile_id], [profile_name], [type] - PlaceholderTextField: new custom field type used by archive_name_format, configurable placeholders via XML attribute - Cursor position preserved after insert, input event dispatched for live status updates
408 lines
16 KiB
PHP
408 lines
16 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 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);
|
|
});
|
|
}
|
|
|
|
input.addEventListener('input', function() {
|
|
clearTimeout(checkTimer);
|
|
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();
|
|
checkDirPermissions();
|
|
})();
|
|
</script>
|
|
HTML;
|
|
}
|
|
}
|