Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/FtpUploader.php
T
jmiller 447f7b572e
Universal: PR Check / Require Docs Update (pull_request) Has been skipped
Universal: PR Check / Wiki Update Reminder (pull_request) Has been skipped
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Secret Scan (pull_request) Successful in 10s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Generic: Project CI / Lint & Validate (pull_request) Successful in 52s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 1m8s
Generic: Project CI / Tests (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
feat: prune remote backups on retention + consolidate backup helpers
Retention (RetentionManager and the plugin's hourly cleanup) only ever
deleted local files; remote archives grew unbounded. Add an idempotent
delete() to RemoteUploaderInterface + all four uploaders and have
RetentionManager remove each pruned archive from the profile's enabled
remotes (best-effort; failures logged). The shared restore.php is left
in place since every backup overwrites it. (#229)

Consolidate duplicated plumbing (#230):
- New RemoteUploaderFactory replaces the createUploaderFromParams copy
  duplicated in BackupEngine and SteppedBackupEngine.
- RetentionManager becomes the single retention authority (global-default
  fallback + pruneOrphans()); the system plugin delegates to it and its
  duplicate doCleanup()/deleteBackupRecord() logic is removed.
- Backend controller, API controller and legacy cli/mokosuitebackup.php
  now run through the shared BackupRunner instead of BackupEngine directly.

Refs #229 #230

Claude-Session: https://claude.ai/code/session_01WbGBN9VyRK61zczYWcCQ2i
2026-07-05 20:34:04 -05:00

211 lines
5.6 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
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
class FtpUploader implements RemoteUploaderInterface
{
private string $host;
private int $port;
private string $username;
private string $password;
private string $remotePath;
private bool $passive;
private bool $ssl;
public function __construct(object $profile)
{
$this->host = $profile->ftp_host ?? '';
$this->port = (int) ($profile->ftp_port ?? 21);
$this->username = $profile->ftp_username ?? '';
$this->password = $profile->ftp_password ?? '';
$this->remotePath = rtrim($profile->ftp_path ?? '/backups', '/');
$this->passive = (bool) ($profile->ftp_passive ?? true);
$this->ssl = (bool) ($profile->ftp_ssl ?? false);
}
public function upload(string $localPath, string $remoteName): array
{
if (!extension_loaded('ftp')) {
return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP uploads. Enable it in php.ini.'];
}
if (empty($this->host)) {
return ['success' => false, 'message' => 'FTP host is not configured'];
}
$conn = $this->connect();
if (!$conn) {
return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port];
}
try {
if (!@ftp_login($conn, $this->username, $this->password)) {
throw new \RuntimeException('FTP login failed for user: ' . $this->username);
}
if ($this->passive) {
ftp_pasv($conn, true);
}
// Ensure remote directory exists (create recursively)
$this->ensureRemoteDir($conn, $this->remotePath);
$remoteFile = $this->remotePath . '/' . $remoteName;
if (!@ftp_put($conn, $remoteFile, $localPath, FTP_BINARY)) {
throw new \RuntimeException('Failed to upload file to: ' . $remoteFile);
}
// Verify upload by checking remote file size
$remoteSize = @ftp_size($conn, $remoteFile);
$localSize = filesize($localPath);
if ($remoteSize >= 0 && $remoteSize !== $localSize) {
throw new \RuntimeException(
'Size mismatch: local=' . $localSize . ' remote=' . $remoteSize
);
}
return [
'success' => true,
'message' => 'Uploaded to FTP: ' . $remoteFile,
'remote_path' => $remoteFile,
];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'FTP upload failed: ' . $e->getMessage()];
} finally {
@ftp_close($conn);
}
}
public function delete(string $remoteName): array
{
if (!extension_loaded('ftp')) {
return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP deletes. Enable it in php.ini.'];
}
if (empty($this->host)) {
return ['success' => false, 'message' => 'FTP host is not configured'];
}
$conn = $this->connect();
if (!$conn) {
return ['success' => false, 'message' => 'Failed to connect to FTP server ' . $this->host . ':' . $this->port];
}
try {
if (!@ftp_login($conn, $this->username, $this->password)) {
throw new \RuntimeException('FTP login failed for user: ' . $this->username);
}
if ($this->passive) {
ftp_pasv($conn, true);
}
$remoteFile = $this->remotePath . '/' . $remoteName;
/* Already-absent files: ftp_delete returns false, but if the file
isn't there the retention goal is already met, so probe size to
decide. ftp_size returns -1 when the file does not exist. */
if (@ftp_size($conn, $remoteFile) < 0) {
return ['success' => true, 'message' => 'FTP: nothing to delete (' . $remoteFile . ')'];
}
if (!@ftp_delete($conn, $remoteFile)) {
throw new \RuntimeException('ftp_delete failed for: ' . $remoteFile);
}
return ['success' => true, 'message' => 'Deleted from FTP: ' . $remoteFile];
} catch (\Throwable $e) {
return ['success' => false, 'message' => 'FTP delete failed: ' . $e->getMessage()];
} finally {
@ftp_close($conn);
}
}
public function testConnection(): array
{
if (empty($this->host)) {
return ['success' => false, 'message' => 'FTP host is not configured'];
}
$conn = $this->connect();
if (!$conn) {
return ['success' => false, 'message' => 'Cannot connect to ' . $this->host . ':' . $this->port];
}
try {
if (!@ftp_login($conn, $this->username, $this->password)) {
return ['success' => false, 'message' => 'Login failed for user: ' . $this->username];
}
if ($this->passive) {
ftp_pasv($conn, true);
}
$pwd = @ftp_pwd($conn);
return [
'success' => true,
'message' => 'Connected to ' . $this->host . ' (cwd: ' . $pwd . ')',
];
} finally {
@ftp_close($conn);
}
}
/**
* @return resource|false
*/
private function connect()
{
$timeout = 30;
if ($this->ssl) {
return @ftp_ssl_connect($this->host, $this->port, $timeout);
}
return @ftp_connect($this->host, $this->port, $timeout);
}
/**
* Recursively create remote directories.
*
* @param resource $conn FTP connection
* @param string $path Remote directory path
*/
private function ensureRemoteDir($conn, string $path): void
{
$parts = explode('/', trim($path, '/'));
$current = '';
foreach ($parts as $part) {
$current .= '/' . $part;
if (@ftp_chdir($conn, $current)) {
continue;
}
if (!@ftp_mkdir($conn, $current)) {
throw new \RuntimeException('Cannot create remote directory: ' . $current);
}
}
// Return to root
@ftp_chdir($conn, '/');
}
}