File: //proc/self/cwd/wp-content/plugins/duplicator-pro/addons/ftpaddon/src/Models/FTPStorageAdapter.php
<?php
/**
*
* @package Duplicator
* @copyright (c) 2022, Snap Creek LLC
*/
namespace Duplicator\Addons\FtpAddon\Models;
use Duplicator\Addons\FtpAddon\Utils\FTPUtils;
use Duplicator\Models\Storages\StoragePathInfo;
use Duplicator\Libs\Snap\SnapIO;
use Duplicator\Models\Storages\AbstractStorageAdapter;
use FTP\Connection;
use Exception;
/**
* Description of cls-ftp-chunker
*/
class FTPStorageAdapter extends AbstractStorageAdapter
{
/** @var int */
const DEFAULT_CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
/** @var string */
private $root = '';
/** @var string */
private $server = '';
/** @var int */
private $port = 21;
/** @var string */
private $username = '';
/** @var string */
private $password = '';
/** @var int */
private $timeoutInSec = 15;
/** @var bool */
private $ssl = false;
/** @var bool */
private $passiveMode = false;
/** @var resource */
private $destFileHandle;
/** @var string */
private $lastDestFilePath = '';
/** @var resource */
private $sourceFileHandle;
/** @var string */
private $lastSourceFilePath = '';
/** @var resource */
private $tempFileHandle;
/** @var false|resource|Connection */
private $connection = false; // @phpstan-ignore property.unusedType
/** @var int */
private $throttle = 0;
/**
* Class constructor
*
* @param string $server The server to connect to
* @param int $port The port to connect to
* @param string $username The username to use
* @param string $password The password to use
* @param string $root The root directory to use
* @param int $timeoutInSec The timeout in seconds
* @param bool $ssl Whether to use SSL
* @param bool $passiveMode Whether to use passive mode
* @param int $throttle The throttle in microseconds
*/
public function __construct(
$server,
$port = 21,
$username = '',
$password = '',
$root = '/',
$timeoutInSec = 15,
$ssl = false,
$passiveMode = false,
$throttle = 0
) {
$this->server = $server;
$this->port = (int) $port;
$this->username = $username;
$this->password = $password;
$this->root = SnapIO::trailingslashit($root);
$this->timeoutInSec = max(1, (int) $timeoutInSec);
$this->ssl = (bool) $ssl;
$this->passiveMode = (bool) $passiveMode;
$this->throttle = max(0, (int) $throttle);
}
/**
* Opens the FTP connection and initializes root directory
*
* @param string $errorMsg The error message to return
*
* @return bool True on success, false on failure
*/
public function initialize(&$errorMsg = ''): bool
{
if (!$this->createDir('/')) {
$errorMsg = "Couldn't create root directory.";
return false;
}
$this->wait();
return true;
}
/**
* Throttle the connection
*
* @return void
*/
protected function wait()
{
if ($this->throttle > 0) {
usleep($this->throttle);
}
}
/**
* Check if storage is valid and ready to use.
*
* @param string $errorMsg The error message if storage is invalid.
*
* @return bool
*/
public function isValid(&$errorMsg = ''): bool
{
if (!$this->isConnectionInfoValid($errorMsg)) {
$errorMsg = "FTP connection info is invalid: " . $errorMsg;
return false;
}
if ($this->getConnection() === false) {
$errorMsg = "FTP connection failed.";
return false;
}
if (!$this->isDir('/')) {
$errorMsg = "FTP root directory doesn't exist.";
return false;
}
return true;
}
/**
* Checks if the connection info is valid
*
* @param string $errorMsg The error message to return
*
* @return bool
*/
protected function isConnectionInfoValid(&$errorMsg = ''): bool
{
if (strlen($this->server) < 1) {
$errorMsg = "FTP server is empty.";
return false;
}
if (strlen($this->username) < 1) {
$errorMsg = "FTP username is empty.";
return false;
}
if ($this->port < 1) {
$errorMsg = "FTP port is invalid.";
return false;
}
if (strlen($this->root) < 1) {
$errorMsg = "FTP root directory is empty.";
return false;
}
return true;
}
/**
* Create the directory specified by pathname, recursively if necessary.
*
* @param string $path The relative directory path.
*
* @return bool true on success or false on failure.
*/
protected function realCreateDir($path)
{
$isRoot = ($this->getFullPath($path) === false);
if ($isRoot) {
$path = $this->root;
} else {
$path = $this->getFullPath($path);
}
return $this->createDirRecursively($path);
}
/**
* Create the directory specified by pathname, recursively if necessary.
*
* @param string $path The full path to the directory.
*
* @return bool true on success or false on failure.
*/
private function createDirRecursively($path): bool
{
try {
if (($connection = $this->getConnection()) === false) {
return false;
}
if (@ftp_chdir($connection, $path) === true) {
if ($path !== $this->root) {
@ftp_chdir($connection, $this->root);
}
return true;
}
$parent = dirname($path);
if (!$this->createDirRecursively($parent)) {
return false;
}
if (@ftp_mkdir($connection, $path) === false) {
return false;
}
} finally {
$this->wait();
}
return true;
}
/**
* Get the list of files and directories inside the specified path.
*
* @param string $path Relative storage path, if empty, scan root path.
* @param bool $files If true, add files to the list. Default to true.
* @param bool $folders If true, add folders to the list. Default to true.
*
* @return string[] The list of files and directories, empty array if path is invalid.
*/
public function scanDir($path, $files = true, $folders = true): array
{
if (($infoList = $this->getDirContentsInfo($path)) === false) {
return $this->scanDirNlist($path, $files, $folders);
}
$result = [];
foreach ($infoList as $item) {
if ($item['isDir'] && !$folders) {
continue;
}
if (!$item['isDir'] && !$files) {
continue;
}
$result[] = $item['name'];
}
return $result;
}
/**
* Get the list of files and directories inside the specified path.
* Uses ftp_nlist() to get the list of files and directories.
*
* @param string $path Relative storage path, if empty, scan root path.
* @param bool $files If true, add files to the list. Default to true.
* @param bool $folders If true, add folders to the list. Default to true.
*
* @return string[] The list of files and directories, empty array if path is invalid.
*/
private function scanDirNlist($path, $files = true, $folders = true): array
{
if (($connection = $this->getConnection()) === false) {
return [];
}
if (($fullPath = $this->getFullPath($path, true)) == false) {
return [];
}
if (($list = @ftp_nlist($connection, "$fullPath")) === false) {
return [];
}
$path = SnapIO::trailingslashit($path);
$result = [];
foreach ($list as $item) {
$item = basename($item);
if ($item == '.' || $item == '..') {
continue;
}
$itemPath = $path . $item;
if (!$folders && $this->isDir($itemPath)) {
continue;
}
if (!$files && $this->isFile($itemPath)) {
continue;
}
$result[] = $item;
}
return $result;
}
/**
* Get the list of files and directories inside the specified path.
* Uses ftp_rawlist() to get the list of files and directories.
*
* @param string $path Relative storage path, if empty, scan root path.
* @param string $defaultSystemType The default system type to use if ftp_systype() fails.
*
* @return array{array{name: string, size: int, modified: int, created: int, isDir: bool}}|false
*/
private function getDirContentsInfo($path, $defaultSystemType = '')
{
if (($connection = $this->getConnection()) === false) {
return false;
}
if (($systemType = @ftp_systype($connection)) === false || strlen($systemType) === 0) {
if (strlen($defaultSystemType) > 0) {
$systemType = strtoupper($defaultSystemType);
} else {
return false;
}
} else {
$systemType = strtoupper($systemType);
if ($systemType !== FTPUtils::SYS_TYPE_UNIX && $systemType !== FTPUtils::SYS_TYPE_WINDOWS_NT) {
return false;
}
}
$path = $this->getFullPath($path, true);
if (($list = @ftp_rawlist($connection, "$path")) === false) {
return false;
}
$result = [];
foreach ($list as $item) {
if (($item = FTPUtils::parseRawListString($item, $systemType)) === false) {
continue;
}
if ($item['name'] == '.' || $item['name'] == '..') {
continue;
}
$result[] = $item;
}
return $result;
}
/**
* Check if directory is empty.
*
* @param string $path The folder path
* @param string[] $filters Filters to exclude files and folders from the check, if start and end with /, use regex.
*
* @return bool True is ok, false otherwise
*/
public function isDirEmpty($path, $filters = []): bool
{
if (!$this->isDir($path)) {
return false;
}
$regexFilters = [];
$normalFilters = [];
foreach ($filters as $filter) {
if (preg_match('/^\/.*\/$/', $filter) === 1) {
$regexFilters[] = $filter;
} else {
$normalFilters[] = $filter;
}
}
$contents = $this->scanDir($path);
foreach ($contents as $item) {
if (in_array($item, $normalFilters)) {
continue;
}
foreach ($regexFilters as $regexFilter) {
if (preg_match($regexFilter, $item) === 1) {
continue 2;
}
}
return false;
}
return true;
}
/**
* Get path info and cache it, is path not exists return path info with exists property set to false.
* Gets the path info but has to do seperate calls to get the size and modified time.
* Also the modified time doesn't work for directories.
*
* @param string $path Relative storage path, if empty, return root path info.
*
* @return StoragePathInfo|false The path info or false on error.
*/
protected function getRealPathInfo($path)
{
if (($connection = $this->getConnection()) === false) {
return false;
}
$fullPath = $this->getFullPath($path, true);
$info = new StoragePathInfo();
$info->path = $path;
if (
($size = @ftp_size($connection, $fullPath)) >= 0 ||
($size = $this->getSizeFromRawList($path)) >= 0
) {
$info->exists = true;
$info->isDir = false;
$info->size = $size;
} elseif (@ftp_chdir($connection, $fullPath) === true) {
if ($fullPath !== $this->getFullPath('', true)) {
@ftp_chdir($connection, $this->root);
}
$info->exists = true;
$info->isDir = true;
$info->size = 0;
} else {
$info->exists = false;
$info->isDir = false;
$info->size = 0;
}
if ($info->exists) {
if ($info->isDir) {
$info->modified = time();
} else {
if (($info->modified = (int) @ftp_mdtm($connection, $fullPath)) === -1) {
$info->modified = 0;
}
}
$info->created = $info->modified;
}
return $info;
}
/**
* Get the size of the file.
*
* @param string $path The path to file.
*
* @return int<-1,max> The size of file or -1 on failure.
*/
protected function getSizeFromRawList($path)
{
$parent = dirname($path) !== '.' ? dirname($path) : '';
if (($contents = $this->getDirContentsInfo($parent, FTPUtils::SYS_TYPE_UNIX)) === false) {
return -1;
}
foreach ($contents as $item) {
if ($item['name'] === basename($path) && $item['isDir'] === false) {
return $item['size'];
}
}
return -1;
}
/**
* Create file with content.
*
* @param string $path The path to file.
* @param string $content The content of file.
*
* @return false|int The number of bytes that were written to the file, or false on failure.
*/
protected function realCreateFile($path, $content)
{
try {
if (($connection = $this->getConnection()) === false) {
return false;
}
if (($storageFileFullPath = $this->getFullPath($path)) == false) {
return false;
}
$parent = dirname($path);
if (!$this->createDir($parent)) {
return false;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'duplicator-pro-');
if (($bytesWritten = file_put_contents($tmpFile, $content)) === false) {
return false;
}
//ftp_put overwrites the file if it exists, no need to delete it first
if (@ftp_put($connection, $storageFileFullPath, $tmpFile, FTP_BINARY) === false) {
SnapIO::unlink($tmpFile);
return false;
}
SnapIO::unlink($tmpFile);
return $bytesWritten;
} finally {
$this->wait();
}
}
/**
* Copy local file to storage, partial copy is supported.
* If destination file exists, it will be overwritten.
* If offset is less than the destination file size, the file will be truncated.
*
* @param string $sourceFile The source file full path
* @param string $storageFile Storage destination path
* @param int<0,max> $offset The offset where the data starts.
* @param int $length The maximum number of bytes read. Default to -1 (read all the remaining buffer).
* @param int $timeout The timeout for the copy operation in microseconds. Default to 0 (no timeout).
* @param array<string,mixed> $extraData Extra data to pass to copy function and updated during copy.
* Used for storages that need to maintain persistent data during copy intra-session.
*
* @return false|int The number of bytes that were written to the file, or false on failure.
*/
protected function realCopyToStorage($sourceFile, $storageFile, $offset = 0, $length = -1, $timeout = 0, &$extraData = [])
{
try {
$startTime = microtime(true);
if (($connection = $this->getConnection()) === false) {
return false;
}
if (!is_file($sourceFile)) {
return false;
}
if (($storageFileFullPath = $this->getFullPath($storageFile)) == false) {
return false;
}
$parent = dirname($storageFile);
if ($offset === 0 && !$this->createDir($parent)) {
return false;
}
// Uplaod file at once without any other operation
if ($timeout === 0 && (($offset === 0 && $length < 0) || filesize($sourceFile) < $length)) {
if (@ftp_put($connection, $storageFileFullPath, $sourceFile, FTP_BINARY) === false) {
return false;
}
return filesize($sourceFile);
}
$sourceFileHandle = $this->getSourceFileHandle($sourceFile);
$tempFileHandle = $this->getTempFileHandle();
$length = $length < 0 ? self::DEFAULT_CHUNK_SIZE : $length;
$bytesWritten = 0;
do {
if (@fseek($sourceFileHandle, $offset) === -1 || ($chunk = @fread($sourceFileHandle, $length)) === false) {
return false;
}
if (
@ftruncate($tempFileHandle, 0) === false ||
@rewind($tempFileHandle) === false ||
@fwrite($tempFileHandle, $chunk) === false
) {
return false;
}
@rewind($tempFileHandle);
// No need to delete remote file, ftp_fput overwrites the file if the offset is 0
if (@ftp_fput($connection, $storageFileFullPath, $tempFileHandle, FTP_BINARY, $offset) === false) {
return false;
}
//abort on first chunk if no timeout set
if ($timeout === 0) {
return $length;
}
$bytesWritten += strlen($chunk);
$offset += strlen($chunk);
} while ((self::getElapsedTime($startTime) < $timeout) && !feof($sourceFileHandle));
return $bytesWritten;
} finally {
$this->wait();
}
}
/**
* Copy storage file to local file, partial copy is supported.
* If destination file exists, it will be overwritten.
* If offset is less than the destination file size, the file will be truncated.
*
* @param string $storageFile The storage file path
* @param string $destFile The destination local file full path
* @param int<0,max> $offset The offset where the data starts.
* @param int $length The maximum number of bytes read. Default to -1 (read all the remaining buffer).
* @param int $timeout The timeout for the copy operation in microseconds. Default to 0 (no timeout).
* @param array<string,mixed> $extraData Extra data to pass to copy function and updated during copy.
* Used for storages that need to maintain persistent data during copy intra-session.
*
* @return false|int The number of bytes that were written to the file, or false on failure.
*/
public function copyFromStorage($storageFile, $destFile, $offset = 0, $length = -1, $timeout = 0, &$extraData = [])
{
try {
$startTime = microtime(true);
if (($connection = $this->getConnection()) === false) {
return false;
}
if (($fullPath = $this->getFullPath($storageFile)) === false) {
return false;
}
if (wp_mkdir_p(dirname($destFile)) == false) {
return false;
}
if ($offset === 0 && @file_exists($destFile) && !@unlink($destFile)) {
return false;
}
if ($offset > 0 && !@file_exists($destFile)) {
return false;
}
if (!$this->isFile($storageFile)) {
return false;
}
if ($timeout === 0 && $offset === 0 && $length < 0) {
if (($content = $this->getFileContent($storageFile)) === false) {
return false;
}
return @file_put_contents($destFile, $content);
}
if (
($destHandle = $this->getDestFileHandle($destFile)) === false ||
@fseek($destHandle, $offset) === -1
) {
return false;
}
//This is necessary to be able to call this function multiple times in one session
//Otherwise ftp_nb_fget will fail if the file is already opened for upload
if (isset($extraData['multiPartInProgress']) && $extraData['multiPartInProgress'] === true) {
if (($connection = $this->resetConnection()) === false) {
return false;
}
} else {
$extraData['multiPartInProgress'] = true;
}
$sizeBefore = filesize($destFile);
$result = @ftp_nb_fget($connection, $destHandle, $fullPath, FTP_BINARY, $offset);
while (
$result === FTP_MOREDATA &&
(
($timeout !== 0 && self::getElapsedTime($startTime) < $timeout) ||
($timeout === 0 && @ftell($destHandle) - $sizeBefore <= $length)
)
) {
$result = @ftp_nb_continue($connection);
}
if ($result === FTP_FAILED) {
return false;
}
if ($timeout !== 0) {
return @ftell($destHandle) - $sizeBefore;
} else {
return $length;
}
} finally {
$this->wait();
}
}
/**
* Resets the connection
*
* @return false|resource|Connection True on success, false on failure
*/
private function resetConnection()
{
if ($this->connection !== false) {
@ftp_close($this->connection);
}
$this->connection = false;
if (($connection = $this->getConnection()) === false) {
return false;
}
return $connection;
}
/**
* Delete reletative path from storage root.
*
* @param string $path The path to delete. (Accepts directories and files)
* @param bool $recursive Allows the deletion of nested directories specified in the pathname. Default to false.
*
* @return bool true on success or false on failure.
*/
protected function realDelete($path, $recursive = false)
{
try {
if (($connection = $this->getConnection()) === false) {
return false;
}
if (($fullPath = $this->getFullPath($path, true)) == false) {
return false;
}
if ($this->isDir($path)) {
if ($recursive) {
foreach ($this->scanDir($path) as $item) {
if (!$this->delete(SnapIO::trailingslashit($path) . $item, true)) {
return false;
}
}
}
return @ftp_rmdir($connection, $fullPath);
} elseif ($this->isFile($path)) {
return @ftp_delete($connection, $fullPath);
} else {
//path doesn't exist
return true;
}
} finally {
$this->wait();
}
}
/**
* Get file content.
*
* @param string $path The path to file.
*
* @return string|false The content of file or false on failure.
*/
public function getFileContent($path)
{
if (($connection = $this->getConnection()) === false) {
return false;
}
if (($fullPath = $this->getFullPath($path)) == false) {
return false;
}
$tmpFile = tempnam(sys_get_temp_dir(), 'duplicator-pro');
if (@ftp_get($connection, $tmpFile, $fullPath, FTP_BINARY) === false) {
SnapIO::unlink($tmpFile);
return false;
}
if (($content = file_get_contents($tmpFile)) === false) {
SnapIO::unlink($tmpFile);
return false;
}
SnapIO::unlink($tmpFile);
return $content;
}
/**
* Move and/or rename a file or directory.
*
* @param string $oldPath Relative storage path
* @param string $newPath Relative storage path
*
* @return bool true on success or false on failure.
*/
protected function realMove($oldPath, $newPath)
{
try {
if (($connection = $this->getConnection()) === false) {
return false;
}
$newPathParent = dirname($newPath);
if (!$this->createDir($newPathParent)) {
return false;
}
if (($oldPath = $this->getFullPath($oldPath)) == false) {
return false;
}
if (($newPath = $this->getFullPath($newPath)) == false) {
return false;
}
return @ftp_rename($connection, $oldPath, $newPath);
} finally {
$this->wait();
}
}
/**
* Destroy the storage on deletion.
*
* @return bool true on success or false on failure.
*/
public function destroy()
{
//Don't accidentally delete root directory
if (
preg_match('/^[a-zA-Z]:\/$/', $this->root) === 1 ||
preg_match('/^\/$/', $this->root) === 1
) {
return true;
}
return $this->delete('/', true);
}
/**
* Returns an empty file stream to temporarlly store chunk data.
*
* @return resource
*/
private function getTempFileHandle()
{
if (is_resource($this->tempFileHandle)) {
if (ftruncate($this->tempFileHandle, 0) === false) {
throw new Exception('Can\'t truncate temp file');
}
return $this->tempFileHandle;
}
if (($this->tempFileHandle = @fopen('php://temp', 'wb+')) === false) {
throw new Exception('Can\'t open temp handle');
}
return $this->tempFileHandle;
}
/**
* Returns the dest file handle
*
* @param string $destFilePath The dest file path
*
* @return resource|false The dest file handle or false on failure
*/
private function getDestFileHandle($destFilePath)
{
if ($this->lastDestFilePath === $destFilePath && is_resource($this->destFileHandle)) {
return $this->destFileHandle;
}
if (@is_resource($this->destFileHandle)) {
@fclose($this->destFileHandle);
}
if (($this->destFileHandle = @fopen($destFilePath, 'cb')) === false) {
return false;
}
$this->lastDestFilePath = $destFilePath;
return $this->destFileHandle;
}
/**
* Returns the source file handle
*
* @param string $sourceFilePath The source file path
*
* @return resource
*/
private function getSourceFileHandle($sourceFilePath)
{
if ($this->lastSourceFilePath === $sourceFilePath) {
return $this->sourceFileHandle;
}
if (is_resource($this->sourceFileHandle)) {
fclose($this->sourceFileHandle);
}
if (($this->sourceFileHandle = SnapIO::fopen($sourceFilePath, 'r')) === false) {
throw new Exception('Can\'t open ' . $sourceFilePath . ' file');
}
$this->lastSourceFilePath = $sourceFilePath;
return $this->sourceFileHandle;
}
/**
* Opens the FTP connection
*
* @param string $errorMsg The error message to return
*
* @return bool True on success, false on failure
*/
private function connect(&$errorMsg = ''): bool
{
if ($this->connection !== false) {
return true;
}
if (!$this->isConnectionInfoValid($errorMsg)) {
return false;
}
try {
if (!function_exists('ftp_connect')) {
throw new Exception('FTP functions are not available.');
}
if ($this->ssl && !function_exists('ftp_ssl_connect')) {
throw new Exception('Attempted to open FTP SSL connection when OpenSSL hasn\'t been statically built into this PHP install.');
}
if ($this->ssl) {
$this->connection = @ftp_ssl_connect($this->server, $this->port, $this->timeoutInSec);
} else {
$this->connection = @ftp_connect($this->server, $this->port, $this->timeoutInSec);
}
if ($this->connection === false) {
throw new Exception('Error connecting to FTP server. ' . $this->server . ':' . $this->port);
}
if (ftp_login($this->connection, $this->username, $this->password) === false) {
throw new Exception('Error logging in user ' . $this->username . ', double check your username and password');
}
if ($this->passiveMode && !@ftp_pasv($this->connection, true)) {
throw new Exception("Couldn't set the connection into passive mode.");
}
if (ftp_set_option($this->getConnection(), FTP_AUTOSEEK, false) === false) {
throw new Exception("Couldn't disable auto seek.");
}
} catch (Exception $e) {
if ($this->connection !== false) {
ftp_close($this->connection);
$this->connection = false;
}
$errorMsg = $e->getMessage();
return false;
}
return true;
}
/**
* Returns the FTP connection resource
*
* @return false|resource|Connection The FTP connection resource or false if not connected
*/
public function getConnection()
{
return ($this->connect() === true ? $this->connection : false);
}
/**
* Closes the FTP connection
*/
public function __destruct()
{
if ($this->connection !== false) {
@ftp_close($this->connection);
}
if (is_resource($this->sourceFileHandle)) {
@fclose($this->sourceFileHandle);
}
if (is_resource($this->tempFileHandle)) {
@fclose($this->tempFileHandle);
}
if (is_resource($this->destFileHandle)) {
@fclose($this->destFileHandle);
}
}
/**
* Return the full path of storage from relative path.
*
* @param string $path The relative storage path
* @param bool $acceptEmpty If true, return root path if path is empty. Default to false.
*
* @return string|false The full path or false if path is invalid.
*/
protected function getFullPath($path, $acceptEmpty = false)
{
$path = ltrim((string) $path, '/\\');
if (strlen($path) === 0) {
return $acceptEmpty ? SnapIO::untrailingslashit($this->root) : false;
}
return $this->root . $path;
}
/**
* Get the elapsed time in microseconds
*
* @param float $startTime The start time
*
* @return float The elapsed time in microseconds
*/
private static function getElapsedTime($startTime)
{
return (microtime(true) - $startTime) * SECONDS_IN_MICROSECONDS;
}
}