Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/AkeebaImporter.php
T
jmiller 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
fix(remote-cleanup): resolve dropped-column references found in review
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
2026-07-04 15:17:54 -05:00

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;
}
}