跳至主要内容
版本:5.1

文件系统插件 - FTP

此插件建立在 文件系统插件 - 基本 插件的基础上,演示了如何让 Joomla 能够使用不在本地文件存储器中的媒体文件系统。在这种情况下,我们开发了一个可通过 FTP 访问媒体文件存储器的插件。

编写 FTP 文件系统插件

要编写 FTP 文件系统插件,你需要提供 2 个类

  1. Provider 类实现了 administrator/components/com_media/src/Provider/ProviderInterface.php 中的 Joomla\Component\Media\Administrator\Provider\ProviderInterface。此操作很简单,只需复制 plugins/filesystem/local/src/Extension/Local.php 中的本地文件系统插件的方法。主插件 (Extension) 类兼作 Provider 类。
  2. Adapter 类实现了 administrator/components/com_media/src/Adapter/AdapterInterface.php 中的 Joomla\Component\Media\Administrator\Adapter\AdapterInterface。这是大量工作所在的区域,因为您必须将各种类型的文件操作映射到 ftp 调用。可在 FTP 函数 中找到 PHP 可用的一组 ftp 函数。

测试

若要测试 FTP 方面,您需要在您的本地机器上安装一个 FTP 服务器。我使用 Filezilla 来测试以下文件系统插件代码,但请注意不同的 FTP 服务器实现之间的界面可能会有所不同。特别是,PHP 函数 ftp_mlsd 可能不可用,您将需要改用 ftp_nlistftp_rawlist。在 Filezilla 上,我配置了一个测试用户和一个将 /shared 映射到我电脑上一个文件夹的装入点。由于插件需要知道这些详细信息,因此将其设置为了 XML 清单文件中定义的插件配置参数

  • 主机:我使用了 localhost
  • 用户名:FTP 服务器上用户的用户名
  • 密码:FTP 服务器上用户的密码
  • ftproot:装入点的虚拟路径;我使用了“shared”

我发现编写一个小型 PHP 程序来让自己能够看到调用各种 ftp 函数的结果是有用的。

FTP 连接

您必须在每次 HTTP 请求时打开并关闭 FTP 连接,因为如果您尝试在 HTTP 请求之间保持连接,那么该连接可能会超时。在此文件系统插件代码中,打开和用户登录操作在构造函数中完成,而在析构函数中关闭。

public function __construct(string $ftp_server, string $ftp_username, string $ftp_password, string $ftp_root, string $url_root)
{
...
if (!$this->ftp_connection = @ftp_connect($this->ftp_server)) {
...
}
if (!@ftp_login($this->ftp_connection, $this->ftp_username, $this->ftp_password)) {
...
}
}

public function __destruct()
{
if ($this->ftp_connection) {
@ftp_close($this->ftp_connection);
}
}

网址

如果您想将媒体包括在您的前端网站中,那么您将需要提供网址以允许访问者访问它们,并且您可通过适配器的 getUrl() 函数来执行此操作。您有以下 2 种可能

  • 如果您在与 ftp 服务器同一服务器上有一个网络服务器,且从网络服务器可以访问 ftp 目录,那么您只需生成此网络服务器上的相关网址即可。
  • 否则您必须从 ftp 服务器复制文件并将其存储在本地目录中,并形成与本地文件相关的 URL。

在文件系统插件代码中对两种选项都进行了编码,您可以通过配置插件参数“urlroot”来选择使用哪种选项

  • 如果您为“urlroot”提供一个值,则代码将从中以及传入的 $path 参数中形成 URL
  • 如果您不提供值,则代码会下载文件并将其存储在 Joomla /tmp 目录中,使用文件名哈希来形成临时文件的文件名,并生成关联的 URL。(使用的 xxh3 哈希至少需要 PHP 8.1,但您可以轻松地更改此文件)。
