Compare commits

..

23 Commits

Author SHA1 Message Date
gitea-actions[bot] 29da9776cd chore: promote changelog [Unreleased] → [01.39.00] 2026-06-23 17:08:24 +00:00
gitea-actions[bot] 09bac755a9 chore(release): build 01.39.00 [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
2026-06-23 17:08:16 +00:00
jmiller f830dc2ddf Merge pull request 'feat: Data sanitization — passwords, emails, sessions (#129)' (#130) from feat/sanitize-user-passwords into main 2026-06-23 17:06:47 +00:00
Jonathan Miller 5698c074da feat: data sanitization — passwords, emails, sessions (#129)
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
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
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 7s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 54s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
New "Data Sanitization" fieldset on profile form with four options:
- Sanitize User Passwords: replaces all bcrypt hashes with invalid sentinel
- Preserve Super Admin: keeps Super Users group passwords intact
- Sanitize User Emails: replaces with user123@sanitized.example.com
- Clear Session Data: excludes #__session table data (default: on)

DatabaseDumper sanitizes rows inline during dump — both in-memory
and file-streaming paths. Super admin detection uses group_id=8
from #__user_usergroup_map with static caching.

Use cases: sharing backups, creating demo/staging sites, GDPR compliance.

Partial #129 (Part 2 — restore script password reset — tracked separately)
2026-06-23 12:06:19 -05:00
gitea-actions[bot] aaf189b87a chore: promote changelog [Unreleased] → [01.38.05] 2026-06-23 16:59:10 +00:00
gitea-actions[bot] 61023821e6 chore(release): build 01.38.05 [skip ci]
Publish to Composer / Publish Package (release) Failing after 34s
2026-06-23 16:59:02 +00:00
jmiller 02a6e30db1 Merge pull request 'feat: Comprehensive help modal for backup directory + fix help button' (#128) from fix/folder-picker-tooltip into main 2026-06-23 16:58:45 +00:00
gitea-actions[bot] 5a0cd51df6 chore(version): pre-release bump to 01.38.05-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 5s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 33s
2026-06-23 16:58:33 +00:00
Jonathan Miller 12c832d7fe feat: comprehensive help modal for backup directory field
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 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 11s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Universal: PR Check / Secret Scan (pull_request) Successful in 4s
Universal: Build & Release / Promote to RC (pull_request) Failing after 8s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 43s
Expanded the help modal with:
- Absolute paths: starts with / or drive letter, used as-is
- Relative paths: ./backups, ../backups, ../../backups with table
  showing URL-style conventions and resolved examples
- Placeholder paths: detailed descriptions of each placeholder
  with current server values
- Recommended configurations table: single site, multi-site,
  date-organized, per-profile, shared hosting
- Security warnings for web-root-accessible paths
- Help button uses JS click handler with Bootstrap 5 fallback
  (fixes non-working tooltip icon)
2026-06-23 11:58:20 -05:00
gitea-actions[bot] 65c8820db4 chore: promote changelog [Unreleased] → [01.38.04] 2026-06-23 16:53:45 +00:00
gitea-actions[bot] 0f914c3061 chore(release): build 01.38.04 [skip ci]
Publish to Composer / Publish Package (release) Failing after 42s
2026-06-23 16:53:42 +00:00
jmiller 4191f44c1b Merge pull request 'feat: Uppercase all placeholders + EXAMPLE prefix in display' (#127) from fix/uppercase-placeholders into main 2026-06-23 16:53:28 +00:00
gitea-actions[bot] fb99afbeba chore(version): pre-release bump to 01.38.04-dev [skip ci]
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 17s
Publish to Composer / Publish Package (release) Failing after 33s
2026-06-23 16:53:16 +00:00
Jonathan Miller de632e9c5c feat: uppercase all placeholders + EXAMPLE prefix in resolution display
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
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 20s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 10s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Universal: PR Check / Secret Scan (pull_request) Successful in 12s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 14s
Universal: Build & Release / Promote to RC (pull_request) Failing after 10s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 47s
All placeholders changed from lowercase to UPPERCASE:
[host] → [HOST], [site_name] → [SITE_NAME], [date] → [DATE],
[datetime] → [DATETIME], [profile_id] → [PROFILE_ID], etc.

[HOME] and [DEFAULT_DIR] were already uppercase — now consistent.

SQL migration 01.39.01 updates existing profile data in the database.
Resolution display prefixed with "EXAMPLE:" to clarify these are
example values resolved at backup time.

13 files updated across engines, fields, forms, templates, and SQL.
2026-06-23 11:52:52 -05:00
gitea-actions[bot] 53ff99148c chore: promote changelog [Unreleased] → [01.38.03] 2026-06-23 16:50:30 +00:00
gitea-actions[bot] c2ff3b272a chore(release): build 01.38.03 [skip ci]
Publish to Composer / Publish Package (release) Failing after 4s
2026-06-23 16:50:27 +00:00
jmiller 747b68c179 Merge pull request 'fix: Resolve [site_name] and all placeholders in checkDir AJAX' (#126) from fix/site-name-resolution into main 2026-06-23 16:50:07 +00:00
gitea-actions[bot] cbff40d04c chore(version): pre-release bump to 01.38.03-dev [skip ci]
Publish to Composer / Publish Package (release) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 21s
2026-06-23 16:49:45 +00:00
Jonathan Miller e415e701cd fix: resolve [site_name] and other placeholders in checkDir AJAX
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
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 5s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 15s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 8s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Universal: Build & Release / Promote to RC (pull_request) Failing after 15s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 46s
BackupDirectory::resolve() only handles [HOME] and [DEFAULT_DIR].
The checkDir AJAX endpoint now uses PlaceholderResolver to also
resolve [site_name], [host], [profile_id], [date], etc. before
checking if the directory exists. This makes the "Resolves to"
display accurate for all placeholder types.
2026-06-23 11:49:21 -05:00
jmiller d184ed9de0 chore: sync repo-health.yml from Template-Generic [skip ci] 2026-06-23 16:38:26 +00:00
jmiller 297f27c807 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-23 16:38:26 +00:00
jmiller 30e8d7baa9 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-23 16:38:25 +00:00
jmiller efc5754bef chore: sync auto-bump.yml from Template-Generic [skip ci] 2026-06-23 16:38:23 +00:00
28 changed files with 473 additions and 114 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Automation
# VERSION: 01.38.02
# VERSION: 01.39.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
+6 -11
View File
@@ -1,19 +1,14 @@
# Changelog
## [Unreleased]
## [01.38.02] --- 2026-06-23
## [01.39.00] --- 2026-06-23
## [01.38.02] --- 2026-06-23
## [01.39.00] --- 2026-06-23
## [01.38.01] --- 2026-06-23
## [01.38.05] --- 2026-06-23
## [01.38.01] --- 2026-06-23
## [01.38.05] --- 2026-06-23
## [01.38.00] --- 2026-06-23
## [01.38.04] --- 2026-06-23
## [01.38.00] --- 2026-06-23
### Added
- Standalone restore script mode — restore.php as separate file that scans for backup ZIPs in its directory (#107)
- MokoRestore profile option: None / Wrapped / Standalone
- Standalone mode uploads restore.php alongside backup to remote storage
## [01.38.04] --- 2026-06-23
+1 -1
View File
@@ -1,6 +1,6 @@
# MokoSuiteBackup
<!-- VERSION: 01.38.02 -->
<!-- VERSION: 01.39.00 -->
Full-site backup and restore for Joomla — database, files, and configuration.
@@ -75,10 +75,10 @@
type="PlaceholderText"
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
default="[host]_[datetime]_profile[profile_id]"
default="[HOST]_[DATETIME]_profile[PROFILE_ID]"
maxlength="512"
hint="[host]_[datetime]_profile[profile_id]"
placeholders="[host],[datetime],[date],[time],[year],[month],[day],[hour],[minute],[second],[profile_id],[profile_name],[site_name],[type],[random]"
hint="[HOST]_[DATETIME]_profile[PROFILE_ID]"
placeholders="[HOST],[DATETIME],[DATE],[TIME],[YEAR],[MONTH],[DAY],[HOUR],[MINUTE],[SECOND],[PROFILE_ID],[PROFILE_NAME],[SITE_NAME],[TYPE],[RANDOM]"
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
/>
<field
@@ -101,6 +101,54 @@
/>
</fieldset>
<fieldset name="sanitization" label="COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION">
<field
name="sanitize_passwords"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="preserve_super_admin"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN"
description="COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC"
default="1"
class="btn-group"
showon="sanitize_passwords:1"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_emails"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC"
default="0"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
<field
name="sanitize_sessions"
type="radio"
label="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS"
description="COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC"
default="1"
class="btn-group"
>
<option value="1">JYES</option>
<option value="0">JNO</option>
</field>
</fieldset>
<fieldset name="sidebar" label="COM_MOKOJOOMBACKUP_FIELDSET_STATUS">
<field
name="id"
@@ -130,15 +130,26 @@ COM_MOKOJOOMBACKUP_FIELD_ENCRYPTION_PASSWORD_DESC="Set a password to encrypt the
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE="Split Size (MB)"
COM_MOKOJOOMBACKUP_FIELD_SPLIT_SIZE_DESC="Split archive into parts of this size in MB. 0 = no splitting."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR="Backup Directory"
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [host], [date], [year], [month], [day], [profile_name], [site_name], [type]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
COM_MOKOJOOMBACKUP_FIELD_BACKUP_DIR_DESC="Directory where backup archives are stored. Supports placeholders: [HOME] (user home directory), [HOST], [DATE], [YEAR], [MONTH], [DAY], [PROFILE_NAME], [SITE_NAME], [TYPE]. Use [HOME]/backups to store outside the web root. Absolute paths (starting with /) are used as-is; relative paths resolve from the Joomla root."
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT="Archive Name Format"
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [host] hostname, [date] Ymd, [time] His, [datetime] Ymd_His, [year] [month] [day] [hour] [minute] [second], [profile_id], [profile_name], [site_name], [type], [random]."
COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC="Filename template for backup archives (without extension). Placeholders: [HOST] hostname, [DATE] Ymd, [TIME] His, [DATETIME] Ymd_His, [YEAR] [MONTH] [DAY] [HOUR] [MINUTE] [SECOND], [PROFILE_ID], [PROFILE_NAME], [SITE_NAME], [TYPE], [RANDOM]."
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE="MokoRestore Script"
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include the MokoRestore standalone restore wizard. 'Wrapped' bundles it inside the backup ZIP. 'Standalone' generates a separate restore.php that scans for backup ZIPs in its directory — ideal for remote servers."
COM_MOKOJOOMBACKUP_MOKORESTORE_NONE="None"
COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED="Wrapped (inside backup ZIP)"
COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE="Standalone (separate restore.php)"
; Data Sanitization
COM_MOKOJOOMBACKUP_FIELDSET_SANITIZATION="Data Sanitization"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS="Sanitize User Passwords"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_PASSWORDS_DESC="Replace all user password hashes with an invalid value. Users will not be able to log in with the restored backup without resetting their password. Ideal for sharing backups, creating demo/staging sites, or GDPR compliance."
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN="Preserve Super Admin Password"
COM_MOKOJOOMBACKUP_FIELD_PRESERVE_SUPER_ADMIN_DESC="Keep the password for Super Users (group ID 8) intact. You will still be able to log in as a Super Admin after restoring."
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS="Sanitize User Emails"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_EMAILS_DESC="Replace all user email addresses with dummy values (user123@sanitized.example.com). Prevents accidental emails being sent to real users from a cloned/staging site. Super admin emails are preserved if 'Preserve Super Admin' is enabled."
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS="Clear Session Data"
COM_MOKOJOOMBACKUP_FIELD_SANITIZE_SESSIONS_DESC="Exclude active session data from the backup. This logs out all users and prevents session hijacking when the backup is restored on another server. Enabled by default."
; Exclusion filter fields
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS_DESC="Browse and check directories to exclude from file backup. You can also type paths manually."
@@ -7,7 +7,7 @@
-->
<extension type="component" method="upgrade">
<name>MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`compression_level` TINYINT(1) UNSIGNED NOT NULL DEFAULT 5 COMMENT '0=none, 9=max',
`split_size` INT(11) UNSIGNED NOT NULL DEFAULT 0 COMMENT '0=no split, otherwise MB per part',
`backup_dir` VARCHAR(512) NOT NULL DEFAULT '[DEFAULT_DIR]',
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders',
`archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders',
`exclude_dirs` TEXT NOT NULL COMMENT 'Newline-separated directory paths to exclude',
`exclude_files` TEXT NOT NULL COMMENT 'Newline-separated filename patterns to exclude',
`exclude_tables` TEXT NOT NULL COMMENT 'Newline-separated table names to exclude',
@@ -40,6 +40,10 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
`remote_keep_local` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep local copy after upload',
`encryption_password` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'AES-256 archive encryption password (blank = no encryption)',
`include_mokorestore` VARCHAR(20) NOT NULL DEFAULT '0' COMMENT 'MokoRestore mode: 0=none, 1=wrapped, standalone',
`sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user password hashes with invalid value',
`preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Keep super admin password when sanitizing',
`sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 COMMENT 'Replace user emails with dummy values',
`sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Skip session table data',
`notify_email` VARCHAR(512) NOT NULL DEFAULT '' COMMENT 'Comma-separated notification emails',
`notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs',
`notify_on_success` TINYINT(1) NOT NULL DEFAULT 0,
@@ -9,4 +9,4 @@ ALTER TABLE `#__mokosuitebackup_records` MODIFY `log` MEDIUMTEXT DEFAULT NULL;
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `notify_user_groups` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'Comma-separated Joomla user group IDs' AFTER `notify_email`;
-- Add archive_name_format column with placeholder support
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[host]_[datetime]_profile[profile_id]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
ALTER TABLE `#__mokosuitebackup_profiles` ADD COLUMN `archive_name_format` VARCHAR(512) NOT NULL DEFAULT '[HOST]_[DATETIME]_profile[PROFILE_ID]' COMMENT 'Filename format with placeholders' AFTER `backup_dir`;
@@ -0,0 +1,34 @@
-- MokoSuiteBackup 01.39.01 — Uppercase all placeholders in profile data
UPDATE `#__mokosuitebackup_profiles` SET
`archive_name_format` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
`archive_name_format`,
'[host]', '[HOST]'),
'[site_name]', '[SITE_NAME]'),
'[datetime]', '[DATETIME]'),
'[date]', '[DATE]'),
'[time]', '[TIME]'),
'[year]', '[YEAR]'),
'[month]', '[MONTH]'),
'[day]', '[DAY]'),
'[hour]', '[HOUR]'),
'[minute]', '[MINUTE]'),
'[second]', '[SECOND]'),
'[profile_id]', '[PROFILE_ID]'),
'[profile_name]', '[PROFILE_NAME]'),
'[type]', '[TYPE]'),
'[random]', '[RANDOM]')
WHERE `archive_name_format` REGEXP '\\[[a-z]';
UPDATE `#__mokosuitebackup_profiles` SET
`backup_dir` = REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
`backup_dir`,
'[host]', '[HOST]'),
'[site_name]', '[SITE_NAME]'),
'[date]', '[DATE]'),
'[year]', '[YEAR]'),
'[month]', '[MONTH]'),
'[day]', '[DAY]'),
'[profile_id]', '[PROFILE_ID]'),
'[profile_name]', '[PROFILE_NAME]')
WHERE `backup_dir` REGEXP '\\[[a-z]';
@@ -0,0 +1,7 @@
-- MokoSuiteBackup 01.39.02 — Data sanitization columns
ALTER TABLE `#__mokosuitebackup_profiles`
ADD COLUMN `sanitize_passwords` TINYINT(1) NOT NULL DEFAULT 0 AFTER `include_mokorestore`,
ADD COLUMN `preserve_super_admin` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_passwords`,
ADD COLUMN `sanitize_emails` TINYINT(1) NOT NULL DEFAULT 0 AFTER `preserve_super_admin`,
ADD COLUMN `sanitize_sessions` TINYINT(1) NOT NULL DEFAULT 1 AFTER `sanitize_emails`;
@@ -15,8 +15,10 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Controller;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\CMS\MVC\Controller\BaseController;
use Joomla\CMS\Session\Session;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\PlaceholderResolver;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedBackupEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Engine\SteppedRestoreEngine;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
@@ -283,7 +285,32 @@ class AjaxController extends BaseController
return;
}
$resolved = BackupDirectory::resolve($rawPath);
/* Resolve all placeholders — both directory ([HOME], [DEFAULT_DIR])
and name-level ([SITE_NAME], [HOST], [PROFILE_ID], etc.) */
$profileId = $this->input->getInt('profile_id', 0);
if ($profileId > 0) {
$db = Factory::getDbo();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitebackup_profiles'))
->where($db->quoteName('id') . ' = ' . $profileId);
$db->setQuery($query);
$profile = $db->loadObject();
}
if (empty($profile)) {
/* No profile context — create a minimal dummy for PlaceholderResolver */
$profile = (object) [
'id' => 1,
'title' => 'default',
'backup_type' => 'full',
];
}
$resolver = new PlaceholderResolver($profile);
$withNamePlaceholders = $resolver->resolve($rawPath);
$resolved = BackupDirectory::resolve($withNamePlaceholders);
if (BackupDirectory::hasPlaceholders($resolved)) {
$this->sendJson([
@@ -88,7 +88,7 @@ class BackupEngine
$archiveName = '';
$archiver = $this->createArchiver($archiveFormat);
$archiveExt = $archiver->getExtension();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.' . $archiveExt;
if (empty($description)) {
@@ -137,7 +137,19 @@ class BackupEngine
if ($profile->backup_type !== 'files') {
$this->log('Starting database dump...');
$sqlTempFile = $this->backupDir . '/.database-' . $tag . '.sql';
$dumper = new DatabaseDumper($excludeTables);
$sanitizePasswords = (bool) ($profile->sanitize_passwords ?? false);
$preserveSuperAdmin = (bool) ($profile->preserve_super_admin ?? false);
$sanitizeEmails = (bool) ($profile->sanitize_emails ?? false);
$sanitizeSessions = (bool) ($profile->sanitize_sessions ?? true);
$dumper = new DatabaseDumper($excludeTables, $sanitizePasswords, $preserveSuperAdmin, $sanitizeEmails, $sanitizeSessions);
if ($sanitizePasswords) {
$this->log('User passwords will be sanitized' . ($preserveSuperAdmin ? ' (super admin preserved)' : ''));
}
if ($sanitizeEmails) {
$this->log('User emails will be sanitized');
}
$dbSize = $dumper->dumpToFile($sqlTempFile);
$archiver->addFile($sqlTempFile, 'database.sql');
$tablesCount = $dumper->getTablesCount();
@@ -27,12 +27,35 @@ class DatabaseDumper
private int $tablesCount = 0;
/** @var bool Whether to sanitize user passwords */
private bool $sanitizePasswords = false;
/** @var bool Whether to preserve super admin password when sanitizing */
private bool $preserveSuperAdmin = false;
/** @var bool Whether to sanitize user emails */
private bool $sanitizeEmails = false;
/** @var bool Whether to clear session data */
private bool $sanitizeSessions = false;
/** Known invalid bcrypt hash used for sanitized passwords */
private const SANITIZED_HASH = '$2y$10$SANITIZED.MOKOSUITEBACKUP.INVALID.HASH.DO.NOT.USE.000000';
/**
* @param array $excludeTables Table names to exclude (with #__ prefix).
* Supports suffixes: :data-only, :structure-only.
* No suffix = exclude both (backward compatible).
* @param array $excludeTables Table names to exclude (with #__ prefix).
* @param bool $sanitizePasswords Replace user password hashes with invalid value
* @param bool $preserveSuperAdmin Keep super admin password when sanitizing
* @param bool $sanitizeEmails Replace user emails with sanitized placeholders
* @param bool $sanitizeSessions Skip session table data entirely
*/
public function __construct(array $excludeTables = [])
public function __construct(
array $excludeTables = [],
bool $sanitizePasswords = false,
bool $preserveSuperAdmin = false,
bool $sanitizeEmails = false,
bool $sanitizeSessions = false
)
{
foreach ($excludeTables as $entry) {
if (str_ends_with($entry, ':data-only')) {
@@ -43,6 +66,16 @@ class DatabaseDumper
$this->excludeBoth[] = $entry;
}
}
$this->sanitizePasswords = $sanitizePasswords;
$this->preserveSuperAdmin = $preserveSuperAdmin;
$this->sanitizeEmails = $sanitizeEmails;
$this->sanitizeSessions = $sanitizeSessions;
/* If session sanitization is on, auto-exclude session table data */
if ($sanitizeSessions) {
$this->excludeDataOnly[] = '#__session';
}
}
/**
@@ -154,6 +187,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -326,6 +360,7 @@ class DatabaseDumper
}
foreach ($rows as $row) {
$this->sanitizeRow($row, $abstractName, $db);
$values = [];
foreach ($row as $value) {
@@ -351,6 +386,86 @@ class DatabaseDumper
return filesize($filePath) ?: 0;
}
/**
* Sanitize a row if it belongs to the users table and sanitization is enabled.
*
* Replaces the password column with an invalid hash so the backup
* cannot be used to extract user credentials.
*/
private function sanitizeRow(array &$row, string $abstractTable, object $db): void
{
if ($abstractTable !== '#__users') {
return;
}
if (!$this->sanitizePasswords && !$this->sanitizeEmails) {
return;
}
if ($this->sanitizeEmails && isset($row['email']) && isset($row['id'])) {
$userId = (int) $row['id'];
/* Preserve super admin emails if preserving super admin */
if (!$this->preserveSuperAdmin || !$this->isSuperAdmin($userId, $db)) {
$row['email'] = 'user' . $userId . '@sanitized.example.com';
}
}
if (!$this->sanitizePasswords || !isset($row['password'])) {
return;
}
if ($this->preserveSuperAdmin && isset($row['id'])) {
if ($this->isSuperAdmin((int) $row['id'], $db)) {
return;
}
}
$row['password'] = self::SANITIZED_HASH;
}
/**
* Check if a user ID belongs to the Super Users group (group_id = 8).
*/
private function isSuperAdmin(int $userId, object $db): bool
{
static $superAdminIds = null;
if ($superAdminIds === null) {
$prefix = $db->getPrefix();
try {
$db->setQuery(
$db->getQuery(true)
->select('DISTINCT ' . $db->quoteName('user_id'))
->from($db->quoteName($prefix . 'user_usergroup_map'))
->where($db->quoteName('group_id') . ' = 8')
);
$superAdminIds = array_map('intval', $db->loadColumn() ?: []);
} catch (\Throwable $e) {
$superAdminIds = [];
}
}
return in_array($userId, $superAdminIds, true);
}
/**
* Check if passwords were sanitized (for use by callers to log the action).
*/
public function isPasswordSanitizationEnabled(): bool
{
return $this->sanitizePasswords;
}
/**
* Get the sentinel hash used for sanitized passwords.
*/
public static function getSanitizedHash(): string
{
return self::SANITIZED_HASH;
}
public function getTablesCount(): int
{
return $this->tablesCount;
@@ -7,7 +7,7 @@
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Resolves placeholders like [host], [date], [profile_name] in backup
* Resolves placeholders like [HOST], [DATE], [PROFILE_NAME] in backup
* directory paths and archive filename formats.
*/
@@ -24,21 +24,21 @@ class PlaceholderResolver
* Supported placeholders and their descriptions (for documentation).
*/
public const PLACEHOLDERS = [
'[host]' => 'Server hostname',
'[date]' => 'Date as Ymd (e.g. 20260604)',
'[time]' => 'Time as His (e.g. 143025)',
'[datetime]' => 'Date and time as Ymd_His',
'[year]' => 'Four-digit year',
'[month]' => 'Two-digit month',
'[day]' => 'Two-digit day',
'[hour]' => 'Two-digit hour (24h)',
'[minute]' => 'Two-digit minute',
'[second]' => 'Two-digit second',
'[profile_id]' => 'Backup profile ID',
'[profile_name]' => 'Profile title (sanitized)',
'[site_name]' => 'Joomla site name (sanitized)',
'[type]' => 'Backup type (full, database, files, differential)',
'[random]' => 'Random 6-character hex string',
'[HOST]' => 'Server hostname',
'[DATE]' => 'Date as Ymd (e.g. 20260604)',
'[TIME]' => 'Time as His (e.g. 143025)',
'[DATETIME]' => 'Date and time as Ymd_His',
'[YEAR]' => 'Four-digit year',
'[MONTH]' => 'Two-digit month',
'[DAY]' => 'Two-digit day',
'[HOUR]' => 'Two-digit hour (24h)',
'[MINUTE]' => 'Two-digit minute',
'[SECOND]' => 'Two-digit second',
'[PROFILE_ID]' => 'Backup profile ID',
'[PROFILE_NAME]' => 'Profile title (sanitized)',
'[SITE_NAME]' => 'Joomla site name (sanitized)',
'[TYPE]' => 'Backup type (full, database, files, differential)',
'[RANDOM]' => 'Random 6-character hex string',
'[DEFAULT_DIR]' => 'Default backup directory',
'[HOME]' => 'Home directory of the PHP process owner',
];
@@ -62,21 +62,21 @@ class PlaceholderResolver
}
$this->replacements = [
'[host]' => $hostname,
'[date]' => $now->format('Ymd'),
'[time]' => $now->format('His'),
'[datetime]' => $now->format('Ymd_His'),
'[year]' => $now->format('Y'),
'[month]' => $now->format('m'),
'[day]' => $now->format('d'),
'[hour]' => $now->format('H'),
'[minute]' => $now->format('i'),
'[second]' => $now->format('s'),
'[profile_id]' => (string) ($profile->id ?? '0'),
'[profile_name]' => $this->sanitize($profile->title ?? 'default'),
'[site_name]' => $this->sanitize($siteName ?: 'joomla'),
'[type]' => $profile->backup_type ?? 'full',
'[random]' => bin2hex(random_bytes(3)),
'[HOST]' => $hostname,
'[DATE]' => $now->format('Ymd'),
'[TIME]' => $now->format('His'),
'[DATETIME]' => $now->format('Ymd_His'),
'[YEAR]' => $now->format('Y'),
'[MONTH]' => $now->format('m'),
'[DAY]' => $now->format('d'),
'[HOUR]' => $now->format('H'),
'[MINUTE]' => $now->format('i'),
'[SECOND]' => $now->format('s'),
'[PROFILE_ID]' => (string) ($profile->id ?? '0'),
'[PROFILE_NAME]' => $this->sanitize($profile->title ?? 'default'),
'[SITE_NAME]' => $this->sanitize($siteName ?: 'joomla'),
'[TYPE]' => $profile->backup_type ?? 'full',
'[RANDOM]' => bin2hex(random_bytes(3)),
'[DEFAULT_DIR]' => BackupDirectory::getDefaultAbsolute(),
'[HOME]' => BackupDirectory::getHomeDirectory(),
];
@@ -103,7 +103,7 @@ class PlaceholderResolver
*/
public function getHostname(): string
{
return $this->replacements['[host]'];
return $this->replacements['[HOST]'];
}
/**
@@ -111,7 +111,7 @@ class PlaceholderResolver
*/
public function getTag(): string
{
return $this->replacements['[datetime]'];
return $this->replacements['[DATETIME]'];
}
/**
@@ -83,7 +83,7 @@ class SteppedBackupEngine
$now = date('Y-m-d H:i:s');
$tag = $resolver->getTag();
$nameFormat = $profile->archive_name_format ?? '[host]_[datetime]_profile[profile_id]';
$nameFormat = $profile->archive_name_format ?? '[HOST]_[DATETIME]_profile[PROFILE_ID]';
$archiveName = $resolver->resolve($nameFormat) . '.zip';
$session->archivePath = $backupDir . '/' . $archiveName;
@@ -52,15 +52,15 @@ class FolderPickerField extends FormField
$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'),
'[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);
@@ -96,7 +96,7 @@ class FolderPickerField extends FormField
<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">
<button type="button" class="btn btn-outline-info" id="{$id}_helpBtn" title="Help — placeholders, paths, and examples">
<span class="icon-question-circle" aria-hidden="true"></span>
</button>
</div>
@@ -104,12 +104,12 @@ class FolderPickerField extends FormField
<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>
<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}">
@@ -124,36 +124,112 @@ class FolderPickerField extends FormField
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-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory Placeholders</h5>
<h5 class="modal-title" id="{$id}_helpLabel">Backup Directory — Help</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>
<h6 class="text-primary">How Path Resolution Works</h6>
<p>The backup directory path is resolved at backup time. You can use <strong>absolute paths</strong>, <strong>relative paths</strong>, or <strong>placeholder paths</strong>.</p>
<div class="card mb-3">
<div class="card-header fw-bold">Absolute Paths</div>
<div class="card-body py-2">
<p class="mb-1">Start with <code>/</code> (Linux) or a drive letter (Windows). Used as-is.</p>
<ul class="mb-0">
<li><code>/home/user/backups</code> — Fixed path on the server</li>
<li><code>/var/backups/joomla</code> — System backup directory</li>
</ul>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Relative Paths</div>
<div class="card-body py-2">
<p class="mb-1">Paths that do <strong>not</strong> start with <code>/</code> are resolved relative to the Joomla root directory, using the same conventions as URL paths:</p>
<table class="table table-sm mb-2">
<thead><tr><th>Path</th><th>Meaning</th><th>Resolves To</th></tr></thead>
<tbody>
<tr><td><code>backups</code></td><td>Subdirectory of Joomla root</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>./backups</code></td><td>Same as above (explicit current dir)</td><td><code>{$jRoot}/backups</code></td></tr>
<tr><td><code>../backups</code></td><td>One level <strong>above</strong> Joomla root</td><td>Parent of <code>{$jRoot}</code></td></tr>
<tr><td><code>../../backups</code></td><td>Two levels above Joomla root</td><td>Grandparent of <code>{$jRoot}</code></td></tr>
</tbody>
</table>
<div class="alert alert-warning py-1 px-2 mb-0" style="font-size:0.85rem;">
<strong>Warning:</strong> Relative paths that stay inside the web root may expose backup files to direct download if .htaccess is not supported. Use <code>../</code> or <code>[HOME]</code> to store backups outside the web root.
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-bold">Placeholder Paths (Recommended)</div>
<div class="card-body py-2">
<p class="mb-1">Use <code>[PLACEHOLDER]</code> tokens that are replaced with actual values at backup time. This makes paths <strong>portable</strong> across servers.</p>
<ul class="mb-0">
<li><code>[HOME]/backups</code> — User's home directory + /backups</li>
<li><code>[HOME]/[HOST]/backups</code> — Per-site subdirectory under home</li>
<li><code>[DEFAULT_DIR]</code> — Joomla's default backup directory</li>
</ul>
</div>
</div>
<h6 class="text-primary mt-3">Available Placeholders</h6>
<table class="table table-sm table-striped">
<thead><tr><th>Placeholder</th><th>Description</th><th>Example</th></tr></thead>
<thead><tr><th>Placeholder</th><th>Description</th><th>Current Value</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>
<tr><td><code>[HOME]</code></td><td>Home directory of the PHP process owner. Detected from environment, POSIX, or JPATH_ROOT.</td><td><code>{$placeholders['[HOME]']}</code></td></tr>
<tr><td><code>[DEFAULT_DIR]</code></td><td>Default backup directory inside the Joomla web root. Protected by .htaccess but not recommended for production.</td><td><code>{$placeholders['[DEFAULT_DIR]']}</code></td></tr>
<tr><td><code>[HOST]</code></td><td>Server hostname from HTTP_HOST. Sanitized to alphanumeric, dots, and hyphens.</td><td><code>{$placeholders['[HOST]']}</code></td></tr>
<tr><td><code>[SITE_NAME]</code></td><td>Joomla site name from Global Configuration. Spaces become hyphens, special characters stripped.</td><td><code>{$placeholders['[SITE_NAME]']}</code></td></tr>
<tr><td><code>[DATE]</code></td><td>Current date in Ymd format (e.g. 20260623).</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 (01-12).</td><td><code>{$placeholders['[MONTH]']}</code></td></tr>
<tr><td><code>[DAY]</code></td><td>Two-digit day (01-31).</td><td><code>{$placeholders['[DAY]']}</code></td></tr>
<tr><td><code>[PROFILE_ID]</code></td><td>Numeric ID of the backup profile being used.</td><td><code>1</code></td></tr>
<tr><td><code>[PROFILE_NAME]</code></td><td>Title of the backup profile, sanitized for filesystem use.</td><td><code>default</code></td></tr>
<tr><td><code>[TYPE]</code></td><td>Backup type: full, database, files, or differential.</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>
<h6 class="text-primary mt-3">Recommended Configurations</h6>
<table class="table table-sm">
<thead><tr><th>Use Case</th><th>Path</th><th>Notes</th></tr></thead>
<tbody>
<tr>
<td><strong>Single site, secure</strong></td>
<td><code>[HOME]/backups</code></td>
<td>Outside web root. Best for most sites.</td>
</tr>
<tr>
<td><strong>Multiple sites on one server</strong></td>
<td><code>[HOME]/backups/[HOST]</code></td>
<td>Each site gets its own subdirectory.</td>
</tr>
<tr>
<td><strong>Date-organized</strong></td>
<td><code>[HOME]/backups/[YEAR]/[MONTH]</code></td>
<td>Backups sorted by year and month.</td>
</tr>
<tr>
<td><strong>Per-profile</strong></td>
<td><code>[HOME]/backups/[PROFILE_NAME]</code></td>
<td>Separate directory for each backup profile.</td>
</tr>
<tr>
<td><strong>Shared hosting (default)</strong></td>
<td><code>[DEFAULT_DIR]</code></td>
<td>Inside web root, protected by .htaccess. Use only if you cannot write outside web root.</td>
</tr>
</tbody>
</table>
<div class="alert alert-info py-2 mt-3 mb-0">
<strong>Tip:</strong> The directory is created automatically if it doesn't exist. Placeholders are resolved fresh each time a backup runs, so date-based paths create new directories over time.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
@@ -188,6 +264,36 @@ class FolderPickerField extends FormField
});
});
/* Help button — open modal with Bootstrap 5 or fallback */
var helpBtn = document.getElementById('{$id}_helpBtn');
var helpModal = document.getElementById('{$id}_helpModal');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', function(e) {
e.preventDefault();
if (typeof bootstrap !== 'undefined' && bootstrap.Modal) {
var modal = bootstrap.Modal.getOrCreateInstance(helpModal);
modal.show();
} else {
helpModal.classList.add('show');
helpModal.style.display = 'block';
helpModal.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop fade show';
backdrop.id = '{$id}_backdrop';
document.body.appendChild(backdrop);
helpModal.querySelector('.btn-close, [data-bs-dismiss]').addEventListener('click', function() {
helpModal.classList.remove('show');
helpModal.style.display = 'none';
helpModal.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
var bd = document.getElementById('{$id}_backdrop');
if (bd) bd.remove();
});
}
});
}
var fieldId = '{$id}';
var btn = document.getElementById(fieldId + '_btn');
var browser = document.getElementById(fieldId + '_browser');
@@ -195,7 +301,7 @@ class FolderPickerField extends FormField
var input = document.getElementById(fieldId);
var placeholders = {$placeholdersJson};
// Resolve placeholders in a path (forward: [site_name] -> actual value)
// 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]);
@@ -322,7 +428,7 @@ class FolderPickerField extends FormField
fullResolved.className = 'mt-1';
var arrow = document.createElement('span');
arrow.className = 'text-muted';
arrow.textContent = 'Resolves to: ';
arrow.textContent = 'EXAMPLE: ';
fullResolved.appendChild(arrow);
var code = document.createElement('code');
code.textContent = resolve(val);
@@ -33,8 +33,8 @@ class PlaceholderTextField extends FormField
$placeholders = array_filter(array_map('trim', explode(',', $placeholderAttr)));
if (empty($placeholders)) {
$placeholders = ['[host]', '[date]', '[datetime]', '[time]', '[year]', '[month]', '[day]',
'[hour]', '[minute]', '[second]', '[profile_id]', '[profile_name]', '[site_name]', '[type]', '[random]'];
$placeholders = ['[HOST]', '[DATE]', '[DATETIME]', '[TIME]', '[YEAR]', '[MONTH]', '[DAY]',
'[HOUR]', '[MINUTE]', '[SECOND]', '[PROFILE_ID]', '[PROFILE_NAME]', '[SITE_NAME]', '[TYPE]', '[RANDOM]'];
}
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
@@ -65,7 +65,7 @@ class HtmlView extends BaseHtmlView
}
// "View Backups" link button
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $profileId);
$backupsUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $profileId);
$toolbar->linkButton('view-backups', 'COM_MOKOJOOMBACKUP_VIEW_BACKUPS')
->url($backupsUrl)
->icon('icon-database')
@@ -78,7 +78,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
<?php echo $this->escape($item->backup_type); ?>
</td>
<td class="text-center">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[profile_id]=' . $item->id); ?>">
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&filter[PROFILE_ID]=' . $item->id); ?>">
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
<?php echo (int) $item->backup_count; ?>
</span>
@@ -403,7 +403,7 @@ $listDirn = $this->escape($this->state->get('list.direction'));
var label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', 'mb-rtype-' + type);
label.textContent = typeLabels[type] || type;
label.textContent = typeLabels[TYPE] || type;
div.appendChild(input);
div.appendChild(label);
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="actionlog" method="upgrade">
<name>Action Log - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="console" method="upgrade">
<name>Console - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="content" method="upgrade">
<name>Content - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-04</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="quickicon" method="upgrade">
<name>Quick Icon - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="task" method="upgrade">
<name>Task - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
@@ -7,7 +7,7 @@
-->
<extension type="plugin" group="webservices" method="upgrade">
<name>Web Services - MokoSuiteBackup</name>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
+1 -1
View File
@@ -8,7 +8,7 @@
<extension type="package" method="upgrade">
<name>Package - MokoSuiteBackup</name>
<packagename>mokosuitebackup</packagename>
<version>01.38.02</version>
<version>01.39.00</version>
<creationDate>2026-06-02</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>