Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5bbab7e72 | |||
| 18b65d30ac | |||
| f55b032cc9 | |||
| e62dba8f40 | |||
| 0619825f38 | |||
| 70d7da34b3 | |||
| 13c251196b | |||
| 4841f24eab | |||
| 64ffbb9d61 | |||
| 83e91c6fa6 | |||
| b1833825e7 | |||
| bde20e82ad | |||
| 8348d23fe4 | |||
| d9557489d5 | |||
| 089ec69595 | |||
| 7427cbb043 | |||
| 456e744d81 | |||
| 6d5ef50727 | |||
| 00e7963988 | |||
| bc06657317 | |||
| bda4b0a23d | |||
| e327f9cf5c | |||
| 5b9351e5f0 | |||
| 5785e9fd1e | |||
| 1e9c8d54f4 | |||
| 7515274712 | |||
| 0be459fe34 | |||
| 11ccdbfde4 | |||
| fd517c16f3 | |||
| fe76f81b47 | |||
| 18127454b5 | |||
| 7826c315b1 | |||
| e329dbd99b | |||
| d6b3e8cff0 |
@@ -5,7 +5,7 @@
|
||||
# FILE INFORMATION
|
||||
# DEFGROUP: Gitea.Workflow
|
||||
# INGROUP: mokocli.Automation
|
||||
# VERSION: 01.35.00
|
||||
# VERSION: 01.38.00
|
||||
# BRIEF: Auto-create feature branch when an issue is opened
|
||||
|
||||
name: "Universal: Issue Branch"
|
||||
|
||||
+17
-11
@@ -1,21 +1,27 @@
|
||||
# Changelog
|
||||
## [Unreleased]
|
||||
|
||||
## [01.35.01] --- 2026-06-23
|
||||
## [01.38.00] --- 2026-06-23
|
||||
|
||||
## [01.35.01] --- 2026-06-23
|
||||
|
||||
## [01.35.00] --- 2026-06-23
|
||||
|
||||
## [01.35.00] --- 2026-06-23
|
||||
## [01.38.00] --- 2026-06-23
|
||||
|
||||
### Added
|
||||
- SFTP remote storage with SSH key file authentication — key stored securely in database
|
||||
- CLI restore options: --files-only, --db-only, --no-preserve-config, --password
|
||||
- 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.34.00] --- 2026-06-23
|
||||
## [01.37.00] --- 2026-06-23
|
||||
|
||||
## [01.34.00] --- 2026-06-23
|
||||
## [01.37.00] --- 2026-06-23
|
||||
|
||||
### Added
|
||||
- Dashboard: snapshot widget, backup trend chart (30 days), and storage breakdown by profile (#61)
|
||||
- Run Backup button on profiles list and edit views with backup count badges (#100, #101)
|
||||
- Snapshot detail view with tabbed browser for articles, categories, and modules (#104)
|
||||
- "Do not navigate away" warning in backup and restore progress modals (#108)
|
||||
- Joomla Action Logs integration for restore, snapshot, and snapshot restore events (#110)
|
||||
- 8 comprehensive testing issues created (#111-#118)
|
||||
- Manual purge feature issue (#119)
|
||||
|
||||
## [01.36.00] --- 2026-06-23
|
||||
|
||||
## [01.36.00] --- 2026-06-23
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# MokoSuiteBackup
|
||||
|
||||
<!-- VERSION: 01.35.01 -->
|
||||
<!-- VERSION: 01.38.00 -->
|
||||
|
||||
Full-site backup and restore for Joomla — database, files, and configuration.
|
||||
|
||||
|
||||
@@ -72,23 +72,25 @@
|
||||
/>
|
||||
<field
|
||||
name="archive_name_format"
|
||||
type="text"
|
||||
type="PlaceholderText"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_ARCHIVE_NAME_FORMAT_DESC"
|
||||
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]"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="include_mokorestore"
|
||||
type="radio"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC"
|
||||
default="0"
|
||||
class="btn-group"
|
||||
>
|
||||
<option value="1">JYES</option>
|
||||
<option value="0">JNO</option>
|
||||
<option value="0">COM_MOKOJOOMBACKUP_MOKORESTORE_NONE</option>
|
||||
<option value="1">COM_MOKOJOOMBACKUP_MOKORESTORE_WRAPPED</option>
|
||||
<option value="standalone">COM_MOKOJOOMBACKUP_MOKORESTORE_STANDALONE</option>
|
||||
</field>
|
||||
<field
|
||||
name="encryption_password"
|
||||
@@ -159,7 +161,6 @@
|
||||
default="none"
|
||||
>
|
||||
<option value="none">COM_MOKOJOOMBACKUP_REMOTE_NONE</option>
|
||||
<option value="ftp">COM_MOKOJOOMBACKUP_REMOTE_FTP</option>
|
||||
<option value="sftp">COM_MOKOJOOMBACKUP_REMOTE_SFTP</option>
|
||||
<option value="google_drive">COM_MOKOJOOMBACKUP_REMOTE_GDRIVE</option>
|
||||
<option value="s3">COM_MOKOJOOMBACKUP_REMOTE_S3</option>
|
||||
@@ -203,23 +204,34 @@
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
/>
|
||||
<field
|
||||
name="sftp_auth_type"
|
||||
type="list"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC"
|
||||
default="key"
|
||||
showon="remote_storage:sftp"
|
||||
>
|
||||
<option value="password">COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD</option>
|
||||
<option value="key">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY</option>
|
||||
<option value="key_passphrase">COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE</option>
|
||||
</field>
|
||||
<field
|
||||
name="sftp_password"
|
||||
type="password"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:password"
|
||||
/>
|
||||
<field
|
||||
name="sftp_key_data"
|
||||
type="textarea"
|
||||
type="SshKey"
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC"
|
||||
rows="6"
|
||||
cols="60"
|
||||
filter="raw"
|
||||
showon="remote_storage:sftp"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:key,key_passphrase"
|
||||
addfieldprefix="Joomla\Component\MokoSuiteBackup\Administrator\Field"
|
||||
/>
|
||||
<field
|
||||
name="sftp_passphrase"
|
||||
@@ -227,7 +239,7 @@
|
||||
label="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE"
|
||||
description="COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC"
|
||||
maxlength="255"
|
||||
showon="remote_storage:sftp"
|
||||
showon="remote_storage:sftp[AND]sftp_auth_type:key_passphrase"
|
||||
/>
|
||||
<field
|
||||
name="sftp_path"
|
||||
|
||||
@@ -78,6 +78,12 @@ COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_PROFILE_NEW="New Profile"
|
||||
COM_MOKOJOOMBACKUP_PROFILE_EDIT="Edit Profile"
|
||||
|
||||
; Profile actions
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
|
||||
; Table headings
|
||||
COM_MOKOJOOMBACKUP_HEADING_DESCRIPTION="Description"
|
||||
COM_MOKOJOOMBACKUP_HEADING_PROFILE="Profile"
|
||||
@@ -127,8 +133,11 @@ 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_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_INCLUDE_MOKORESTORE="Include Restore Script"
|
||||
COM_MOKOJOOMBACKUP_FIELD_INCLUDE_MOKORESTORE_DESC="Include MokoRestore (standalone restore.php) inside the backup archive. Creates a self-contained package that can restore the site on a blank server without Joomla installed."
|
||||
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)"
|
||||
|
||||
; Exclusion filter fields
|
||||
COM_MOKOJOOMBACKUP_FIELD_EXCLUDE_DIRS="Exclude Directories"
|
||||
@@ -256,7 +265,17 @@ COM_MOKOJOOMBACKUP_FIELD_SFTP_USERNAME_DESC="Username for SSH authentication"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD="SSH Password"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSWORD_DESC="Password for SSH authentication. Leave blank if using a key file."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY="SSH Private Key"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Paste the contents of your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_DESC="Upload or paste your SSH private key (e.g. id_rsa or id_ed25519). The key is stored securely in the database and written to a temp file with 0600 permissions only during upload, then deleted. Leave blank to use password authentication."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD="Upload Key File"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE="Replace Key"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED="Key loaded"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_NONE="No key file"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR="Remove Key"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE="Authentication Type"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_AUTH_TYPE_DESC="Choose how to authenticate with the SFTP server."
|
||||
COM_MOKOJOOMBACKUP_SFTP_AUTH_PASSWORD="Password"
|
||||
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY="Key File"
|
||||
COM_MOKOJOOMBACKUP_SFTP_AUTH_KEY_PASSPHRASE="Key File + Passphrase"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE="Key Passphrase"
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PASSPHRASE_DESC="Passphrase for the private key, if encrypted. Leave blank for unencrypted keys."
|
||||
COM_MOKOJOOMBACKUP_FIELD_SFTP_PATH="Remote Path"
|
||||
@@ -405,6 +424,20 @@ COM_MOKOJOOMBACKUP_WEBCRON_IP_NONE="No IP restrictions — any IP can trigger we
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_PLACEHOLDER="Enter IP address"
|
||||
COM_MOKOJOOMBACKUP_WEBCRON_IP_ADD="Add"
|
||||
|
||||
; Snapshot browse / detail view
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE="Browse Snapshot"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES="Articles"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES="Categories"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES="Modules"
|
||||
COM_MOKOJOOMBACKUP_HEADING_STATE="State"
|
||||
COM_MOKOJOOMBACKUP_HEADING_POSITION="Position"
|
||||
COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE="Module Type"
|
||||
COM_MOKOJOOMBACKUP_HEADING_LEVEL="Level"
|
||||
COM_MOKOJOOMBACKUP_LOADING="Loading..."
|
||||
COM_MOKOJOOMBACKUP_SELECT_ALL="Select All"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_SELECTED="Restore Selected"
|
||||
COM_MOKOJOOMBACKUP_SNAPSHOT_NO_ARTICLES_SELECTED="No articles selected for restore."
|
||||
|
||||
; Errors
|
||||
COM_MOKOJOOMBACKUP_ERROR_FILE_NOT_FOUND="Backup archive file not found or has been deleted."
|
||||
COM_MOKOJOOMBACKUP_ERROR_NO_RECORD_SELECTED="No backup record selected for restore."
|
||||
|
||||
@@ -35,6 +35,10 @@ COM_MOKOJOOMBACKUP_PROFILES_TITLE="Backup Profiles"
|
||||
COM_MOKOJOOMBACKUP_TOOLBAR_BACKUP_NOW="Backup Now"
|
||||
COM_MOKOJOOMBACKUP_NO_BACKUPS="No backups found. Click 'Backup Now' to create your first backup."
|
||||
COM_MOKOJOOMBACKUP_NO_PROFILES="No backup profiles found."
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP="Run"
|
||||
COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW="Run Backup Now"
|
||||
COM_MOKOJOOMBACKUP_VIEW_BACKUPS="View Backups"
|
||||
COM_MOKOJOOMBACKUP_HEADING_BACKUPS="Backups"
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_NOTICE="To receive automatic updates, configure your <a href=\"%s\">Update Site</a> with your download key."
|
||||
COM_MOKOJOOMBACKUP_UPDATE_SITE_MISSING="MokoSuiteBackup update site not found. Reinstall the package to register the update server."
|
||||
COM_MOKOJOOMBACKUP_POSTINSTALL_UPDATE_SITE="MokoSuiteBackup installed successfully. Configure your <a href=\"%s\">Update Site</a> to receive automatic updates."
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="component" method="upgrade">
|
||||
<name>MokoSuiteBackup</name>
|
||||
<version>01.35.01</version>
|
||||
<version>01.38.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS `#__mokosuitebackup_profiles` (
|
||||
`sftp_host` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_port` INT(5) UNSIGNED NOT NULL DEFAULT 22,
|
||||
`sftp_username` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key',
|
||||
`sftp_password` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
`sftp_key_data` MEDIUMTEXT,
|
||||
`sftp_passphrase` VARCHAR(255) NOT NULL DEFAULT '',
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- MokoSuiteBackup 01.36.00 — SFTP auth type column
|
||||
|
||||
ALTER TABLE `#__mokosuitebackup_profiles`
|
||||
ADD COLUMN `sftp_auth_type` VARCHAR(20) NOT NULL DEFAULT 'key' AFTER `sftp_username`;
|
||||
@@ -622,28 +622,67 @@ class AjaxController extends BaseController
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid snapshot data']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tables = $data['tables'] ?? [];
|
||||
|
||||
// Articles
|
||||
$articles = [];
|
||||
|
||||
foreach ($data['tables']['#__content'] as $row) {
|
||||
$articles[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'catid' => (int) ($row['catid'] ?? 0),
|
||||
'state' => (int) ($row['state'] ?? 0),
|
||||
'created' => $row['created'] ?? '',
|
||||
];
|
||||
if (!empty($tables['#__content'])) {
|
||||
foreach ($tables['#__content'] as $row) {
|
||||
$articles[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'catid' => (int) ($row['catid'] ?? 0),
|
||||
'state' => (int) ($row['state'] ?? 0),
|
||||
'created' => $row['created'] ?? '',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Categories
|
||||
$categories = [];
|
||||
|
||||
if (!empty($tables['#__categories'])) {
|
||||
foreach ($tables['#__categories'] as $row) {
|
||||
$categories[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'extension' => $row['extension'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
'level' => (int) ($row['level'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Modules
|
||||
$modules = [];
|
||||
|
||||
if (!empty($tables['#__modules'])) {
|
||||
foreach ($tables['#__modules'] as $row) {
|
||||
$modules[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'module' => $row['module'] ?? '',
|
||||
'position' => $row['position'] ?? '',
|
||||
'published' => (int) ($row['published'] ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'total' => count($articles),
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'categories' => $categories,
|
||||
'modules' => $modules,
|
||||
'total_articles' => \count($articles),
|
||||
'total_categories' => \count($categories),
|
||||
'total_modules' => \count($modules),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -237,26 +237,32 @@ class BackupEngine
|
||||
$this->verifyArchive($archivePath, $profile->backup_type);
|
||||
$this->log('Archive integrity verified');
|
||||
|
||||
// Step 2.5: Wrap with MokoRestore script (if enabled)
|
||||
$includeMokoRestore = (bool) ($profile->include_mokorestore ?? false);
|
||||
// Step 2.5: MokoRestore script (if enabled)
|
||||
$mokoRestoreMode = $profile->include_mokorestore ?? '0';
|
||||
$restoreScriptPath = '';
|
||||
|
||||
if ($includeMokoRestore) {
|
||||
if ($mokoRestoreMode === '1') {
|
||||
// Wrapped mode: backup ZIP inside an outer ZIP with restore.php
|
||||
$this->log('Wrapping with MokoRestore script...');
|
||||
$mokoRestoreName = str_replace('.zip', '-mokorestore.zip', $archiveName);
|
||||
$mokoRestorePath = $this->backupDir . '/' . $mokoRestoreName;
|
||||
MokoRestore::wrap($archivePath, $mokoRestorePath);
|
||||
|
||||
// Replace the original archive with the wrapped one
|
||||
if (is_file($archivePath) && !unlink($archivePath)) {
|
||||
$this->log('WARNING: Could not remove pre-wrap archive');
|
||||
}
|
||||
rename($mokoRestorePath, $archivePath);
|
||||
$totalSize = filesize($archivePath);
|
||||
$sizeHuman = number_format($totalSize / 1048576, 2) . ' MB';
|
||||
// Recompute checksum for the final wrapped archive
|
||||
$checksum = hash_file('sha256', $archivePath);
|
||||
$this->log('MokoRestore archive created: ' . $sizeHuman);
|
||||
$this->log('SHA-256 (wrapped): ' . $checksum);
|
||||
} elseif ($mokoRestoreMode === 'standalone') {
|
||||
// Standalone mode: restore.php as a separate file next to the backup ZIP
|
||||
$this->log('Generating standalone restore.php...');
|
||||
$restoreScriptPath = $this->backupDir . '/restore.php';
|
||||
MokoRestore::generateStandalone($restoreScriptPath);
|
||||
$this->log('Standalone restore.php generated (' . number_format(filesize($restoreScriptPath)) . ' bytes)');
|
||||
}
|
||||
|
||||
$remoteFilename = '';
|
||||
@@ -277,6 +283,18 @@ class BackupEngine
|
||||
$remoteFilename = $uploadResult['remote_path'] ?? $archiveName;
|
||||
$this->log('Remote upload complete: ' . $uploadResult['message']);
|
||||
|
||||
// Upload standalone restore.php alongside the backup if in standalone mode
|
||||
if (!empty($restoreScriptPath) && is_file($restoreScriptPath)) {
|
||||
$this->log('Uploading standalone restore.php...');
|
||||
$restoreUpload = $uploader->upload($restoreScriptPath, 'restore.php');
|
||||
|
||||
if ($restoreUpload['success']) {
|
||||
$this->log('Standalone restore.php uploaded');
|
||||
} else {
|
||||
$this->log('WARNING: restore.php upload failed: ' . $restoreUpload['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete local copy if configured
|
||||
if (empty($profile->remote_keep_local) && is_file($archivePath)) {
|
||||
@unlink($archivePath);
|
||||
|
||||
@@ -54,6 +54,191 @@ class MokoRestore
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script as a separate file.
|
||||
*
|
||||
* Unlike the wrapped version, this script scans its own directory
|
||||
* for ZIP files and lets the user choose which one to restore from.
|
||||
*
|
||||
* @param string $outputPath Where to write restore.php
|
||||
*
|
||||
* @return string Path to the generated script
|
||||
*/
|
||||
public static function generateStandalone(string $outputPath): string
|
||||
{
|
||||
$script = self::generateStandaloneScript();
|
||||
|
||||
if (file_put_contents($outputPath, $script) === false) {
|
||||
throw new \RuntimeException('Cannot write standalone restore script: ' . $outputPath);
|
||||
}
|
||||
|
||||
return $outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone script content that scans for ZIPs.
|
||||
*/
|
||||
private static function generateStandaloneScript(): string
|
||||
{
|
||||
/* Take the normal backend but replace the hardcoded BACKUP_FILE
|
||||
with a directory scanner that finds ZIP files */
|
||||
$php = self::generateBackend();
|
||||
|
||||
/* Replace the fixed BACKUP_FILE constant with dynamic scanner */
|
||||
$php = str_replace(
|
||||
"define('BACKUP_FILE', RESTORE_DIR . '/site-backup.zip');",
|
||||
"/* BACKUP_FILE is set dynamically — see actionSelectBackup() below */\n" .
|
||||
"define('BACKUP_FILE', ''); /* placeholder — overridden per request */",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Inject the backup scanner function after the constants */
|
||||
$scannerCode = <<<'SCANNER'
|
||||
|
||||
/**
|
||||
* Scan the restore directory for ZIP files that look like backups.
|
||||
*/
|
||||
function scanForBackups(): array
|
||||
{
|
||||
$dir = RESTORE_DIR;
|
||||
$files = [];
|
||||
|
||||
foreach (glob($dir . '/*.zip') as $path) {
|
||||
$name = basename($path);
|
||||
|
||||
/* Skip the restore script wrapper if present */
|
||||
if ($name === 'restore.php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$files[] = [
|
||||
'name' => $name,
|
||||
'path' => $path,
|
||||
'size' => filesize($path),
|
||||
'date' => date('Y-m-d H:i:s', filemtime($path)),
|
||||
];
|
||||
}
|
||||
|
||||
/* Sort by modification time, newest first */
|
||||
usort($files, fn($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backup file selection and set the working file.
|
||||
*/
|
||||
function getSelectedBackupFile(): string
|
||||
{
|
||||
if (!empty($_POST['backup_file'])) {
|
||||
$selected = basename($_POST['backup_file']); /* sanitize — basename only */
|
||||
$path = RESTORE_DIR . '/' . $selected;
|
||||
|
||||
if (is_file($path) && str_ends_with(strtolower($selected), '.zip')) {
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-select if only one ZIP exists */
|
||||
$backups = scanForBackups();
|
||||
|
||||
if (count($backups) === 1) {
|
||||
return $backups[0]['path'];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
SCANNER;
|
||||
|
||||
/* Insert scanner after the opening PHP section but before the action handlers */
|
||||
$php = str_replace(
|
||||
"/* ── Action Handlers",
|
||||
$scannerCode . "\n/* ── Action Handlers",
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify actionExtract to use getSelectedBackupFile() instead of BACKUP_FILE */
|
||||
$php = str_replace(
|
||||
'$zip->open(BACKUP_FILE)',
|
||||
'$zip->open(getSelectedBackupFile() ?: BACKUP_FILE)',
|
||||
$php
|
||||
);
|
||||
|
||||
/* Modify the pre-checks to use getSelectedBackupFile() */
|
||||
$php = str_replace(
|
||||
"file_exists(BACKUP_FILE)",
|
||||
"(getSelectedBackupFile() !== '' || file_exists(BACKUP_FILE))",
|
||||
$php
|
||||
);
|
||||
|
||||
$html = self::generateFrontend();
|
||||
|
||||
/* Add backup file selector to the frontend before the extract step */
|
||||
$selectorHtml = <<<'SELECTOR'
|
||||
<!-- Backup File Selector (standalone mode) -->
|
||||
<div id="mr-step-select" class="mr-step" style="display:none;">
|
||||
<h2 class="mr-step-title">Select Backup File</h2>
|
||||
<p class="mr-desc">Choose which backup archive to restore from.</p>
|
||||
<div id="mr-backup-list"></div>
|
||||
<input type="hidden" name="backup_file" id="mr-backup-file" value="">
|
||||
</div>
|
||||
<script>
|
||||
(function() {
|
||||
var backups = <?php echo json_encode(scanForBackups()); ?>;
|
||||
var list = document.getElementById('mr-backup-list');
|
||||
var hiddenInput = document.getElementById('mr-backup-file');
|
||||
|
||||
if (backups.length === 0) {
|
||||
var alert = document.createElement('div');
|
||||
alert.className = 'mr-alert mr-alert-danger';
|
||||
alert.textContent = 'No ZIP files found in this directory. Upload a backup archive first.';
|
||||
list.appendChild(alert);
|
||||
} else if (backups.length === 1) {
|
||||
hiddenInput.value = backups[0].name;
|
||||
var found = document.createElement('div');
|
||||
found.className = 'mr-alert mr-alert-success';
|
||||
var strong = document.createElement('strong');
|
||||
strong.textContent = backups[0].name;
|
||||
found.appendChild(document.createTextNode('Found: '));
|
||||
found.appendChild(strong);
|
||||
found.appendChild(document.createTextNode(' (' + (backups[0].size / 1048576).toFixed(1) + ' MB)'));
|
||||
list.appendChild(found);
|
||||
} else {
|
||||
var group = document.createElement('div');
|
||||
group.className = 'mr-field-group';
|
||||
backups.forEach(function(b) {
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:block; padding:8px; margin:4px 0; border:1px solid #ddd; border-radius:4px; cursor:pointer;';
|
||||
var radio = document.createElement('input');
|
||||
radio.type = 'radio';
|
||||
radio.name = 'backup_choice';
|
||||
radio.value = b.name;
|
||||
radio.style.marginRight = '8px';
|
||||
radio.addEventListener('change', function() { hiddenInput.value = this.value; });
|
||||
label.appendChild(radio);
|
||||
var nameStrong = document.createElement('strong');
|
||||
nameStrong.textContent = b.name;
|
||||
label.appendChild(nameStrong);
|
||||
label.appendChild(document.createTextNode(' \u2014 ' + (b.size / 1048576).toFixed(1) + ' MB \u2014 ' + b.date));
|
||||
group.appendChild(label);
|
||||
});
|
||||
list.appendChild(group);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
SELECTOR;
|
||||
|
||||
/* Insert the selector before the extract step in the HTML */
|
||||
$html = str_replace(
|
||||
'<!-- Step: Extract -->',
|
||||
$selectorHtml . "\n<!-- Step: Extract -->",
|
||||
$html
|
||||
);
|
||||
|
||||
return $php . $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the standalone restore.php script.
|
||||
*
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class RestoreEngine
|
||||
{
|
||||
@@ -166,6 +167,9 @@ class RestoreEngine
|
||||
error_log('MokoSuiteBackup: Restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(true, $recordId);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => 'Restore complete from: ' . basename($archivePath),
|
||||
@@ -185,6 +189,9 @@ class RestoreEngine
|
||||
$this->recursiveDelete($this->stagingDir);
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterRestore(false, $recordId);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -285,6 +292,26 @@ class RestoreEngine
|
||||
@rmdir($dir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterRestore(bool $success, int $recordId): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterRestore', [
|
||||
'success' => $success,
|
||||
'record_id' => $recordId,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -141,7 +141,15 @@ class SftpUploader implements RemoteUploaderInterface
|
||||
$tmpDir = sys_get_temp_dir();
|
||||
$keyFile = $tmpDir . '/mokobackup-sftp-' . bin2hex(random_bytes(8)) . '.key';
|
||||
|
||||
if (file_put_contents($keyFile, $this->keyData) === false) {
|
||||
/* Key is stored base64-encoded in the database — decode before writing */
|
||||
$keyContent = base64_decode($this->keyData, true);
|
||||
|
||||
if ($keyContent === false) {
|
||||
/* Fallback: might be raw PEM (legacy or paste) */
|
||||
$keyContent = $this->keyData;
|
||||
}
|
||||
|
||||
if (file_put_contents($keyFile, $keyContent) === false) {
|
||||
throw new \RuntimeException('Cannot write temporary SSH key file');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotEngine
|
||||
{
|
||||
@@ -214,6 +215,9 @@ class SnapshotEngine
|
||||
error_log('MokoSuiteBackup: Snapshot creation notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(true, $record->id, array_values($validTypes));
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf(
|
||||
@@ -227,6 +231,9 @@ class SnapshotEngine
|
||||
} catch (\Exception $e) {
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshot(false, 0, $contentTypes);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Snapshot failed: ' . $e->getMessage(),
|
||||
@@ -327,6 +334,27 @@ class SnapshotEngine
|
||||
return $db->loadAssocList() ?: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshot event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshot(bool $success, int $snapshotId, array $contentTypes): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshot', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'content_types' => $contentTypes,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshot', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the snapshot result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshot listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
class SnapshotRestoreEngine
|
||||
{
|
||||
@@ -170,6 +171,9 @@ class SnapshotRestoreEngine
|
||||
error_log('MokoSuiteBackup: Snapshot restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Snapshot restored (%s mode): %d rows across %d tables', $mode, $totalRows, count($tablesToRestore)),
|
||||
@@ -185,6 +189,9 @@ class SnapshotRestoreEngine
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, $mode);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Restore failed: ' . $e->getMessage(),
|
||||
@@ -537,6 +544,9 @@ class SnapshotRestoreEngine
|
||||
error_log('MokoSuiteBackup: Selective restore notification failed: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(true, $snapshotId, 'selective');
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'message' => sprintf('Restored %d articles (%d total rows)', count($selectedRows), $totalRows),
|
||||
@@ -553,6 +563,9 @@ class SnapshotRestoreEngine
|
||||
|
||||
$this->log('FATAL: ' . $e->getMessage());
|
||||
|
||||
// Dispatch event for actionlog and other listeners
|
||||
$this->dispatchAfterSnapshotRestore(false, $snapshotId, 'selective');
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Selective restore failed: ' . $e->getMessage(),
|
||||
@@ -561,6 +574,27 @@ class SnapshotRestoreEngine
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch the onMokoSuiteBackupAfterSnapshotRestore event so plugins (actionlog, etc.) can react.
|
||||
*/
|
||||
private function dispatchAfterSnapshotRestore(bool $success, int $snapshotId, string $mode): void
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
|
||||
$event = new Event('onMokoSuiteBackupAfterSnapshotRestore', [
|
||||
'success' => $success,
|
||||
'snapshot_id' => $snapshotId,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
|
||||
$app->getDispatcher()->dispatch('onMokoSuiteBackupAfterSnapshotRestore', $event);
|
||||
} catch (\Throwable $e) {
|
||||
// Never let a listener failure break the restore result, but log it
|
||||
error_log('MokoSuiteBackup: onAfterSnapshotRestore listener error: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function log(string $message): void
|
||||
{
|
||||
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
||||
|
||||
@@ -100,6 +100,17 @@ class FolderPickerField extends FormField
|
||||
<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>
|
||||
@@ -155,6 +166,26 @@ class FolderPickerField extends FormField
|
||||
</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');
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<?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
|
||||
*
|
||||
* Text field with clickable placeholder pills that insert at cursor position.
|
||||
* Used for backup directory and archive name format fields.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
|
||||
class PlaceholderTextField extends FormField
|
||||
{
|
||||
protected $type = 'PlaceholderText';
|
||||
|
||||
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');
|
||||
$hint = htmlspecialchars($this->element['hint'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||
$max = (int) ($this->element['maxlength'] ?? 512);
|
||||
|
||||
$placeholderAttr = (string) ($this->element['placeholders'] ?? '');
|
||||
$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]'];
|
||||
}
|
||||
|
||||
$html = '<input type="text" name="' . $name . '" id="' . $id . '" value="' . $value . '"'
|
||||
. ' class="form-control" maxlength="' . $max . '"'
|
||||
. ($hint ? ' placeholder="' . $hint . '"' : '') . '>';
|
||||
|
||||
$html .= '<div class="mt-1" style="display:flex; flex-wrap:wrap; gap:4px;">';
|
||||
$html .= '<span class="text-muted small me-1" style="line-height:24px;">Insert:</span>';
|
||||
|
||||
foreach ($placeholders as $ph) {
|
||||
$html .= '<button type="button" class="btn btn-outline-secondary btn-sm py-0 px-1 moko-ph-insert"'
|
||||
. ' data-field="' . $id . '" data-ph="' . htmlspecialchars($ph) . '">'
|
||||
. htmlspecialchars($ph) . '</button>';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= <<<JS
|
||||
<script>
|
||||
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);
|
||||
var newPos = start + ph.length;
|
||||
target.setSelectionRange(newPos, newPos);
|
||||
target.focus();
|
||||
target.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
JS;
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<?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 SSH private key input.
|
||||
* Supports both file upload (via FileReader JS) and paste-in textarea.
|
||||
* The key content is stored in the database, not as a file on disk.
|
||||
*/
|
||||
|
||||
namespace Joomla\Component\MokoSuiteBackup\Administrator\Field;
|
||||
|
||||
defined('_JEXEC') or die;
|
||||
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
|
||||
class SshKeyField extends FormField
|
||||
{
|
||||
protected $type = 'SshKey';
|
||||
|
||||
protected function getInput(): string
|
||||
{
|
||||
$value = $this->value ?? '';
|
||||
$id = $this->id;
|
||||
$name = $this->name;
|
||||
|
||||
$hasKey = !empty($value) && str_contains($value, 'PRIVATE KEY');
|
||||
|
||||
$html = '<div id="' . htmlspecialchars($id) . '-wrapper">';
|
||||
|
||||
/* Status badge */
|
||||
if ($hasKey) {
|
||||
$html .= '<span class="badge bg-success me-2">'
|
||||
. '<span class="icon-lock" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_LOADED')
|
||||
. '</span>';
|
||||
}
|
||||
|
||||
/* File upload button */
|
||||
$html .= '<label class="btn btn-outline-secondary btn-sm" for="' . htmlspecialchars($id) . '-file">';
|
||||
$html .= '<span class="icon-upload" aria-hidden="true"></span> ';
|
||||
$html .= $hasKey ? Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_REPLACE') : Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_UPLOAD');
|
||||
$html .= '</label>';
|
||||
$html .= '<input type="file" id="' . htmlspecialchars($id) . '-file"'
|
||||
. ' accept=".pem,.key,.openssh,.ppk,*" style="display:none;"'
|
||||
. ' onchange="mokoSshKeyFileSelected(\'' . htmlspecialchars($id) . '\', this)">';
|
||||
|
||||
$html .= '<span id="' . htmlspecialchars($id) . '-status" class="ms-2 text-muted small"></span>';
|
||||
|
||||
if ($hasKey) {
|
||||
$html .= ' <button type="button" class="btn btn-sm btn-outline-danger ms-2"'
|
||||
. ' onclick="mokoSshKeyClear(\'' . htmlspecialchars($id) . '\')">'
|
||||
. '<span class="icon-times" aria-hidden="true"></span> '
|
||||
. Text::_('COM_MOKOJOOMBACKUP_FIELD_SFTP_KEY_CLEAR')
|
||||
. '</button>';
|
||||
}
|
||||
|
||||
/* Hidden field — key data is NEVER rendered as visible text.
|
||||
On existing keys, we submit a sentinel value to preserve the DB value
|
||||
unless a new file is uploaded or clear is clicked. */
|
||||
if ($hasKey) {
|
||||
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="__KEEP_EXISTING__">';
|
||||
} else {
|
||||
$html .= '<input type="hidden" name="' . htmlspecialchars($name) . '" id="' . htmlspecialchars($id) . '"'
|
||||
. ' value="">';
|
||||
}
|
||||
|
||||
$html .= '</div>';
|
||||
$html .= $this->getScript();
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
private function getScript(): string
|
||||
{
|
||||
return <<<'JS'
|
||||
<script>
|
||||
function mokoSshKeyFileSelected(fieldId, input) {
|
||||
if (!input.files || !input.files[0]) return;
|
||||
var file = input.files[0];
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
/* Base64 encode the key before storing in the hidden field */
|
||||
var content = e.target.result;
|
||||
var encoded = btoa(content);
|
||||
document.getElementById(fieldId).value = encoded;
|
||||
var status = document.getElementById(fieldId + '-status');
|
||||
if (status) status.textContent = file.name + ' uploaded';
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
function mokoSshKeyClear(fieldId) {
|
||||
document.getElementById(fieldId).value = '';
|
||||
var status = document.getElementById(fieldId + '-status');
|
||||
if (status) status.textContent = 'Key removed';
|
||||
var fileInput = document.getElementById(fieldId + '-file');
|
||||
if (fileInput) fileInput.value = '';
|
||||
}
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,13 @@ class ProfilesModel extends ListModel
|
||||
$query->select('a.*')
|
||||
->from($db->quoteName('#__mokosuitebackup_profiles', 'a'));
|
||||
|
||||
// Subquery: count of backup records per profile
|
||||
$subQuery = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->where($db->quoteName('r.profile_id') . ' = ' . $db->quoteName('a.id'));
|
||||
$query->select('(' . $subQuery . ') AS ' . $db->quoteName('backup_count'));
|
||||
|
||||
$published = $this->getState('filter.published');
|
||||
|
||||
if (is_numeric($published)) {
|
||||
|
||||
@@ -25,6 +25,23 @@ class ProfileTable extends Table
|
||||
|
||||
public function store($updateNulls = true): bool
|
||||
{
|
||||
/* Handle SSH key sentinel — when __KEEP_EXISTING__ is submitted,
|
||||
preserve the current DB value instead of overwriting with the sentinel.
|
||||
This prevents the key from being exposed in the form HTML. */
|
||||
if (isset($this->sftp_key_data) && $this->sftp_key_data === '__KEEP_EXISTING__') {
|
||||
if ($this->id) {
|
||||
$db = $this->getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('sftp_key_data'))
|
||||
->from($db->quoteName($this->_tbl))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $this->id);
|
||||
$db->setQuery($query);
|
||||
$this->sftp_key_data = $db->loadResult() ?: '';
|
||||
} else {
|
||||
$this->sftp_key_data = '';
|
||||
}
|
||||
}
|
||||
|
||||
$result = parent::store($updateNulls);
|
||||
|
||||
if ($result && !empty($this->backup_dir)) {
|
||||
|
||||
@@ -15,6 +15,9 @@ defined('_JEXEC') or die;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\MVC\View\HtmlView as BaseHtmlView;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
use Joomla\CMS\Toolbar\Toolbar;
|
||||
use Joomla\CMS\Toolbar\ToolbarHelper;
|
||||
|
||||
class HtmlView extends BaseHtmlView
|
||||
@@ -48,6 +51,27 @@ class HtmlView extends BaseHtmlView
|
||||
ToolbarHelper::save('profile.save');
|
||||
}
|
||||
|
||||
if (!$isNew) {
|
||||
$toolbar = Toolbar::getInstance();
|
||||
$profileId = (int) $this->item->id;
|
||||
|
||||
// "Run Backup Now" button — links to backup start with CSRF token
|
||||
if ($user->authorise('mokosuitebackup.backup.run', 'com_mokosuitebackup')) {
|
||||
$runUrl = Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $profileId . '&' . Session::getFormToken() . '=1');
|
||||
$toolbar->linkButton('run-backup', 'COM_MOKOJOOMBACKUP_RUN_BACKUP_NOW')
|
||||
->url($runUrl)
|
||||
->icon('icon-play')
|
||||
->buttonClass('btn btn-success');
|
||||
}
|
||||
|
||||
// "View Backups" link button
|
||||
$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')
|
||||
->buttonClass('btn btn-info');
|
||||
}
|
||||
|
||||
ToolbarHelper::cancel('profile.cancel', $isNew ? 'JTOOLBAR_CANCEL' : 'JTOOLBAR_CLOSE');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,10 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
|
||||
@@ -305,6 +305,10 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) {
|
||||
<div id="mokosuitebackup-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:500px; margin:10% auto; background:#fff; border-radius:8px; padding:2rem; box-shadow:0 4px 20px rgba(0,0,0,0.3);">
|
||||
<h3 id="mb-modal-title" style="margin:0 0 1rem;">Backup in Progress</h3>
|
||||
<div class="alert alert-warning py-1 px-2 mb-2" style="font-size:0.85rem;">
|
||||
<span class="icon-warning-circle" aria-hidden="true"></span>
|
||||
<strong>Do not navigate away or close this window</strong> while the backup is running.
|
||||
</div>
|
||||
<div style="background:#e9ecef; border-radius:4px; overflow:hidden; height:24px; margin-bottom:0.5rem;">
|
||||
<div id="mb-progress-bar" style="height:100%; background:#0d6efd; transition:width 0.3s; width:0%; display:flex; align-items:center; justify-content:center; color:#fff; font-size:0.8rem; font-weight:bold;">0%</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\LayoutHelper;
|
||||
use Joomla\CMS\Router\Route;
|
||||
use Joomla\CMS\Session\Session;
|
||||
|
||||
HTMLHelper::_('behavior.multiselect');
|
||||
|
||||
@@ -45,9 +46,15 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'COM_MOKOJOOMBACKUP_HEADING_TYPE', 'a.backup_type', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_BACKUPS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JSTATUS', 'a.published', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-10 text-center">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_ACTIONS'); ?>
|
||||
</th>
|
||||
<th scope="col" class="w-5">
|
||||
<?php echo HTMLHelper::_('searchtools.sort', 'JGRID_HEADING_ID', 'a.id', $listDirn, $listOrder); ?>
|
||||
</th>
|
||||
@@ -70,9 +77,26 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
<td>
|
||||
<?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); ?>">
|
||||
<span class="badge bg-<?php echo ($item->backup_count > 0) ? 'info' : 'secondary'; ?>">
|
||||
<?php echo (int) $item->backup_count; ?>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo HTMLHelper::_('jgrid.published', $item->published, $i, 'profiles.'); ?>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<?php if ($item->published == 1) : ?>
|
||||
<a href="<?php echo Route::_('index.php?option=com_mokosuitebackup&view=backups&task=backups.start&profile_id=' . $item->id . '&' . Session::getFormToken() . '=1'); ?>"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>">
|
||||
<span class="icon-play" aria-hidden="true"></span>
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_RUN_BACKUP'); ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo (int) $item->id; ?>
|
||||
</td>
|
||||
|
||||
@@ -99,14 +99,12 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($item->status === 'complete' && $canManage) : ?>
|
||||
<?php if (in_array('articles', $types)) : ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||
<span class="icon-search"></span>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary mb-snapshot-browse"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-desc="<?php echo $this->escape($item->description); ?>"
|
||||
title="<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?>">
|
||||
<span class="icon-search"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-success mb-snapshot-restore"
|
||||
data-id="<?php echo (int) $item->id; ?>"
|
||||
data-types="<?php echo $this->escape($item->content_types); ?>"
|
||||
@@ -235,9 +233,9 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browse Snapshot Articles Modal -->
|
||||
<!-- Browse Snapshot Detail Modal -->
|
||||
<div id="mb-snapshot-browse-modal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.6); z-index:10000;">
|
||||
<div style="max-width:700px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
||||
<div style="max-width:800px; margin:5% auto; background:#fff; border-radius:8px; box-shadow:0 4px 20px rgba(0,0,0,0.3); max-height:80vh; display:flex; flex-direction:column;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.5rem; border-bottom:1px solid #dee2e6;">
|
||||
<h4 style="margin:0;" id="mb-browse-title"><?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_BROWSE'); ?></h4>
|
||||
<button type="button" class="btn-close mb-modal-close" aria-label="Close"></button>
|
||||
@@ -251,25 +249,86 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
</div>
|
||||
<div id="mb-browse-error" class="alert alert-danger" style="display:none;"></div>
|
||||
<div id="mb-browse-content" style="display:none;">
|
||||
<div class="mb-2">
|
||||
<label class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||
</label>
|
||||
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||
|
||||
<!-- Bootstrap tabs -->
|
||||
<ul class="nav nav-tabs" id="mb-browse-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="mb-tab-articles-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-articles" type="button" role="tab" aria-controls="mb-tab-articles" aria-selected="true">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_ARTICLES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-articles-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-categories-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-categories" type="button" role="tab" aria-controls="mb-tab-categories" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_CATEGORIES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-categories-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="mb-tab-modules-btn" data-bs-toggle="tab" data-bs-target="#mb-tab-modules" type="button" role="tab" aria-controls="mb-tab-modules" aria-selected="false">
|
||||
<?php echo Text::_('COM_MOKOJOOMBACKUP_SNAPSHOT_TAB_MODULES'); ?>
|
||||
<span class="badge bg-secondary ms-1" id="mb-tab-modules-count">0</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content pt-3" id="mb-browse-tabs-content">
|
||||
|
||||
<!-- Articles tab -->
|
||||
<div class="tab-pane fade show active" id="mb-tab-articles" role="tabpanel" aria-labelledby="mb-tab-articles-btn">
|
||||
<div class="mb-2">
|
||||
<label class="form-check form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="mb-browse-select-all">
|
||||
<span class="form-check-label fw-bold"><?php echo Text::_('COM_MOKOJOOMBACKUP_SELECT_ALL'); ?></span>
|
||||
</label>
|
||||
<span class="text-muted ms-2" id="mb-browse-count"></span>
|
||||
</div>
|
||||
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"></th>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Categories tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-categories" role="tabpanel" aria-labelledby="mb-tab-categories-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-categories-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_LEVEL'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-categories-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modules tab -->
|
||||
<div class="tab-pane fade" id="mb-tab-modules" role="tabpanel" aria-labelledby="mb-tab-modules-btn">
|
||||
<table class="table table-sm table-striped" id="mb-browse-modules-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_MODULE_TYPE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_POSITION'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-modules-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<table class="table table-sm table-striped" id="mb-browse-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1"></th>
|
||||
<th>ID</th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_TITLE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_STATE'); ?></th>
|
||||
<th><?php echo Text::_('COM_MOKOJOOMBACKUP_HEADING_DATE'); ?></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="mb-browse-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:0.75rem 1.5rem; border-top:1px solid #dee2e6; text-align:right;">
|
||||
@@ -388,9 +447,16 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
document.getElementById('mb-browse-restore-btn').disabled = true;
|
||||
document.getElementById('mb-browse-select-all').checked = false;
|
||||
|
||||
// Reset to Articles tab
|
||||
var firstTab = document.querySelector('#mb-tab-articles-btn');
|
||||
if (firstTab && typeof bootstrap !== 'undefined') {
|
||||
var tab = new bootstrap.Tab(firstTab);
|
||||
tab.show();
|
||||
}
|
||||
|
||||
document.getElementById('mb-snapshot-browse-modal').style.display = 'block';
|
||||
|
||||
// Fetch articles via AJAX
|
||||
// Fetch snapshot content via AJAX
|
||||
var token = <?php echo json_encode(Session::getFormToken()); ?>;
|
||||
var url = 'index.php?option=com_mokosuitebackup&task=ajax.browseSnapshot&id=' + encodeURIComponent(id) + '&' + token + '=1';
|
||||
|
||||
@@ -405,13 +471,14 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
return;
|
||||
}
|
||||
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
var stateLabels = { '1': 'Published', '0': 'Unpublished', '-1': 'Trashed', '-2': 'Archived' };
|
||||
var stateBadges = { '1': 'bg-success', '0': 'bg-secondary', '-1': 'bg-danger', '-2': 'bg-info' };
|
||||
|
||||
data.articles.forEach(function(article) {
|
||||
// --- Articles ---
|
||||
var tbody = document.getElementById('mb-browse-tbody');
|
||||
while (tbody.firstChild) tbody.removeChild(tbody.firstChild);
|
||||
|
||||
(data.articles || []).forEach(function(article) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdCheck = document.createElement('td');
|
||||
@@ -445,12 +512,84 @@ $listDirn = $this->escape($this->state->get('list.direction'));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-browse-count').textContent = data.total + ' article(s)';
|
||||
document.getElementById('mb-browse-count').textContent = data.total_articles + ' article(s)';
|
||||
document.getElementById('mb-tab-articles-count').textContent = data.total_articles;
|
||||
|
||||
// --- Categories ---
|
||||
var catTbody = document.getElementById('mb-browse-categories-tbody');
|
||||
while (catTbody.firstChild) catTbody.removeChild(catTbody.firstChild);
|
||||
|
||||
(data.categories || []).forEach(function(cat) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = cat.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = '\u2003'.repeat(Math.max(0, cat.level - 1)) + cat.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdExt = document.createElement('td');
|
||||
tdExt.textContent = cat.extension;
|
||||
tr.appendChild(tdExt);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(cat.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(cat.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
var tdLevel = document.createElement('td');
|
||||
tdLevel.textContent = cat.level;
|
||||
tr.appendChild(tdLevel);
|
||||
|
||||
catTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-categories-count').textContent = data.total_categories;
|
||||
|
||||
// --- Modules ---
|
||||
var modTbody = document.getElementById('mb-browse-modules-tbody');
|
||||
while (modTbody.firstChild) modTbody.removeChild(modTbody.firstChild);
|
||||
|
||||
(data.modules || []).forEach(function(mod) {
|
||||
var tr = document.createElement('tr');
|
||||
|
||||
var tdId = document.createElement('td');
|
||||
tdId.textContent = mod.id;
|
||||
tr.appendChild(tdId);
|
||||
|
||||
var tdTitle = document.createElement('td');
|
||||
tdTitle.textContent = mod.title;
|
||||
tr.appendChild(tdTitle);
|
||||
|
||||
var tdType = document.createElement('td');
|
||||
tdType.textContent = mod.module;
|
||||
tr.appendChild(tdType);
|
||||
|
||||
var tdPos = document.createElement('td');
|
||||
tdPos.textContent = mod.position;
|
||||
tr.appendChild(tdPos);
|
||||
|
||||
var tdState = document.createElement('td');
|
||||
var badge = document.createElement('span');
|
||||
badge.className = 'badge ' + (stateBadges[String(mod.published)] || 'bg-secondary');
|
||||
badge.textContent = stateLabels[String(mod.published)] || 'Unknown';
|
||||
tdState.appendChild(badge);
|
||||
tr.appendChild(tdState);
|
||||
|
||||
modTbody.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('mb-tab-modules-count').textContent = data.total_modules;
|
||||
|
||||
document.getElementById('mb-browse-content').style.display = 'block';
|
||||
})
|
||||
.catch(function(err) {
|
||||
document.getElementById('mb-browse-loading').style.display = 'none';
|
||||
document.getElementById('mb-browse-error').textContent = 'Failed to load articles: ' + err.message;
|
||||
document.getElementById('mb-browse-error').textContent = 'Failed to load snapshot content: ' + err.message;
|
||||
document.getElementById('mb-browse-error').style.display = 'block';
|
||||
});
|
||||
});
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
+6
@@ -7,3 +7,9 @@ PLG_ACTIONLOG_MOKOJOOMBACKUP_PROFILE_DELETED="User {username} deleted backup pro
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RECORD_DELETED="User {username} deleted backup record "{title}" (ID: {id})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_COMPLETE="Backup completed: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_BACKUP_FAILED="Backup FAILED: "{title}" (ID: {id}, profile: {profile_id}, origin: {origin})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE="User {username} restored backup #{id}"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED="User {username} attempted to restore backup #{id} but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED="User {username} created content snapshot (ID: {id}, types: {content_types})"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED="User {username} attempted to create content snapshot but it FAILED"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE="User {username} restored snapshot #{id} ({mode} mode)"
|
||||
PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED="User {username} attempted to restore snapshot #{id} ({mode} mode) but it FAILED"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="actionlog" method="upgrade">
|
||||
<name>Action Log - MokoSuiteBackup</name>
|
||||
<version>01.35.01</version>
|
||||
<version>01.38.00</version>
|
||||
<creationDate>2026-06-04</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
+92
-1
@@ -27,7 +27,10 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
return [
|
||||
'onContentAfterSave' => 'onContentAfterSave',
|
||||
'onContentAfterDelete' => 'onContentAfterDelete',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRun' => 'onMokoSuiteBackupAfterRun',
|
||||
'onMokoSuiteBackupAfterRestore' => 'onMokoSuiteBackupAfterRestore',
|
||||
'onMokoSuiteBackupAfterSnapshot' => 'onMokoSuiteBackupAfterSnapshot',
|
||||
'onMokoSuiteBackupAfterSnapshotRestore' => 'onMokoSuiteBackupAfterSnapshotRestore',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -130,6 +133,94 @@ final class MokoSuiteBackupActionlog extends CMSPlugin implements SubscriberInte
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a backup is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$recordId = $args['record_id'] ?? 0;
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $recordId,
|
||||
'title' => 'Backup #' . $recordId,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.backup',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a content snapshot is created.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshot(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$contentTypes = $args['content_types'] ?? [];
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_CREATED'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'content_types' => implode(', ', $contentTypes),
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log when a snapshot is restored.
|
||||
*/
|
||||
public function onMokoSuiteBackupAfterSnapshotRestore(Event $event): void
|
||||
{
|
||||
$args = $event->getArguments();
|
||||
|
||||
$success = $args['success'] ?? false;
|
||||
$snapshotId = $args['snapshot_id'] ?? 0;
|
||||
$mode = $args['mode'] ?? 'replace';
|
||||
|
||||
$messageKey = $success
|
||||
? 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_COMPLETE'
|
||||
: 'PLG_ACTIONLOG_MOKOJOOMBACKUP_SNAPSHOT_RESTORE_FAILED';
|
||||
|
||||
$this->addLog(
|
||||
[
|
||||
$messageKey,
|
||||
'id' => $snapshotId,
|
||||
'title' => 'Snapshot #' . $snapshotId,
|
||||
'mode' => $mode,
|
||||
'userid' => $this->getCurrentUserId(),
|
||||
'username' => $this->getCurrentUserName(),
|
||||
],
|
||||
$messageKey,
|
||||
'com_mokosuitebackup.snapshot',
|
||||
$this->getCurrentUserId()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an action log entry.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
-->
|
||||
<extension type="plugin" group="console" method="upgrade">
|
||||
<name>Console - MokoSuiteBackup</name>
|
||||
<version>01.35.01</version>
|
||||
<version>01.38.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.35.01</version>
|
||||
<version>01.38.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.35.01</version>
|
||||
<version>01.38.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.35.01</version>
|
||||
<version>01.38.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.35.01</version>
|
||||
<version>01.38.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.35.01</version>
|
||||
<version>01.38.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<extension type="package" method="upgrade">
|
||||
<name>Package - MokoSuiteBackup</name>
|
||||
<packagename>mokosuitebackup</packagename>
|
||||
<version>01.35.01</version>
|
||||
<version>01.38.00</version>
|
||||
<creationDate>2026-06-02</creationDate>
|
||||
<author>Moko Consulting</author>
|
||||
<authorEmail>hello@mokoconsulting.tech</authorEmail>
|
||||
|
||||
Reference in New Issue
Block a user