public function getUrl(string $path): string
{
...
if ($this->url_root) {
return $this->url_root . $path;
} else {
$hash = hash("xxh3", $path);
$local_filename = JPATH_ROOT . '/tmp/' . $hash . '.tmp';
if (file_exists($local_filename)) {
return Uri::root() . 'tmp/' . $hash . '.tmp';
} else {
if (!@ftp_get($this->ftp_connection, $local_filename, $this->ftp_root . $path)) {
...
}
return Uri::root() . 'tmp/' . $hash . '.tmp';
}
}
}

为了避免重复下载文件,代码会检查文件是否已存在于 /tmp 目录中。当然,这在生产环境中不起作用,因为文件内容可能会在 ftp 服务器上发生更改,但您可以改进此方法来构建一些缓存功能。

文件还是目录?

几个函数中的很多复杂性(例如 getFilegetFiles)源于您必须确定作为 $path 传递的是文件还是目录。代码中的策略是在父目录上调用 ftp_mlsd,并尝试匹配返回结果中的文件名。另一种方法是在 $path 上尝试 ftp_chdir 并查看是否可行。

错误处理

文件系统插件代码使用 Joomla 日志记录机制报告错误,因此您需要通过 Joomla 全局配置启用它。

Log::add("FTP can't connect: {$message}", Log::ERROR, 'ftp');

如果在 FTP 服务器上执行文件操作时出现错误,则默认情况下会在关联的 ftp_xxx 函数中收到 PHP 警告。这将出现在 HTTP 响应中,并将破坏 Ajax 请求的 JSON 响应。

因此,我们通过以下方式抑制 PHP 警告

  • 改用 @ftp_xxx (PHP 错误抑制运算符)
  • 通过 error_get_last() 获取错误消息
  • 抛出一个异常,包括从 error_get_last() 检索的错误消息。com_media 将捕获该异常,并将其通过 JSON 响应中的消息中继。

此处的一个例外是您在空目录上使用 ftp_msld(),而 getFile 中会引发 FileNotFoundException,在 getFiles 中会引发空数组。

如果您尝试删除一个非空的目录或重命名一个包含子目录的目录,则 Filezilla FTP 服务器可能会返回错误,但错误消息相当具有误导性

  • ftp_rmdir():此函数在此系统上不受支持。
  • ftp_rename():权限被拒绝

此外,您可能会发现报告了一个 Joomla 错误“未找到该帐户”。您可以通过使用浏览器的开发工具来清除会话存储和本地存储来规避此问题。(这是由于媒体管理器中一个较早的错误导致的,该错误已修复,但仍偶尔出现)。

未实现

缩略图

代码未实现缩略图。要实现此功能,您应将缩略图调整为 200 x 200 像素,并且您需要按如上所述为它们提供 URL,并在 getFilegetFiles 中返回的对象的 thumb_path 字段中返回 URL。(请注意,AdapterInterface.php 文件中这些函数之前的注释不完整,您应该比较 Joomla 本地适配器插件的代码中所写的内容)。

Mime 类型

该代码包含了一个从文件扩展名到 Mime 类型的映射,但仅适用于少数扩展名。您可以在线找到更好的列表,例如此处。如果媒体类型不是图像,则媒体管理器使用 Mime 类型来确定要显示的图标,因此您需要将其设置为媒体管理器将识别的内容。

上传文件

显然,在从互联网上传文件时您需要非常小心,以避免黑客上传恶意软件。您需要使以下代码更加健壮,例如更严格地检查文件名并使用 MediaHelper::canUpload()

search() 函数尚未实现。虽然您可以形成一个将导致调用此函数的 GET 请求,但这似乎未由 media-manager.js(它实现了其自己的搜索功能)启动。

Joomla API

使用对 Joomla API 的 HTTP 请求尚未测试此功能。

插件源代码

你可以将下面的源代码复制到 plg_filesystem_ftp 目录,或从 下载 FTP 文件系统插件 下载完整的插件。

安装完成后,记得启用插件!还需要运行本地 FTP 服务器,并使用 FTP 服务器的详细信息配置插件。

清单文件

