Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Field/WebcronSecretField.php
T
Jonathan Miller ef31713029
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
feat: content snapshots, restore UI, and config hardening (v01.25.00)
Add content snapshot system for lightweight article/category/module
versioning independent of full backups. Snapshots store as JSON files
with replace or merge restore modes, wrapped in DB transactions.

- SnapshotEngine: dumps articles, categories, modules + related tables
  (workflow_associations, tag maps, frontpage) to JSON
- SnapshotRestoreEngine: replace (clean slate) or merge (upsert) mode
- Full MVC: controller, models, view, template with create/restore modals
- New ACL permission: mokosuitebackup.snapshot.manage
- Submenu entry with camera icon, upgrade SQL for snapshots table

Improve full-site restore UI with confirmation modal offering options
for files, database, preserve config, and encryption password.

Config improvements:
- WebcronSecretField: CSPRNG generator, strength meter, rejects weak
  patterns (password, admin, secret), enforces min 16 chars
- IpWhitelistField: table-based management, current IP detection with
  one-click "Add my IP" button
- Default profile shows "Title (#ID)" format
- Default backup dir uses [DEFAULT_DIR] placeholder
- Install script generates random 32-char webcron secret
- Dashboard quick actions: full-width dropdown with button below
2026-06-21 15:25:53 -05:00

185 lines
5.3 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
*
* Custom field for the webcron secret word.
* Generates a random default and validates minimum strength.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
defined('_JEXEC') or die;
use Joomla\CMS\Form\FormField;
use Joomla\CMS\Language\Text;
class WebcronSecretField extends FormField
{
protected $type = 'WebcronSecret';
private const MIN_LENGTH = 16;
private const WEAK_PATTERNS = [
'password', 'secret', '123456', 'admin', 'backup',
'test', 'webcron', 'qwerty', 'letmein', 'welcome',
];
protected function getInput(): string
{
$value = $this->value ?? '';
$id = $this->id;
$name = $this->name;
$maxLength = (int) ($this->element['maxlength'] ?? 64);
$strengthHtml = '';
$strengthClass = 'text-muted';
$strengthText = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
if (!empty($value)) {
$strength = $this->evaluateStrength($value);
$strengthClass = $strength['class'];
$strengthText = $strength['label'];
}
$html = '<div class="input-group">';
$html .= '<input type="text" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
. ' value="' . htmlspecialchars($value) . '"'
. ' class="form-control" maxlength="' . $maxLength . '"'
. ' autocomplete="off" spellcheck="false"'
. ' onchange="mokoWebcronCheckStrength(this)"'
. ' onkeyup="mokoWebcronCheckStrength(this)">';
$html .= '<button type="button" class="btn btn-outline-secondary" onclick="mokoWebcronGenerate(\'' . htmlspecialchars($id) . '\')"'
. ' title="' . Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE') . '">'
. '<span class="icon-refresh" aria-hidden="true"></span> '
. Text::_('COM_MOKOJOOMBACKUP_WEBCRON_GENERATE')
. '</button>';
$html .= '</div>';
$html .= '<div id="' . htmlspecialchars($id) . '-strength" class="small mt-1 ' . $strengthClass . '">'
. $strengthText . '</div>';
$html .= $this->getScript();
return $html;
}
private function evaluateStrength(string $value): array
{
$len = strlen($value);
// Check weak patterns
$lower = strtolower($value);
foreach (self::WEAK_PATTERNS as $weak) {
if (str_contains($lower, $weak)) {
return [
'class' => 'text-danger fw-bold',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
];
}
}
if ($len < self::MIN_LENGTH) {
return [
'class' => 'text-danger',
'label' => Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', self::MIN_LENGTH),
];
}
$hasUpper = preg_match('/[A-Z]/', $value);
$hasLower = preg_match('/[a-z]/', $value);
$hasDigit = preg_match('/[0-9]/', $value);
if ($hasUpper && $hasLower && $hasDigit && $len >= 32) {
return [
'class' => 'text-success',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'),
];
}
if ($hasUpper && $hasLower && $hasDigit) {
return [
'class' => 'text-warning',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'),
];
}
return [
'class' => 'text-danger',
'label' => Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'),
];
}
private function getScript(): string
{
$minLen = self::MIN_LENGTH;
$weakJson = json_encode(self::WEAK_PATTERNS);
$labelNone = Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_NONE');
$labelShort = json_encode(Text::sprintf('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_SHORT', $minLen));
$labelWeak = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_WEAK'));
$labelOk = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_OK'));
$labelStrong = json_encode(Text::_('COM_MOKOJOOMBACKUP_WEBCRON_STRENGTH_STRONG'));
return <<<JS
<script>
function mokoWebcronGenerate(fieldId) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
var result = '';
var arr = new Uint8Array(32);
(window.crypto || window.msCrypto).getRandomValues(arr);
for (var i = 0; i < 32; i++) {
result += chars.charAt(arr[i] % chars.length);
}
var field = document.getElementById(fieldId);
field.value = result;
mokoWebcronCheckStrength(field);
}
function mokoWebcronCheckStrength(field) {
var val = field.value;
var el = document.getElementById(field.id + '-strength');
var weak = {$weakJson};
var lower = val.toLowerCase();
if (!val) {
el.className = 'small mt-1 text-muted';
el.textContent = '{$labelNone}';
return;
}
for (var i = 0; i < weak.length; i++) {
if (lower.indexOf(weak[i]) !== -1) {
el.className = 'small mt-1 text-danger fw-bold';
el.textContent = {$labelWeak};
return;
}
}
if (val.length < {$minLen}) {
el.className = 'small mt-1 text-danger';
el.textContent = {$labelShort};
return;
}
var hasUpper = /[A-Z]/.test(val);
var hasLower = /[a-z]/.test(val);
var hasDigit = /[0-9]/.test(val);
if (hasUpper && hasLower && hasDigit && val.length >= 32) {
el.className = 'small mt-1 text-success';
el.textContent = {$labelStrong};
} else if (hasUpper && hasLower && hasDigit) {
el.className = 'small mt-1 text-warning';
el.textContent = {$labelOk};
} else {
el.className = 'small mt-1 text-danger';
el.textContent = {$labelWeak};
}
}
</script>
JS;
}
}