پلاگین سیستم فایل – FTP

این پلاگین بر اساس پلاگین پایه‌ی filesystem ساخته شده تا نشان دهد چگونه می‌توان جوملا را قادر ساخت از یک سیستم فایل رسانه‌ای که در فایل‌استور محلی نیست، استفاده کند. در اینجا ما یک پلاگین توسعه می‌دهیم که اجازه می‌دهد فایل‌استور رسانه از طریق FTP قابل دسترسی باشد.

نوشتن پلاگین Filesystem FTP

برای نوشتن یک پلاگین FTP Filesystem به دو کلاس نیاز دارید:

- کلاس Provider که اینترفیس Joomla\Component\Media\Administrator\Provider\ProviderInterface را در مسیر administrator/components/com_media/src/Provider/ProviderInterface.php پیاده‌سازی می‌کند. این قسمت ساده است و شما می‌توانید روش پلاگین Local Filesystem را در plugins/filesystem/local/src/Extension/Local.php کپی کنید. کلاس اصلی پلاگین (افزونه) به عنوان کلاس Provider نیز عمل می‌کند.

- کلاس Adapter که اینترفیس Joomla\Component\Media\Administrator\Adapter\AdapterInterface را در مسیر administrator/components/com_media/src/Adapter/AdapterInterface.php پیاده‌سازی می‌کند. عمده‌ی کار در اینجا است، چون باید انواع عملیات فایل را به فراخوانی‌های FTP نگاشت کنید. مجموعه توابع PHP مربوط به FTP در FTP Functions فهرست شده‌اند.

آزمایش

برای آزمایش بخش‌های FTP باید یک سرور FTP روی ماشین محلی‌تان نصب کنید. من برای تست کد پلاگین filesystem از Filezilla استفاده کردم، اما توجه کنید ممکن است تفاوت‌هایی در رابط کاربری بین پیاده‌سازی‌های مختلف سرور FTP وجود داشته باشد. به‌خصوص، تابع PHP به نام ftp_mlsd ممکن است موجود نباشد و شما برای مدیریت لیست فایل‌ها به جای آن باید از ftp_nlist یا ftp_rawlist استفاده کنید. در Filezilla یک کاربر آزمایشی و یک نقطه اتصال (mount point) تعریف کردم که مسیر /shared را به یک فولدر روی کامپیوترم مپ می‌کند. این جزییات باید توسط پلاگین شناخته شود، بنابراین به عنوان پارامترهای تنظیمات پلاگین در فایل XML manifest تعریف می‌شوند:

- host: من localhost را استفاده کردم 

- username: نام کاربری کاربر روی سرور FTP 

- password: رمز عبور کاربر روی سرور FTP 

- ftproot: مسیر مجازی نقطه اتصال؛ من "shared" را استفاده کردم 

برای تست، نوشتن یک برنامه کوچکی به زبان PHP مفید بود که نتایج فراخوانی توابع مختلف FTP را نشان می‌داد.

اتصال FTP

باید اتصال FTP را در هر درخواست HTTP باز و بسته کنید، چون اگر تلاش کنید اتصال را بین درخواست‌های HTTP باز نگه دارید، ممکن است اتصال تایم‌اوت شود. در این کد پلاگین filesystem، باز کردن اتصال و لاگین کاربر در سازنده (constructor) انجام می‌شود و بسته شدن اتصال در مخرب (destructor).

 
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);
    }
}
 

 

آدرس‌های URL

اگر بخواهید رسانه‌های خود را در وب‌سایت بخش جلویی (فرانت‌اند) نمایش دهید، باید URLهایی برای دسترسی بازدیدکنندگان فراهم کنید که این کار را از طریق تابع `getUrl()` در آداپتر خود انجام می‌دهید. شما دو گزینه دارید:

1. اگر وب‌سرور روی همان سرور FTP شما باشد و دایرکتوری FTP از طریق وب‌سرور قابل دسترسی باشد، کافی است URL مربوط به همین وب‌سرور را بسازید.

2. در غیر این صورت، باید فایل را از سرور FTP دانلود کرده و در یک دایرکتوری محلی ذخیره کنید و URL مربوط به فایل محلی را بسازید.

در کد پلاگین فایل‌سیستم، هر دو گزینه برنامه‌نویسی شده‌اند و می‌توانید با تنظیم پارامتر پلاگین `"urlroot"` مشخص کنید کدام گزینه استفاده شود:

- اگر مقدار `"urlroot"` را بدهید، کد URL را از این مقدار و آرگومان `$path` می‌سازد.

- اگر مقدار ندهید، کد فایل را دانلود کرده و در دایرکتوری `/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` موجود باشد. البته این در محیط تولید (Production) کارایی ندارد چون ممکن است محتوای فایل روی سرور FTP تغییر کند، اما می‌توانید این روش را برای ایجاد قابلیت کش بهتر کنید.

کد منبع پلاگین

می‌توانید کد منبع زیر را در یک دایرکتوری به نام `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>من</author>
    <creationDate>امروز</creationDate>
    <version>1.0.0</version>
    <description>سیستم‌فایل FTP من</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"
                    default=""
                >
                </field>
                <field 
                    name="username"
                    type="text"
                    label="نام کاربری FTP"
                    default=""
                >
                </field>
                <field 
                    name="password"
                    type="text"
                    label="رمز عبور FTP"
                    default=""
                >
                </field>
                <field 
                    name="ftproot"
                    type="text"
                    label="نقطه اتصال ریشه FTP"
                    default=""
                >
                </field>
                <field 
                    name="urlroot"
                    type="text"
                    label="آدرس پایه URL دایرکتوری سرور FTP"
                    default=""
                >
                </field>
            </fieldset>
        </fields>
    </config>
</extension>

 

فایل ارائه‌دهنده سرویس

این کد الگو (boilerplate) برای نمونه‌سازی پلاگین از طریق کانتینر تزریق وابستگی جوملا است. 

`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;
            }
        );
    }
};

 

کلاس پلاگین / ارائه‌دهنده

این کلاس برگرفته شده از کلاس معادل آن در پلاگین فایل‌سیستم محلی جوملا است. 

`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',
        ];
    }
    
    /**
     * پیکربندی ارائه‌دهنده‌ها برای آداپتر FTP
     *
     * @param   MediaProviderEvent  $event  رویداد مربوط به ProviderManager
     *
     * @return   void
     *
     * @since    4.0.0
     */
    public function onSetupProviders(MediaProviderEvent $event)
    {
        $event->getProviderManager()->registerProvider($this);
    }

    /**
     * بازگرداندن شناسه ارائه‌دهنده
     *
     * @return  string
     *
     * @since  4.0.0
     */
    public function getID()
    {
        return $this->_name; // از فیلد "element" رکورد پلاگین در جدول افزونه‌ها
    }

    /**
     * بازگرداندن نام نمایشی ارائه‌دهنده
     *
     * @return string
     *
     * @since  4.0.0
     */
    public function getDisplayName()
    {
        return 'FTP از راه دور';
    }

    /**
     * بازگرداندن آرایه‌ای از آداپترها
     *
     * @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;
    }
}
 

کلاس آداپتور (Adapter)

اینجا جایی است که تمام کارها انجام می‌شود! به وضوح بین کد getFile و getFiles همپوشانی وجود دارد، اما کد به این صورت باقی مانده است تا درک عملکرد هر تابع آسان‌تر شود.

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";
                $obj->width     = 0;
                $obj->height    = 0;

                // Add thumbnail preview when possible. Recomended size: should fit in to 200x200px box.
                // Relative or full path.
                $obj->thumb_path = Uri::root(true) . '/images/powered_by.png';
                // Optional, an actual size of thumbnail to enable lazy loading
                $obj->thumb_width = 200;
                $obj->thumb_height = 44;

                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";
                        $obj->width     = 0;
                        $obj->height    = 0;

                        // Add thumbnail preview when possible. Recomended size: should fit in to 200x200px box.
                        // Relative or full path.
                        $obj->thumb_path = Uri::root(true) . '/images/powered_by.png';
                        // Optional, an actual size of thumbnail to enable lazy loading
                        $obj->thumb_width = 200;
                        $obj->thumb_height = 44;

                        $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";
            $obj->width     = 0;
            $obj->height    = 0;
            
            // Add thumbnail preview when possible. Recomended size: should fit in to 200x200px box.
            // Relative or full path.
            $obj->thumb_path = Uri::root(true) . '/images/powered_by.png';
            // Optional, an actual size of thumbnail to enable lazy loading
            $obj->thumb_width = 200;
            $obj->thumb_height = 44;
            
            $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;
    }
}