plg_filesystem_ftp/ftp.xml
<?xml version="1.0" encoding="UTF-8"?>
<extension type="plugin" group="filesystem" method="upgrade">
<name>plg_filesystem_ftp</name>
<author>me</author>
<creationDate>today</creationDate>
<version>1.0.0</version>
<description>My ftp filesystem</description>
<namespace path="src">My\Plugin\Filesystem\Ftp</namespace>
<files>
<folder plugin="ftp">services</folder>
<folder>src</folder>
</files>
<config>
<fields name="params">
<fieldset name="basic">
<field
name="host"
type="text"
label="FTP server host"
default=""
>
</field>
<field
name="username"
type="text"
label="FTP username"
default=""
>
</field>
<field
name="password"
type="text"
label="FTP password"
default=""
>
</field>
<field
name="ftproot"
type="text"
label="FTP mount point root"
default=""
>
</field>
<field
name="urlroot"
type="text"
label="Base URL of FTP server directory"
default=""
>
</field>
</fieldset>
</fields>
</config>
</extension>

服务提供程序文件

这是用于通过 Joomla 依赖注入容器实例化插件的样板代码。

plg_filesystem_ftp/services/provider.php
<?php
defined('_JEXEC') or die;

use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use My\Plugin\Filesystem\Ftp\Extension\Ftp;

return new class () implements ServiceProviderInterface {

public function register(Container $container)
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Ftp(
$dispatcher,
(array) PluginHelper::getPlugin('filesystem', 'ftp')
);
$plugin->setApplication(Factory::getApplication());

return $plugin;
}
);
}
};

插件/提供程序类

已从 Joomla 本地文件系统插件中的同类中进行了改编。

plg_filesystem_ftp/src/Extension/Ftp.php
<?php
namespace My\Plugin\Filesystem\Ftp\Extension;

use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Component\Media\Administrator\Event\MediaProviderEvent;
use Joomla\Component\Media\Administrator\Provider\ProviderInterface;
use Joomla\Event\DispatcherInterface;
use My\Plugin\Filesystem\Ftp\Adapter\FtpAdapter;
use Joomla\CMS\Factory;

\defined('_JEXEC') or die;

final class Ftp extends CMSPlugin implements ProviderInterface
{

public static function getSubscribedEvents(): array {
return [
'onSetupProviders' => 'onSetupProviders',
];
}

/**
* Setup Providers for FTP Adapter
*
* @param MediaProviderEvent $event Event for ProviderManager
*
* @return void
*
* @since 4.0.0
*/
public function onSetupProviders(MediaProviderEvent $event)
{
$event->getProviderManager()->registerProvider($this);
}

/**
* Returns the ID of the provider
*
* @return string
*
* @since 4.0.0
*/
public function getID()
{
return $this->_name; // from "element" field of plugin's record in extensions table
}

/**
* Returns the display name of the provider
*
* @return string
*
* @since 4.0.0
*/
public function getDisplayName()
{
return 'Remote FTP';
}

/**
* Returns and array of adapters
*
* @return \Joomla\Component\Media\Administrator\Adapter\AdapterInterface[]
*
* @since 4.0.0
*/
public function getAdapters()
{
$adapters = [];
$ftp_server = $this->params->get('server', '');
$ftp_username = $this->params->get('username', '');
$ftp_password = $this->params->get('password', '');
$ftp_root = $this->params->get('ftproot', '');
$url_root = $this->params->get('urlroot', '');

$adapter = new FtpAdapter($ftp_server, $ftp_username, $ftp_password, $ftp_root, $url_root);

$adapters[$adapter->getAdapterName()] = $adapter;

return $adapters;
}
}

适配器类

所有工作都在这里进行!getFilegetFiles 中的代码显然有重叠,但代码会保留原样,以便更容易理解每个函数的作用。

plg_filesystem_ftp/src/Adapter/FtpAdapter.php
<?php

namespace My\Plugin\Filesystem\Ftp\Adapter;

