* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. * @license GNU General Public License version 3 or later; see LICENSE */ namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; defined('_JEXEC') or die; use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; /** * Enforces per-profile backup retention — the single retention authority. * * A profile may cap retained backups by age (retention_days) and/or by * number of copies (retention_count). When a profile leaves a rule at 0 the * caller-supplied global default is used instead. A backup is pruned when * EITHER rule matches: it is older than the effective days OR it falls outside * the newest effective count of copies. * * Pruning a record removes, in this order: * 1. the DB row (#__mokosuitebackup_records), * 2. the local archive + its .log file, and * 3. the archive copy on every enabled remote destination for the profile. * * The standalone restore script (restore.php) is intentionally NOT deleted * from remotes: it is a single, fixed-name file that every backup overwrites, * so newer backups still depend on it — removing it while pruning an old * archive would break restore for the copies that remain. * * Because records do not store which destinations they were uploaded to, * remote deletion targets the profile's *currently enabled* remotes. Remote * deletes are idempotent (an already-absent file counts as success) and * best-effort: a failing remote is logged but never blocks local pruning. */ final class RetentionManager { /** * Prune old backups for a profile according to its retention settings. * * Called after a backup completes and from the periodic admin-side cleanup. * Only 'complete' and 'warning' records are considered — pending/running/ * failed records are never pruned here. * * @param object $db Database driver * @param object $profile Profile row (needs id, retention_days, retention_count) * @param int $globalDays Fallback max age (days) when the profile's is 0 * @param int $globalCount Fallback max copies when the profile's is 0 * * @return int Number of backup records deleted */ public static function prune(object $db, object $profile, int $globalDays = 0, int $globalCount = 0): int { $days = (int) ($profile->retention_days ?? 0); $count = (int) ($profile->retention_count ?? 0); // A profile value of 0 means "use the global default". $days = $days > 0 ? $days : $globalDays; $count = $count > 0 ? $count : $globalCount; // No retention configured — nothing to do. if ($days <= 0 && $count <= 0) { return 0; } // Newest first, so the index is the copy's position from the top. $query = $db->getQuery(true) ->select($db->quoteName(['id', 'archivename', 'absolute_path', 'backupstart'])) ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')') ->order($db->quoteName('backupstart') . ' DESC'); $db->setQuery($query); $records = $db->loadObjectList() ?: []; if (empty($records)) { return 0; } $cutoffTs = $days > 0 ? (time() - ($days * 86400)) : null; $remotes = self::loadEnabledRemotes($db, (int) $profile->id); $deleted = 0; foreach ($records as $index => $record) { $tooOld = $cutoffTs !== null && strtotime((string) $record->backupstart) < $cutoffTs; $overCount = $count > 0 && $index >= $count; // Delete-if-either: prune when age OR count rule is exceeded. if (!$tooOld && !$overCount) { continue; } if (self::deleteRecord($db, $record, $remotes)) { $deleted++; } } return $deleted; } /** * Delete backup records whose profile no longer exists. * * Orphans have no owning profile and therefore no remote destinations to * reconcile, so only the local archive/log and DB row are removed. * * @param object $db Database driver * * @return int Number of orphaned records deleted */ public static function pruneOrphans(object $db): int { $query = $db->getQuery(true) ->select($db->quoteName(['r.id', 'r.archivename', 'r.absolute_path'], ['id', 'archivename', 'absolute_path'])) ->from($db->quoteName('#__mokosuitebackup_records', 'r')) ->join( 'LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p') . ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id') ) ->where($db->quoteName('p.id') . ' IS NULL') ->where($db->quoteName('r.status') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')'); $db->setQuery($query); $orphans = $db->loadObjectList() ?: []; $deleted = 0; foreach ($orphans as $record) { if (self::deleteRecord($db, $record, [])) { $deleted++; } } return $deleted; } /** * Delete a single backup record, its on-disk archive + log file, and the * archive copy on each enabled remote destination. * * The DB row is removed first; the local files are only unlinked if that * succeeds, so a failed delete never orphans the record from its files. * Remote deletion is best-effort and runs regardless. * * @param object $db Database driver * @param object $record Record row (needs id, archivename, absolute_path) * @param object[] $remotes Enabled remote rows (type, params) for the profile */ private static function deleteRecord(object $db, object $record, array $remotes): bool { $query = $db->getQuery(true) ->delete($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('id') . ' = ' . (int) $record->id); $db->setQuery($query); try { $db->execute(); } catch (\Throwable $e) { error_log('MokoSuiteBackup: retention could not delete record ' . $record->id . ': ' . $e->getMessage()); return false; } $archivePath = (string) ($record->absolute_path ?? ''); if ($archivePath !== '' && is_file($archivePath)) { @unlink($archivePath); $logPath = BackupDirectory::logPathFromArchive($archivePath); if (is_file($logPath)) { @unlink($logPath); } } self::deleteFromRemotes($record, $remotes); return true; } /** * Remove the record's archive from every enabled remote destination. * * Best-effort: each remote is tried independently and failures are logged, * never thrown, so one unreachable destination can't stall retention. * * @param object $record Record row (needs archivename) * @param object[] $remotes Enabled remote rows (type, params) */ private static function deleteFromRemotes(object $record, array $remotes): void { $archiveName = (string) ($record->archivename ?? ''); if ($archiveName === '' || empty($remotes)) { return; } foreach ($remotes as $remote) { try { $params = json_decode((string) ($remote->params ?? ''), true) ?: []; $uploader = RemoteUploaderFactory::create((string) $remote->type, $params); $result = $uploader->delete($archiveName); if (empty($result['success'])) { error_log( 'MokoSuiteBackup: retention could not delete ' . $archiveName . ' from ' . $remote->type . ' remote: ' . ($result['message'] ?? 'unknown error') ); } } catch (\Throwable $e) { error_log( 'MokoSuiteBackup: retention remote-delete error for ' . $archiveName . ' (' . ($remote->type ?? '?') . '): ' . $e->getMessage() ); } } } /** * Load the profile's enabled remote destinations (type + params only). * * Returns an empty array if the remotes table is missing (very old installs) * or the profile has none, so callers can iterate unconditionally. */ private static function loadEnabledRemotes(object $db, int $profileId): array { try { $query = $db->getQuery(true) ->select($db->quoteName(['type', 'params'])) ->from($db->quoteName('#__mokosuitebackup_remotes')) ->where($db->quoteName('profile_id') . ' = ' . $profileId) ->where($db->quoteName('enabled') . ' = 1') ->order($db->quoteName('ordering') . ' ASC'); $db->setQuery($query); return $db->loadObjectList() ?: []; } catch (\Throwable $e) { return []; } } }