2026-06-04 20:30:28 -05:00
<? php
/**
* @package MokoJoomBackup
2026-06-06 14:52:27 -05:00
* @subpackage com_mokojoombackup
2026-06-04 20:30:28 -05:00
* @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
*/
2026-06-06 14:52:27 -05:00
namespace Joomla\Component\MokoJoomBackup\Administrator\Field ;
2026-06-04 20:30:28 -05:00
defined ( '_JEXEC' ) or die ;
2026-06-06 17:52:42 -05:00
use Joomla\CMS\Factory ;
2026-06-04 20:30:28 -05:00
use Joomla\CMS\Form\FormField ;
use Joomla\CMS\Language\Text ;
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 ;
}
2026-06-06 17:52:42 -05:00
// 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 = [
2026-06-07 06:54:12 -05:00
'[DEFAULT_DIR]' => JPATH_ADMINISTRATOR . '/components/com_mokojoombackup/backups' ,
2026-06-06 17:52:42 -05:00
'[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 );
2026-06-06 16:32:28 -05:00
$hasPlaceholders = preg_match ( '/\[.+\]/' , $absPath );
if ( $hasPlaceholders ) {
2026-06-06 17:52:42 -05:00
$exists = is_dir ( $resolvedPath );
$statusClass = $exists ? 'text-success' : 'text-info' ;
$statusIcon = $exists ? 'icon-publish' : 'icon-info-circle' ;
2026-06-06 16:32:28 -05:00
$statusText = Text :: _ ( 'COM_MOKOJOOMBACKUP_FOLDER_PLACEHOLDER' );
2026-06-06 17:52:42 -05:00
$resolvedSafe = htmlspecialchars ( $resolvedPath , ENT_QUOTES , 'UTF-8' );
$statusDetail = " { $statusText } : <code> { $resolvedSafe } </code>" ;
2026-06-06 16:32:28 -05:00
} 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' );
2026-06-06 17:52:42 -05:00
$absPathSafe = htmlspecialchars ( $absPath , ENT_QUOTES , 'UTF-8' );
$statusDetail = " { $statusText } : <code> { $absPathSafe } </code>" ;
2026-06-06 16:32:28 -05:00
}
2026-06-04 20:30:28 -05:00
return <<< HTML
<div class="input-group">
<input type="text" name="{$name}" id="{$id}" value="{$value}"
class="form-control" maxlength="512"
2026-06-07 06:54:12 -05:00
placeholder="[DEFAULT_DIR] or /home/user/backups/[host]" />
2026-06-04 20:30:28 -05:00
<button type="button" class="btn btn-outline-secondary" id="{$id}_btn">
<span class="icon-folder-open" aria-hidden="true"></span>
Browse
</button>
</div>
2026-06-06 17:52:42 -05:00
<div class="mt-1" id="{$id}_status">
2026-06-04 20:30:28 -05:00
<small class="{$statusClass}">
<span class="{$statusIcon}" aria-hidden="true"></span>
2026-06-06 17:52:42 -05:00
{$statusDetail}
2026-06-04 20:30:28 -05:00
</small>
</div>
2026-06-07 06:54:12 -05:00
<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>
2026-06-04 20:30:28 -05:00
<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() {
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);
2026-06-06 17:52:42 -05:00
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;
}
2026-06-04 20:30:28 -05:00
2026-06-07 06:54:12 -05:00
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 isDefault = (!val || val === '[DEFAULT_DIR]' || val === 'administrator/components/com_mokojoombackup/backups');
if (warn) warn.style.display = isDefault ? '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_mokojoombackup&format=json', {
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { 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();
});
2026-06-04 20:30:28 -05:00
btn.addEventListener('click', function() {
if (browser.style.display !== 'none') {
browser.style.display = 'none';
return;
}
browser.style.display = 'block';
2026-06-06 17:52:42 -05:00
// Resolve placeholders before browsing so the server sees real paths
loadDir(resolve(input.value || '/'));
2026-06-04 20:30:28 -05:00
});
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');
2026-06-06 14:52:27 -05:00
fetch('index.php?option=com_mokojoombackup&format=json', {
2026-06-04 20:30:28 -05:00
method: 'POST',
body: form,
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(function(r) { 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();
2026-06-06 17:52:42 -05:00
// Store with placeholders reversed back in
input.value = unresolve(dir.path);
2026-06-07 06:54:12 -05:00
checkDirPermissions();
2026-06-04 20:30:28 -05:00
loadDir(dir.path);
});
item.addEventListener('dblclick', function(e) {
e.preventDefault();
2026-06-06 17:52:42 -05:00
input.value = unresolve(dir.path);
2026-06-07 06:54:12 -05:00
checkDirPermissions();
2026-06-04 20:30:28 -05:00
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);
2026-06-06 17:52:42 -05:00
// 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);
2026-06-04 20:30:28 -05:00
tree.appendChild(info);
}
2026-06-07 06:54:12 -05:00
// Run initial check on page load
setDefaultDirWarning();
checkDirPermissions();
2026-06-04 20:30:28 -05:00
})();
</script>
HTML ;
}
}