use Joomla\CMS\Filesystem\File;
use Joomla\CMS\String\PunycodeHelper;
use Joomla\CMS\Language\Text;
use Joomla\CMS\Uri\Uri;
use Joomla\Component\Media\Administrator\Adapter\AdapterInterface;
use Joomla\Component\Media\Administrator\Exception\FileNotFoundException;
use Joomla\CMS\Log\Log;

\defined('_JEXEC') or die;

class FtpAdapter implements AdapterInterface
{
// Incomplete mapping of file extension to mime type
static $mapper = array(
'.avi' => 'video/avi',
'.bmp' => 'image/bmp',
'.gif' => 'image/gif',
'.jpeg' => 'image/jpeg',
'.jpg' => 'image/jpeg',
'.mp3' => 'audio/mpeg',
'.mp4' => 'video/mp4',
'.mpeg' => 'video/mpeg',
'.pdf' => 'application/pdf',
'.png' => 'image/png',
);

// Configuration from the plugin parameters
private $ftp_server = "";
private $ftp_username = "";
private $ftp_password = "";
private $ftp_root = "";
private $url_root = "";

// ftp connection
private $ftp_connection = null;

public function __construct(string $ftp_server, string $ftp_username, string $ftp_password, string $ftp_root, string $url_root)
{
$this->ftp_server = $ftp_server;
$this->ftp_username = $ftp_username;
$this->ftp_password = $ftp_password;
$this->ftp_root = $ftp_root;
$this->url_root = $url_root;

if (!$this->ftp_connection = @ftp_connect($this->ftp_server)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't connect: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
if (!@ftp_login($this->ftp_connection, $this->ftp_username, $this->ftp_password)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't login: {$message}", Log::ERROR, 'ftp');
@ftp_close($this->ftp_connection);
$this->ftp_connection = null;
throw new \Exception($message);
}
}

public function __destruct()
{
if ($this->ftp_connection) {
@ftp_close($this->ftp_connection);
}
}

/**
* This is the comment from the LocalAdapter interface - but it's not complete!
*
* Returns the requested file or folder. The returned object
* has the following properties available:
* - type: The type can be file or dir
* - name: The name of the file
* - path: The relative path to the root
* - extension: The file extension
* - size: The size of the file
* - create_date: The date created
* - modified_date: The date modified
* - mime_type: The mime type
* - width: The width, when available
* - height: The height, when available
*
* If the path doesn't exist a FileNotFoundException is thrown.
*
* @param string $path The path to the file or folder
*
* @return \stdClass
*
* @since 4.0.0
* @throws \Exception
*/
public function getFile(string $path = '/'): \stdClass
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

// To get the file details we need to run mlsd on the directory
$slash = strrpos($path, '/');
if ($slash === false) {
Log::add("FTP unexpectedly no slash in path {$path}", Log::ERROR, 'ftp');
return [];
}
if ($slash) {
$directory = substr($path, 0, $slash);
$filename = substr($path, $slash + 1);
} else { // it's the top level directory
$directory = "";
$filename = substr($path, 1);
}

if (!$files = ftp_mlsd($this->ftp_connection, $this->ftp_root . $directory)) {
throw new FileNotFoundException();
}

foreach ($files as $file) {
if ($file['name'] == $filename) {
$obj = new \stdClass();
$obj->type = $file['type'];
$obj->name = $file['name'];
$obj->path = $path;
$obj->extension = $file['type'] == 'file' ? File::getExt($obj->name) : '';
$obj->size = $file['type'] == 'file' ? intval($file['size']) : '';
$obj->create_date = $this->convertDate($file['modify']);
$obj->create_date_formatted = $obj->create_date;
$obj->modified_date = $obj->create_date;
$obj->modified_date_formatted = $obj->create_date_formatted;
$obj->mime_type = $file['type'] == 'file' ? $this->extension_mime_mapper(strrchr($file['name'], ".")) : "directory";
if ($obj->mime_type == 'image/png' || $obj->mime_type == 'image/jpeg') {
$obj->thumb_path = Uri::root() . "images/powered_by.png";
}
$obj->width = 0;
$obj->height = 0;

return $obj;
}
}

throw new FileNotFoundException();
}

/**
* Returns the folders and files for the given path. The returned objects
* have the following properties available:
* - type: The type can be file or dir
* - name: The name of the file
* - path: The relative path to the root
* - extension: The file extension
* - size: The size of the file
* - create_date: The date created
* - modified_date: The date modified
* - mime_type: The mime type
* - width: The width, when available
* - height: The height, when available
*
* If the path doesn't exist a FileNotFoundException is thrown.
*
* @param string $path The folder
*
* @return \stdClass[]
*
* @since 4.0.0
* @throws \Exception
*/
public function getFiles(string $path = '/'): array
{
// This can be called with a folder or a file, eg
// $path = '/' is the top level folder
// $path = '/sub' is the folder sub under the top level
// $path = '/fname.png' is a file in the top level folder
// $path = '/sub/fname.jpg' is a file in the sub folder

if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

$result = [];
$requestedDirectory = "";
$pathPrefix = "";

if ($path == '/') {
$requestedDirectory = $this->ftp_root;
$pathPrefix = "";
} else {
$slash = strrpos($path, '/');
if ($slash === false) {
Log::add("FTP unexpectedly no slash in path {$path}", Log::ERROR, 'ftp');
return [];
}
$parentDirectory = $this->ftp_root . substr($path, 0, $slash);
$filename = substr($path, $slash + 1);

// run mlsd and try to match on the filename, to determine if it's a file or directory
if (!$files = ftp_mlsd($this->ftp_connection, $parentDirectory)) {
return [];
}

foreach ($files as $file) {
if ($file['name'] == $filename) {
// it's a file, just get the file details and return them
if ($file['type'] == 'file') {
$obj = new \stdClass();
$obj->type = $file['type'];
$obj->name = $file['name'];
$obj->path = $path;
$obj->extension = $file['type'] == 'file' ? File::getExt($obj->name) : '';
$obj->size = $file['type'] == 'file' ? intval($file['size']) : '';
$obj->create_date = $this->convertDate($file['modify']);
$obj->create_date_formatted = $obj->create_date;
$obj->modified_date = $obj->create_date;
$obj->modified_date_formatted = $obj->create_date_formatted;
$obj->mime_type = $file['type'] == 'file' ? $this->extension_mime_mapper(strrchr($file['name'], ".")) : "directory";
if ($obj->mime_type == 'image/png' || $obj->mime_type == 'image/jpeg') {
$obj->thumb_path = Uri::root() . "images/powered_by.png";
}
$obj->width = 0;
$obj->height = 0;

$results[] = $obj;
return $results;
} else {
$requestedDirectory = $this->ftp_root . $path;
$pathPrefix = $path;
break; // it was a directory
}
}
}
}

// need to run mlsd again, this time on the requested directory
if (!$files = ftp_mlsd($this->ftp_connection, $requestedDirectory)) {
return [];
}
foreach ($files as $file) {
$obj = new \stdClass();
$obj->type = $file['type'];
$obj->name = $file['name'];
$obj->path = $pathPrefix . '/' . $file['name'];
$obj->extension = $file['type'] == 'file' ? File::getExt($obj->name) : '';
$obj->size = $file['type'] == 'file' ? intval($file['size']) : '';
$obj->create_date = $this->convertDate($file['modify']);
$obj->create_date_formatted = $obj->create_date;
$obj->modified_date = $obj->create_date;
$obj->modified_date_formatted = $obj->create_date_formatted;
$obj->mime_type = $file['type'] == 'file' ? $this->extension_mime_mapper(strrchr($file['name'], ".")) : "directory";
if ($obj->mime_type == 'image/png' || $obj->mime_type == 'image/jpeg') {
$obj->thumb_path = Uri::root() . "images/powered_by.png";
}
$obj->width = 0;
$obj->height = 0;

$results[] = $obj;
}
return $results;
}

function convertDate($date_string) {
$d = date_parse_from_format("YmdHis\.v", $date_string);
$date_formatted = sprintf("%04d-%02d-%02d %02d:%02d", $d['year'], $d['month'], $d['day'], $d['hour'], $d['minute']);
return $date_formatted;
}

function extension_mime_mapper($extension) {
if (array_key_exists($extension, self::$mapper)) {
return self::$mapper[$extension];
} else {
return 'application/octet-stream';
}
}

/**
* Returns a resource to download the path.
*
* @param string $path The path to download
*
* @return resource
*
* @since 4.0.0
* @throws \Exception
*/
public function getResource(string $path)
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

// write the data to PHP://temp stream
$handle = fopen('php://temp', 'w+');

if (!@ftp_fget($this->ftp_connection, $handle, $this->ftp_root . $path)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't get file {$path}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
rewind($handle);

return $handle;
}

/**
* Creates a folder with the given name in the given path.
*
* It returns the new folder name. This allows the implementation
* classes to normalise the file name.
*
* @param string $name The name
* @param string $path The folder
*
* @return string
*
* @since 4.0.0
* @throws \Exception
*/
public function createFolder(string $name, string $path): string
{

$name = $this->getSafeName($name);

if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

$directory = $this->ftp_root . $path . '/' . $name;

if (!@ftp_mkdir($this->ftp_connection, $directory)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP error on mkdir {$directory}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}

return $name;
}

/**
* Creates a file with the given name in the given path with the data.
*
* It returns the new file name. This allows the implementation
* classes to normalise the file name.
*
* @param string $name The name
* @param string $path The folder
* @param string $data The data
*
* @return string
*
* @since 4.0.0
* @throws \Exception
*/
public function createFile(string $name, string $path, $data): string
{
$name = $this->getSafeName($name);
$remote_filename = $this->ftp_root . $path . '/' . $name;

// write the data to PHP://temp stream
$handle = fopen('php://temp', 'w+');
fwrite($handle, $data);
rewind($handle);

if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

if (!@ftp_fput($this->ftp_connection, $remote_filename, $handle, FTP_BINARY)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't create file {$remote_filename}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
fclose($handle);

return $name;
}

/**
* Updates the file with the given name in the given path with the data.
*
* @param string $name The name
* @param string $path The folder
* @param string $data The data
*
* @return void
*
* @since 4.0.0
* @throws \Exception
*/
public function updateFile(string $name, string $path, $data)
{
$name = $this->getSafeName($name);
$remote_filename = $this->ftp_root . $path . '/' . $name;

// write the data to PHP://temp stream
$handle = fopen('php://temp', 'w+');
fwrite($handle, $data);
rewind($handle);

if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

ftp_pasv($this->ftp_connection, true); // may not be necessary

if (!@ftp_fput($this->ftp_connection, $remote_filename, $handle, FTP_BINARY)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't create file {$remote_filename}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
fclose($handle);

return;
}

/**
* Deletes the folder or file of the given path.
*
* @param string $path The path to the file or folder
*
* @return void
*
* @since 4.0.0
* @throws \Exception
*/
public function delete(string $path)
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

// We have to find if this is a file or if it's a directory.
// So we split the directory path from the filename and then call mlsd on the directory
$slash = strrpos($path, '/');
if ($slash === false) {
Log::add("FTP delete: unexpectedly no slash in path {$path}", Log::ERROR, 'ftp');
return [];
}
$directory = substr($path, 0, $slash);
$filename = substr($path, $slash + 1);

if (!$files = ftp_mlsd($this->ftp_connection, $this->ftp_root . $directory)) {
Log::add("Can't delete non-existent file {$path}", Log::ERROR, 'ftp');
return;
}

// Go through the files in the folder looking for a match with a file or directory
foreach ($files as $file) {
if ($file['name'] == $filename) {
if ($file['type'] == 'file') {
if (!$result = @ftp_delete($this->ftp_connection, $this->ftp_root . $path)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("Unable to delete file {$path}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
} else {
if (!$result = @ftp_rmdir($this->ftp_connection, $this->ftp_root . $path)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("Unable to delete directory {$path}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
}
return;
}
}
}

/**
* Copies a file or folder from source to destination.
*
* It returns the new destination path. This allows the implementation
* classes to normalise the file name.
*
* @param string $sourcePath The source path
* @param string $destinationPath The destination path
* @param bool $force Force to overwrite
*
* @return string
*
* @since 4.0.0
* @throws \Exception
*/
public function copy(string $sourcePath, string $destinationPath, bool $force = false): string
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

// copy the data of the source file down into PHP://temp stream
$handle = fopen('php://temp', 'w+');
if (!@ftp_fget($this->ftp_connection, $handle, $this->ftp_root . $sourcePath)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't get file {$sourcePath} for copying: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
rewind($handle);

// copy from the temp stream up to the destination
if (!@ftp_fput($this->ftp_connection, $this->ftp_root . $destinationPath, $handle)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP can't copy to file {$destinationPath}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
fclose($handle);

return $destinationPath;
}

/**
* Moves a file or folder from source to destination.
*
* It returns the new destination path. This allows the implementation
* classes to normalise the file name.
*
* @param string $sourcePath The source path
* @param string $destinationPath The destination path
* @param bool $force Force to overwrite
*
* @return string
*
* @since 4.0.0
* @throws \Exception
*/
public function move(string $sourcePath, string $destinationPath, bool $force = false): string
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

if (!@ftp_rename($this->ftp_connection, $this->ftp_root . $sourcePath, $this->ftp_root . $destinationPath)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("Unable to rename {$sourcePath} to {$destinationPath}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}

return $destinationPath;
}

/**
* Returns a url which can be used to display an image from within the "images" directory.
*
* @param string $path Path of the file relative to adapter
*
* @return string
*
* @since 4.0.0
*/
public function getUrl(string $path): string
{
if (!$this->ftp_connection) {
throw new \Exception("No FTP connection available");
}

if ($this->url_root) {
return $this->url_root . $path;
} else {
$hash = hash("xxh3", $path);
$local_filename = JPATH_ROOT . '/tmp/' . $hash . '.tmp';
if (file_exists($local_filename)) {
return Uri::root() . 'tmp/' . $hash . '.tmp';
} else {
if (!@ftp_get($this->ftp_connection, $local_filename, $this->ftp_root . $path)) {
$message = error_get_last() !== null && error_get_last() !== [] ? error_get_last()['message'] : 'Error';
Log::add("FTP Unable to download {$path} to {$local_filename}: {$message}", Log::ERROR, 'ftp');
throw new \Exception($message);
}
return Uri::root() . 'tmp/' . $hash . '.tmp';
}
}
return '';
}

/**
* Returns the name of this adapter.
*
* @return string
*
* @since 4.0.0
*/
public function getAdapterName(): string
{
return $this->ftp_root;
}

/**
* Search for a pattern in a given path
*
* @param string $path The base path for the search
* @param string $needle The path to file
* @param bool $recursive Do a recursive search
*
* @return \stdClass[]
*
* @since 4.0.0
*/
public function search(string $path, string $needle, bool $recursive = false): array
{
return array();
}

/**
* Creates a safe file name for the given name.
*
* @param string $name The filename
*
* @return string
*
* @since 4.0.0
* @throws \Exception
*/
private function getSafeName(string $name): string
{
// Copied from the Joomla local filesystem plugin code

// Make the filename safe
if (!$name = File::makeSafe($name)) {
throw new \Exception(Text::_('COM_MEDIA_ERROR_MAKESAFE'));
}

// Transform filename to punycode
$name = PunycodeHelper::toPunycode($name);

// Get the extension
$extension = File::getExt($name);

// Normalise extension, always lower case
if ($extension) {
$extension = '.' . strtolower($extension);
}

$nameWithoutExtension = substr($name, 0, \strlen($name) - \strlen($extension));

return $nameWithoutExtension . $extension;
}
}