46daabc34f
Universal: PR Check / Branch Policy (pull_request) Failing after 1s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 6s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Generic: Project CI / Lint & Validate (pull_request) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 40s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 41s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report: Scripts Governance (pull_request) Has been cancelled
Generic: Repo Health / Report: Repository Health (pull_request) Has been cancelled
Follow-up to the legacy remote-storage removal — three consumers still referenced columns this branch drops: - 02.52.25.sql: use plain DROP COLUMN instead of DROP COLUMN IF EXISTS. IF EXISTS on DROP COLUMN is a MariaDB-only extension and errors on Oracle MySQL 8.x (which Joomla also supports); the columns always exist here, so the guard is unnecessary and the migration is now portable. - AkeebaImporter::mapToMokoProfile(): stop inserting the 19 dropped remote_storage/ftp_*/gdrive_*/s3_* columns (would fatal with "Unknown column" on Akeeba import). Remote settings now live in the remotes table and are re-added on the profile Remote tab after import. - AjaxController::browseSftpDir() + SftpPathField: remove. These were the legacy single-SFTP path picker, orphaned when the SftpPath form field was removed; they read now-dropped sftp_* columns. Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
497 lines
14 KiB
PHP
497 lines
14 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package MokoSuiteBackup
|
|
* @subpackage com_mokosuitebackup
|
|
* @author Moko Consulting <hello@mokoconsulting.tech>
|
|
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
|
|
* @license GNU General Public License version 3 or later; see LICENSE
|
|
*
|
|
* Imports Akeeba Backup Pro profiles and backup history into MokoSuiteBackup.
|
|
*
|
|
* Reads from #__ak_profiles and #__ak_stats, maps Akeeba's configuration
|
|
* format to MokoSuiteBackup's individual column format.
|
|
*
|
|
* Akeeba config format:
|
|
* INI-style with dot-notation keys, e.g.:
|
|
* akeeba.basic.backup_type=full
|
|
* akeeba.advanced.archiver_engine=zip
|
|
* akeeba.advanced.proc_engine=none
|
|
*
|
|
* Akeeba filter format:
|
|
* JSON with structure like:
|
|
* {"directories": {"include": [...], "exclude": [...]},
|
|
* "files": {"include": [...], "exclude": [...]},
|
|
* "databases": {"include": {...}, "exclude": {...}}}
|
|
*/
|
|
|
|
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
|
|
|
|
defined('_JEXEC') or die;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
|
|
|
|
class AkeebaImporter
|
|
{
|
|
private array $log = [];
|
|
|
|
/**
|
|
* Check if Akeeba Backup tables exist in the database.
|
|
*
|
|
* @return array{profiles: bool, stats: bool}
|
|
*/
|
|
public function detect(): array
|
|
{
|
|
$db = Factory::getDbo();
|
|
$tables = $db->getTableList();
|
|
$prefix = $db->getPrefix();
|
|
|
|
return [
|
|
'profiles' => in_array($prefix . 'ak_profiles', $tables),
|
|
'stats' => in_array($prefix . 'ak_stats', $tables),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Preview Akeeba profiles without importing.
|
|
*
|
|
* @return array List of Akeeba profiles with parsed settings
|
|
*/
|
|
public function preview(): array
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__ak_profiles'))
|
|
->order($db->quoteName('id') . ' ASC');
|
|
$db->setQuery($query);
|
|
$akProfiles = $db->loadObjectList();
|
|
|
|
$previews = [];
|
|
|
|
foreach ($akProfiles as $akProfile) {
|
|
$config = $this->parseAkeebaConfig($akProfile->configuration ?? '');
|
|
$filters = $this->parseAkeebaFilters($akProfile->filters ?? '');
|
|
|
|
$previews[] = [
|
|
'akeeba_id' => $akProfile->id,
|
|
'title' => $akProfile->description,
|
|
'backup_type' => $this->mapBackupType($config),
|
|
'archive_format' => $this->mapArchiveFormat($config),
|
|
'remote_storage' => $this->mapRemoteStorage($config),
|
|
'exclude_dirs_count' => count($filters['exclude_dirs']),
|
|
'exclude_tables_count' => count($filters['exclude_tables']),
|
|
];
|
|
}
|
|
|
|
return $previews;
|
|
}
|
|
|
|
/**
|
|
* Import all Akeeba profiles into MokoSuiteBackup.
|
|
*
|
|
* @param bool $importHistory Also import backup history from #__ak_stats
|
|
*
|
|
* @return array{success: bool, message: string, profiles_imported: int, records_imported: int}
|
|
*/
|
|
public function import(bool $importHistory = false): array
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
$detection = $this->detect();
|
|
|
|
if (!$detection['profiles']) {
|
|
return [
|
|
'success' => false,
|
|
'message' => 'Akeeba Backup tables not found. Is Akeeba Backup Pro installed?',
|
|
'profiles_imported' => 0,
|
|
'records_imported' => 0,
|
|
];
|
|
}
|
|
|
|
// Load Akeeba profiles
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__ak_profiles'))
|
|
->order($db->quoteName('id') . ' ASC');
|
|
$db->setQuery($query);
|
|
$akProfiles = $db->loadObjectList();
|
|
|
|
$profilesImported = 0;
|
|
$profileIdMap = []; // akeeba_id => mokosuitebackup_id
|
|
|
|
foreach ($akProfiles as $akProfile) {
|
|
$config = $this->parseAkeebaConfig($akProfile->configuration ?? '');
|
|
$filters = $this->parseAkeebaFilters($akProfile->filters ?? '');
|
|
|
|
$mokoProfile = $this->mapToMokoProfile($akProfile, $config, $filters);
|
|
|
|
$db->insertObject('#__mokosuitebackup_profiles', $mokoProfile, 'id');
|
|
$profileIdMap[$akProfile->id] = $mokoProfile->id;
|
|
$profilesImported++;
|
|
|
|
$this->log('Imported profile: "' . $akProfile->description . '" (Akeeba #' . $akProfile->id . ' → MokoSuiteBackup #' . $mokoProfile->id . ')');
|
|
}
|
|
|
|
// Import backup history
|
|
$recordsImported = 0;
|
|
|
|
if ($importHistory && $detection['stats']) {
|
|
$recordsImported = $this->importHistory($profileIdMap);
|
|
}
|
|
|
|
$this->log('Import complete: ' . $profilesImported . ' profiles, ' . $recordsImported . ' records');
|
|
|
|
return [
|
|
'success' => true,
|
|
'message' => 'Imported ' . $profilesImported . ' profiles' . ($recordsImported ? ' and ' . $recordsImported . ' backup records' : ''),
|
|
'profiles_imported' => $profilesImported,
|
|
'records_imported' => $recordsImported,
|
|
'log' => implode("\n", $this->log),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Import backup history from #__ak_stats.
|
|
*/
|
|
private function importHistory(array $profileIdMap): int
|
|
{
|
|
$db = Factory::getDbo();
|
|
|
|
$query = $db->getQuery(true)
|
|
->select('*')
|
|
->from($db->quoteName('#__ak_stats'))
|
|
->order($db->quoteName('id') . ' ASC');
|
|
$db->setQuery($query);
|
|
$akStats = $db->loadObjectList();
|
|
|
|
$imported = 0;
|
|
|
|
foreach ($akStats as $stat) {
|
|
$mokoProfileId = $profileIdMap[$stat->profile_id] ?? 1;
|
|
|
|
$statusMap = [
|
|
'complete' => 'complete',
|
|
'ok' => 'complete',
|
|
'run' => 'running',
|
|
'fail' => 'fail',
|
|
'idle' => 'pending',
|
|
];
|
|
|
|
$record = (object) [
|
|
'profile_id' => $mokoProfileId,
|
|
'description' => $stat->description ?? 'Imported from Akeeba #' . $stat->id,
|
|
'status' => $statusMap[$stat->status ?? ''] ?? 'complete',
|
|
'origin' => $stat->origin ?? 'backend',
|
|
'backup_type' => $stat->type ?? 'full',
|
|
'archivename' => $stat->archivename ?? '',
|
|
'absolute_path' => $stat->absolute_path ?? '',
|
|
'total_size' => (int) ($stat->total_size ?? 0),
|
|
'db_size' => 0,
|
|
'files_count' => 0,
|
|
'tables_count' => 0,
|
|
'multipart' => (int) ($stat->multipart ?? 0),
|
|
'tag' => $stat->tag ?? '',
|
|
'backupstart' => $stat->backupstart ?? '0000-00-00 00:00:00',
|
|
'backupend' => $stat->backupend ?? '0000-00-00 00:00:00',
|
|
'filesexist' => (int) ($stat->filesexist ?? 0),
|
|
'remote_filename' => $stat->remote_filename ?? '',
|
|
'log' => 'Imported from Akeeba Backup record #' . $stat->id,
|
|
];
|
|
|
|
$db->insertObject('#__mokosuitebackup_records', $record, 'id');
|
|
$imported++;
|
|
}
|
|
|
|
$this->log('Imported ' . $imported . ' backup records from Akeeba history');
|
|
|
|
return $imported;
|
|
}
|
|
|
|
/**
|
|
* Map an Akeeba profile to a MokoSuiteBackup profile object.
|
|
*/
|
|
private function mapToMokoProfile(object $akProfile, array $config, array $filters): object
|
|
{
|
|
$now = date('Y-m-d H:i:s');
|
|
|
|
return (object) [
|
|
'title' => $akProfile->description ?: 'Imported Profile #' . $akProfile->id,
|
|
'description' => 'Imported from Akeeba Backup profile #' . $akProfile->id,
|
|
'backup_type' => $this->mapBackupType($config),
|
|
'archive_format' => $this->mapArchiveFormat($config),
|
|
'compression_level' => $this->mapCompressionLevel($config),
|
|
'split_size' => $this->mapSplitSize($config),
|
|
'backup_dir' => $this->mapBackupDir($config),
|
|
'exclude_dirs' => implode("\n", $filters['exclude_dirs']),
|
|
'exclude_files' => implode("\n", $filters['exclude_files']),
|
|
'exclude_tables' => implode("\n", $filters['exclude_tables']),
|
|
// Remote storage is no longer stored on the profile — it lives in
|
|
// #__mokosuitebackup_remotes. Akeeba remote settings are not imported;
|
|
// re-add remote destinations on the profile's Remote tab after import.
|
|
'remote_keep_local' => 1,
|
|
'include_mokorestore' => (int) (($config['akeeba.advanced.embedded_installer'] ?? 'none') !== 'none'),
|
|
'published' => 1,
|
|
'created' => $now,
|
|
'modified' => $now,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Parse Akeeba's INI-format configuration into a flat key-value array.
|
|
*
|
|
* Akeeba uses a custom format:
|
|
* akeeba.basic.backup_type=full
|
|
* akeeba.advanced.archiver_engine=zip
|
|
* engine.postproc.ftp.host=ftp.example.com
|
|
*
|
|
* Some versions use JSON-encoded configuration instead.
|
|
*/
|
|
private function parseAkeebaConfig(string $raw): array
|
|
{
|
|
$raw = trim($raw);
|
|
|
|
if (empty($raw)) {
|
|
return [];
|
|
}
|
|
|
|
// Try JSON first (newer Akeeba versions)
|
|
$jsonData = json_decode($raw, true);
|
|
|
|
if (is_array($jsonData)) {
|
|
return $this->flattenConfig($jsonData);
|
|
}
|
|
|
|
// Fall back to INI-style parsing
|
|
$config = [];
|
|
$lines = explode("\n", str_replace("\r", '', $raw));
|
|
|
|
foreach ($lines as $line) {
|
|
$line = trim($line);
|
|
|
|
if (empty($line) || $line[0] === ';' || $line[0] === '#') {
|
|
continue;
|
|
}
|
|
|
|
$pos = strpos($line, '=');
|
|
|
|
if ($pos === false) {
|
|
continue;
|
|
}
|
|
|
|
$key = trim(substr($line, 0, $pos));
|
|
$value = trim(substr($line, $pos + 1));
|
|
|
|
// Remove surrounding quotes
|
|
if (strlen($value) >= 2 && $value[0] === '"' && $value[strlen($value) - 1] === '"') {
|
|
$value = substr($value, 1, -1);
|
|
}
|
|
|
|
$config[$key] = $value;
|
|
}
|
|
|
|
return $config;
|
|
}
|
|
|
|
/**
|
|
* Flatten a nested JSON config into dot-notation keys.
|
|
*/
|
|
private function flattenConfig(array $data, string $prefix = ''): array
|
|
{
|
|
$result = [];
|
|
|
|
foreach ($data as $key => $value) {
|
|
$fullKey = $prefix ? $prefix . '.' . $key : $key;
|
|
|
|
if (is_array($value) && !array_is_list($value)) {
|
|
$result = array_merge($result, $this->flattenConfig($value, $fullKey));
|
|
} else {
|
|
$result[$fullKey] = is_array($value) ? json_encode($value) : (string) $value;
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Parse Akeeba's filter configuration.
|
|
*
|
|
* Akeeba filters can be JSON or serialized PHP. Structure:
|
|
* {"directories": ["/path1", "/path2"],
|
|
* "files": ["file1.txt"],
|
|
* "databases": {"tables": ["#__session"]},
|
|
* "extradirs": [...],
|
|
* "regexfiles": [...],
|
|
* "regexdirectories": [...],
|
|
* "regexdbtables": [...]}
|
|
*
|
|
* Also supports per-root format:
|
|
* {"[SITEROOT]": {"directories": [...], "files": [...]}}
|
|
*/
|
|
private function parseAkeebaFilters(string $raw): array
|
|
{
|
|
$result = [
|
|
'exclude_dirs' => [],
|
|
'exclude_files' => [],
|
|
'exclude_tables' => [],
|
|
];
|
|
|
|
$raw = trim($raw);
|
|
|
|
if (empty($raw)) {
|
|
return $result;
|
|
}
|
|
|
|
// Parse as JSON only — unserialize is an object injection risk
|
|
$data = json_decode($raw, true);
|
|
|
|
if (!is_array($data)) {
|
|
// Older Akeeba versions used serialized PHP — skip rather than risk object injection
|
|
return $result;
|
|
}
|
|
|
|
// Extract directory exclusions
|
|
$this->extractFilterValues($data, 'directories', $result['exclude_dirs']);
|
|
$this->extractFilterValues($data, 'extradirs', $result['exclude_dirs']);
|
|
|
|
// Extract file exclusions
|
|
$this->extractFilterValues($data, 'files', $result['exclude_files']);
|
|
$this->extractFilterValues($data, 'skipfiles', $result['exclude_files']);
|
|
|
|
// Extract table exclusions
|
|
$this->extractFilterValues($data, 'databases', $result['exclude_tables']);
|
|
$this->extractFilterValues($data, 'tables', $result['exclude_tables']);
|
|
$this->extractFilterValues($data, 'dbtables', $result['exclude_tables']);
|
|
|
|
// Handle per-root format: {"[SITEROOT]": {"directories": [...]}}
|
|
foreach ($data as $key => $value) {
|
|
if (is_array($value) && (str_contains($key, 'SITEROOT') || str_contains($key, 'SITEDB'))) {
|
|
$this->extractFilterValues($value, 'directories', $result['exclude_dirs']);
|
|
$this->extractFilterValues($value, 'files', $result['exclude_files']);
|
|
$this->extractFilterValues($value, 'tables', $result['exclude_tables']);
|
|
$this->extractFilterValues($value, 'databases', $result['exclude_tables']);
|
|
}
|
|
}
|
|
|
|
// Deduplicate
|
|
$result['exclude_dirs'] = array_values(array_unique($result['exclude_dirs']));
|
|
$result['exclude_files'] = array_values(array_unique($result['exclude_files']));
|
|
$result['exclude_tables'] = array_values(array_unique($result['exclude_tables']));
|
|
|
|
// Clean up paths — remove SITEROOT prefix
|
|
$result['exclude_dirs'] = array_map(function ($path) {
|
|
$path = str_replace(['[SITEROOT]', '[SITEDB]'], '', $path);
|
|
|
|
return ltrim($path, '/\\');
|
|
}, $result['exclude_dirs']);
|
|
|
|
$result['exclude_dirs'] = array_filter($result['exclude_dirs'], fn($v) => $v !== '');
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Recursively extract filter values from nested arrays.
|
|
*/
|
|
private function extractFilterValues(array $data, string $key, array &$target): void
|
|
{
|
|
if (isset($data[$key])) {
|
|
$value = $data[$key];
|
|
|
|
if (is_array($value)) {
|
|
foreach ($value as $k => $v) {
|
|
if (is_string($v) && !empty($v)) {
|
|
$target[] = $v;
|
|
} elseif (is_string($k) && !empty($k)) {
|
|
$target[] = $k;
|
|
}
|
|
}
|
|
} elseif (is_string($value) && !empty($value)) {
|
|
$target[] = $value;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Mapping methods ────────────────────────────────────────────
|
|
|
|
private function mapBackupType(array $config): string
|
|
{
|
|
$type = $config['akeeba.basic.backup_type'] ?? 'full';
|
|
|
|
return match ($type) {
|
|
'dbonly' => 'database',
|
|
'filesonly' => 'files',
|
|
default => 'full',
|
|
};
|
|
}
|
|
|
|
private function mapArchiveFormat(array $config): string
|
|
{
|
|
$engine = $config['akeeba.advanced.archiver_engine'] ?? 'zip';
|
|
|
|
// We only support ZIP; map all Akeeba formats to zip
|
|
return 'zip';
|
|
}
|
|
|
|
private function mapCompressionLevel(array $config): int
|
|
{
|
|
$level = $config['engine.archiver.zip.compression_level'] ?? '';
|
|
|
|
if ($level === '' || $level === 'default') {
|
|
return 5;
|
|
}
|
|
|
|
$level = (int) $level;
|
|
|
|
return max(0, min(9, $level));
|
|
}
|
|
|
|
private function mapSplitSize(array $config): int
|
|
{
|
|
$size = $config['engine.archiver.zip.part_size'] ?? 0;
|
|
|
|
if (empty($size)) {
|
|
return 0;
|
|
}
|
|
|
|
// Akeeba stores in bytes, we store in MB
|
|
return max(0, (int) ($size / 1048576));
|
|
}
|
|
|
|
private function mapBackupDir(array $config): string
|
|
{
|
|
$dir = $config['akeeba.basic.output_directory'] ?? '';
|
|
|
|
if (empty($dir) || $dir === '[DEFAULT_OUTPUT]') {
|
|
return BackupDirectory::DEFAULT_RELATIVE;
|
|
}
|
|
|
|
// Convert absolute path to relative
|
|
if (defined('JPATH_ROOT') && str_starts_with($dir, JPATH_ROOT)) {
|
|
$dir = ltrim(substr($dir, strlen(JPATH_ROOT)), '/\\');
|
|
}
|
|
|
|
return $dir ?: BackupDirectory::DEFAULT_RELATIVE;
|
|
}
|
|
|
|
private function mapRemoteStorage(array $config): string
|
|
{
|
|
$engine = $config['akeeba.advanced.proc_engine'] ?? 'none';
|
|
|
|
return match ($engine) {
|
|
'ftp', 'ftpcurl', 'sftp', 'sftpcurl' => 'ftp',
|
|
'googledrive' => 'google_drive',
|
|
's3', 'amazons3' => 's3',
|
|
'dropbox', 'onedrive', 'box' => 'none', // not yet supported
|
|
default => 'none',
|
|
};
|
|
}
|
|
|
|
private function log(string $message): void
|
|
{
|
|
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
|
|
}
|
|
}
|