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
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
211 lines
5.6 KiB
PHP
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, '/');
|
|
}
|
|
}
|