Remote media storage in Umbraco

Remote media storage in Umbraco

After years of using Umbraco just on a single server and having it running perfectly I one day got a question from the customer telling me he wanted to get rid of the server and if it was possible to move the Umbraco instance to his Azure Kubernetes cluster. Since it has been possible to run Umbraco inside a Docker container since Umbraco 9 this wasn't an issue. Using Umbraco's Azure Blob storage provider took care of the media storage for me. However this got me thinking, what if I wanted to use AWS or even stranger say an FTP folder on my NAS. 

So I started looking into this and discovered that Umbraco allows you to make your own filesystem implementation which allows you to store your media virtually anywhere. However I found that documentation was sketchy and scarce to come by. So in this guide I'll show you how I implemented a filesystem for Umbraco using an FTP server as storage.

First of we need an Umbraco instance, this doesn't have to be a clean install, but for this guide I will be using one. If you're using an existing installation be aware that your local files will need to be moved to storage when your done. Also if you're using an existing installation be aware that any image caching and resizing modifications you have in place might interfere, you'll need to solve those.

So starting with a clean installation I'll first make an interface for my filesystem implementation. In my case I've made an interface (IFTPFilesystem) that inherits from IFileSystem and adds a method GetContentType. This is important later on.

using Umbraco.Cms.Core.IO;

namespace MyFilesystem.FTPFilesystem
{
    public interface IFTPFilesystem : IFileSystem
    {
        string GetContentType(string path);
    }
}

Next we need to implement the IFTPFilesystem interface, for this we add a class FTPFilesystem which implements the IFTPFilesystem interface. The interface forces us to implement the necessary functions which Umbraco needs to store and retreive the files and file information. I'll be using FluentFTP (https://www.nuget.org/packages/FluentFTP) to perform the FTP handling.

using FluentFTP;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Options;
using System.Net;
using System.Text.RegularExpressions;

namespace MyFilesystem.FTPFilesystem
{
    public class FTPFilesystem(IOptions<FTPSettings> settings) : IFTPFilesystem
    {
        private readonly FTPSettings _settings = settings.Value;
        private readonly string _mediaFolder = settings.Value.MediaFolder.EnsureStartsWith("/");
        private readonly string _relativeUrlPrefix = "/media/";
        public bool CanAddPhysical => true;

        public void AddFile(string path, Stream stream)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.UploadStream(stream, _mediaFolder + path, FtpRemoteExists.Overwrite, true);
        }

        public void AddFile(string path, Stream stream, bool overrideIfExists)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.UploadStream(stream, _mediaFolder + path, overrideIfExists ? FtpRemoteExists.Overwrite : FtpRemoteExists.Skip, true);
        }

        public void AddFile(string path, string physicalPath, bool overrideIfExists = true, bool copy = false)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.UploadFile(physicalPath, _mediaFolder + path, overrideIfExists ? FtpRemoteExists.Overwrite : FtpRemoteExists.Skip, true);
        }

        public void DeleteDirectory(string path)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.DeleteDirectory(_mediaFolder + path, FtpListOption.AllFiles);
        }

        public void DeleteDirectory(string path, bool recursive)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.DeleteDirectory(_mediaFolder + path, FtpListOption.AllFiles);
        }

        public void DeleteFile(string path)
        {
            path = path.ReplaceFirst(_relativeUrlPrefix, "/").EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            ftp.DeleteFile(_mediaFolder + path);
        }

        public bool DirectoryExists(string path)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.DirectoryExists(_mediaFolder + path);
        }

        public bool FileExists(string path)
        {
            path = path.ReplaceFirst(_relativeUrlPrefix, "/").EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.FileExists(_mediaFolder + path);
        }

        public DateTimeOffset GetCreated(string path)
        {
            path = path.ReplaceFirst(_relativeUrlPrefix, "/").EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetObjectInfo(_mediaFolder + path).Created;
        }

        public IEnumerable<string> GetDirectories(string path)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetListing(_mediaFolder + path).Where(x => x != null && x.Type == FtpObjectType.Directory).Select(x=> x.Name);
        }

        public IEnumerable<string> GetFiles(string path)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetListing(_mediaFolder + path).Where(x => x != null && x.Type == FtpObjectType.File).Select(x => x.Name);
        }

        public IEnumerable<string> GetFiles(string path, string filter)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetListing(_mediaFolder + path).Where(x => x != null && x.Type == FtpObjectType.File && Regex.IsMatch(x.Name, "(\\S+?(?:" + filter.Replace("*.", string.Empty) + "))")).Select(x => x.Name);
        }

        public string GetFullPath(string path)
        {
            return path.ToLower().EnsureStartsWith(_relativeUrlPrefix);
        }

        public DateTimeOffset GetLastModified(string path)
        {
            path = path.ReplaceFirst(_relativeUrlPrefix, "/").EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetObjectInfo(_mediaFolder + path).Modified;
        }

        public string GetRelativePath(string fullPathOrUrl)
        {
            return fullPathOrUrl.ReplaceFirst($"ftp://{WebUtility.UrlEncode(_settings.Username)}:{WebUtility.UrlEncode(_settings.Password)}@{_settings.Host}:{_settings.Port}", string.Empty).EnsureStartsWith("/");
        }

        public long GetSize(string path)
        {
            path = path.EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.GetObjectInfo(_mediaFolder + path).Size;
        }

        public string GetUrl(string? path)
        {
            return path?.EnsureStartsWith(_relativeUrlPrefix) ?? string.Empty;
        }

        public Stream OpenFile(string path)
        {
            path = path.ReplaceFirst(_relativeUrlPrefix, "/").EnsureStartsWith("/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return ftp.OpenRead(_mediaFolder + path);
        }

        public string GetContentType(string path)
        {
            _ = new FileExtensionContentTypeProvider().TryGetContentType(path, out var contentType);
            return contentType ?? "application/octet-stream";
        }
    }
}

The implemented filesystem needs a number of settings to connect to the FTP server and to know where to put the files. For this I've added a simple FTPSettings class and added the necessary settings to appsettings.json.

namespace MyFilesystem.FTPFilesystem
{
    public class FTPSettings
    {
        public string MediaFolder { get; set; } = "media";
        public string CacheFolder { get; set; } = "cache";

        public string Host { get; set; }
        public string Username { get; set; }
        public string Password { get; set; }
        public int Port { get; set; } = 21;
    }
}

{
  "$schema": "appsettings-schema.json",
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Warning",
        "Microsoft.Hosting.Lifetime": "Information",
        "System": "Warning"
      }
    }
  },
  "Umbraco": {
    "CMS": {
      "Global": {
        "Id": "cf15ff81-f85a-4cd9-9238-71cbc3118dc6",
        "SanitizeTinyMce": true
      },
      "Content": {
        "AllowEditInvariantFromNonDefault": true,
        "ContentVersionCleanupPolicy": {
          "EnableCleanup": true
        }
      },
      "Unattended": {
        "UpgradeUnattended": true
      },
      "Security": {
        "AllowConcurrentLogins": false
      }
    }
  },
  "ConnectionStrings": {
    "umbracoDbDSN": "Data Source=|DataDirectory|/Umbraco.sqlite.db;Cache=Shared;Foreign Keys=True;Pooling=True",
    "umbracoDbDSN_ProviderName": "Microsoft.Data.Sqlite"
  },
  "FTPSettings": {
    "MediaFolder": "media",
    "CacheFolder": "cache",
    "Host": "localhost",
    "Username": "umbraco",
    "Password": "Abc123!@",
    "Port": 21
  }
}

Now that the filesystem is done we need to make Umbraco use it. This can by done by using the SetMediaFileSystem function of the Umbraco builder. To do that we'll first create an extenstion method for IUmbracoBuilder called AddFtpFileSystem. This function loads the FTP server setting from your appsettings.json, ensures the dependency injection is setup correctly and tells Umbraco which filesystem to use.

using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Infrastructure.DependencyInjection;

namespace MyFilesystem.FTPFilesystem
{
    public static class FTPFileSystemExtensions
    {
        public static IUmbracoBuilder AddFtpFileSystem(this IUmbracoBuilder builder)
        {
            builder.Services
                .AddOptions<FTPSettings>()
                .BindConfiguration($"FTPSettings");

            builder.Services.TryAddSingleton<IFTPFilesystem, FTPFilesystem>();

            builder.SetMediaFileSystem(provider => provider.GetRequiredService<IFTPFilesystem>());

            return builder;
        }
    }
}

Next we need to call the extenstion method so go to Program.cs and search for:

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .Build();

Modify this statement to look like:

builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()
    .AddDeliveryApi()
    .AddComposers()
    .AddFtpFileSystem()
    .Build();

Now we can start Umbraco and test our code. Log in to Umbraco and go to the Media section. Upload a file and if all goes well the file should be uploaded to our FTP location (check your FTP server). However you will notice that the images won't display in Umbraco, this is because Umbraco is still looking for them on disk and not finding them. So we need to remedy this. The easiest way to do this is by creating a middleware implementation FTPFilesystemMiddleware which intercepts the requests for media, looks for the file on our FTP server and retreive it when found. It should look like this:

using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using System.Net;
using Umbraco.Cms.Core.Configuration.Models;

namespace MyFilesystem.FTPFilesystem
{
    public class FTPFilesystemMiddleware(IOptions<GlobalSettings> globalSettings, IFTPFilesystem filesystem) : IMiddleware
    {
        private GlobalSettings _globalSettings = globalSettings.Value;
        private IFTPFilesystem _filesystem = filesystem;

        public Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));
            if (next == null) throw new ArgumentNullException(nameof(next));

            return HandleResolveMediaAsync(context, next);
        }

        private async Task HandleResolveMediaAsync(HttpContext context, RequestDelegate next)
        {
            var request = context.Request;
            var response = context.Response;
            var mediaUrlPrefix = _globalSettings.UmbracoMediaPath.ReplaceFirst("~/", "/");

            if (!context.Request.Path.StartsWithSegments(mediaUrlPrefix, StringComparison.InvariantCultureIgnoreCase))
            {
                await next(context).ConfigureAwait(false);
                return;
            }

            string ftpPath = context.Request.Path.Value?.Substring(mediaUrlPrefix.Length);

            if (ftpPath == null || !_filesystem.FileExists(ftpPath))
            {
                response.StatusCode = (int)HttpStatusCode.NotFound;
                return;
            }

            var fileStream = _filesystem.OpenFile(ftpPath);
            var contentType = _filesystem.GetContentType(ftpPath);

            var responseHeaders = response.GetTypedHeaders();
            response.StatusCode = (int)HttpStatusCode.OK;
            response.ContentType = contentType;
            responseHeaders.ContentLength = fileStream.Length;
            responseHeaders.Append(HeaderNames.AcceptRanges, "bytes");

            fileStream.CopyTo(response.Body);


        }
    }
}

Next we need to make Umbraco use it. For this we will need to add 2 functions UseFtpFileSystem to our FTPFileSystemExtensions file. 

using Microsoft.Extensions.DependencyInjection.Extensions;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Web.Common.ApplicationBuilder;

namespace MyFilesystem.FTPFilesystem
{
    public static class FTPFileSystemExtensions
    {
        public static IUmbracoBuilder AddFtpFileSystem(this IUmbracoBuilder builder)
        {
            builder.Services
                .AddOptions<FTPSettings>()
                .BindConfiguration($"FTPSettings");

            builder.Services.TryAddSingleton<FTPFilesystemMiddleware>();
            builder.Services.TryAddSingleton<IFTPFilesystem, FTPFilesystem>();

            builder.SetMediaFileSystem(provider => provider.GetRequiredService<IFTPFilesystem>());

            return builder;
        }

        public static IUmbracoApplicationBuilderContext UseFtpFileSystem(this IUmbracoApplicationBuilderContext builder)
        {
            if (builder == null) throw new ArgumentNullException(nameof(builder));

            UseFtpFileSystem(builder.AppBuilder);

            return builder;
        }

        public static IApplicationBuilder UseFtpFileSystem(this IApplicationBuilder app)
        {
            if (app == null) throw new ArgumentNullException(nameof(app));

            app.UseMiddleware<FTPFilesystemMiddleware>();

            return app;
        }
    }
}

And again this needs to be called. Go to Program.cs and search for:

app.UseUmbraco()
    .WithMiddleware(u =>
    {
        u.UseBackOffice();
        u.UseWebsite();
    })
    .WithEndpoints(u =>
    {
        u.UseInstallerEndpoints();
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
    });

Modify this statement to look like:

app.UseUmbraco()
    .WithMiddleware(u =>
    {
        u.UseBackOffice();
        u.UseWebsite();
        u.UseFtpFileSystem();
    })
    .WithEndpoints(u =>
    {
        u.UseInstallerEndpoints();
        u.UseBackOfficeEndpoints();
        u.UseWebsiteEndpoints();
    });

Start Umbraco again and reload the Media section. The image should load now however a new issue now pops up. Umbraco uses SixLabors.ImageSharp (https://www.nuget.org/packages/SixLabors.ImageSharp) for it's image processing and resizing. However if you try to resize an image by adding one of ImageSharp's resizing parameters (for example ?width=500) you'll notice that you still get the full size image returned. The issue here is once again that ImageSharp is looking for the files on disk and not finding them. So once again we need to fix this. 

We'll have to provide ImageSharp with a provider for the images. We can implement a FTPImageProvider like this:

using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Providers;
using SixLabors.ImageSharp.Web.Resolvers;

namespace MyFilesystem.FTPFilesystem
{
    public class FTPImageProvider(IOptions<FTPSettings> settings, FormatUtilities formatUtilities) : IImageProvider
    {
        private readonly string _relativeUrlPrefix = "/media";

        public ProcessingBehavior ProcessingBehavior => ProcessingBehavior.CommandOnly;
        public Func<HttpContext, bool> Match { get; set; } = _ => true;

        private readonly FTPSettings _settings = settings.Value;
        private readonly FormatUtilities _formatUtilities = formatUtilities;

        public Task<IImageResolver?> GetAsync(HttpContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));

            return Task.FromResult<IImageResolver?>(new FTPImageResolver(_settings, context.Request.Path));
        }

        public bool IsValidRequest(HttpContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));
            return context.Request.Path.StartsWithSegments(_relativeUrlPrefix, StringComparison.InvariantCultureIgnoreCase)
                   && _formatUtilities.TryGetExtensionFromUri(context.Request.GetDisplayUrl(), out _);
        }
    }
}

Which in turn needs a resolver FTPImageResolver:

using FluentFTP;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Resolvers;

namespace MyFilesystem.FTPFilesystem
{
    public class FTPImageResolver(FTPSettings settings, string filePath) : IImageResolver
    {
        private readonly string _relativeUrlPrefix = "/media/";

        private readonly FTPSettings _settings = settings;
        private readonly string _mediaFolder = settings.MediaFolder.EnsureStartsWith("/");

        public Task<ImageMetadata> GetMetaDataAsync()
        {
            var path = filePath.ReplaceFirst(_relativeUrlPrefix, "/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();

            if (!ftp.FileExists(_mediaFolder + path))
            {
                return Task.FromResult(new ImageMetadata(DateTime.Now, TimeSpan.MinValue, 0));
            }
            var metadata = ftp.GetObjectInfo(_mediaFolder + path);
            return Task.FromResult(new ImageMetadata(metadata.Modified, TimeSpan.FromMinutes(2), metadata.Size));
        }

        public Task<Stream> OpenReadAsync()
        {
            var path = filePath.ReplaceFirst(_relativeUrlPrefix, "/");
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return Task.FromResult(ftp.OpenRead(_mediaFolder + path));
        }
    }
}

And we need to make Umbraco use it by modifying the AddFtpFileSystem extenstion function in FTPFileSystemExtensions like so:

using Microsoft.Extensions.DependencyInjection.Extensions;
using SixLabors.ImageSharp.Web.DependencyInjection;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Web.Common.ApplicationBuilder;

namespace MyFilesystem.FTPFilesystem
{
    public static class FTPFileSystemExtensions
    {
        public static IUmbracoBuilder AddFtpFileSystem(this IUmbracoBuilder builder)
        {
            builder.Services
                .AddOptions<FTPSettings>()
                .BindConfiguration($"FTPSettings");

            builder.Services.TryAddSingleton<FTPFilesystemMiddleware>();
            builder.Services.TryAddSingleton<IFTPFilesystem, FTPFilesystem>();
            builder.Services.AddImageSharp().ClearProviders().AddProvider<FTPImageProvider>();

            builder.SetMediaFileSystem(provider => provider.GetRequiredService<IFTPFilesystem>());

            return builder;
        }

        public static IUmbracoApplicationBuilderContext UseFtpFileSystem(this IUmbracoApplicationBuilderContext builder)
        {
            if (builder == null) throw new ArgumentNullException(nameof(builder));

            UseFtpFileSystem(builder.AppBuilder);

            return builder;
        }

        public static IApplicationBuilder UseFtpFileSystem(this IApplicationBuilder app)
        {
            if (app == null) throw new ArgumentNullException(nameof(app));

            app.UseMiddleware<FTPFilesystemMiddleware>();

            return app;
        }
    }
}

Again we start Umbraco and lo and behold the images are resizing properly now.

At this point you could just call it a day and have your images working. However this will force ImageSharp the retreive and resize the image every time you attempt to load it. To prevent this we can implement a simple cache FTPImageCache like this:

using FluentFTP;
using Microsoft.Extensions.Options;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Caching;
using SixLabors.ImageSharp.Web.Resolvers;
using System.Text.Json;

namespace MyFilesystem.FTPFilesystem
{
    public class FTPImageCache(IOptions<FTPSettings> settings) : IImageCache
    {
        private const string metaSuffix = ".meta";

        private readonly FTPSettings _settings = settings.Value;
        private readonly string cacheFolder = settings.Value.CacheFolder.EnsureEndsWith("/");

        public Task<IImageCacheResolver?> GetAsync(string key)
        {
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();

            if(!ftp.FileExists(cacheFolder + key + metaSuffix))
            {
                return Task.FromResult<IImageCacheResolver?>(null);
            }

            ftp.DownloadBytes(out var bytes, cacheFolder + key + metaSuffix);

            var metadata = JsonSerializer.Deserialize<ImageCacheMetadataObject>(bytes);

            return Task.FromResult<IImageCacheResolver?>(new FTPImageCacheResolver(_settings, cacheFolder + key, metadata));
        }

        public Task SetAsync(string key, Stream stream, ImageCacheMetadata metadata)
        {
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();

            ftp.UploadStream(stream, cacheFolder + key, FtpRemoteExists.Overwrite, true);
            ftp.UploadBytes(JsonSerializer.SerializeToUtf8Bytes(metadata), cacheFolder + key + metaSuffix, FtpRemoteExists.Overwrite, true);
            return Task.CompletedTask;
        }
    }
}

Which like the FTPImageProvider needs a resolver FTPImageCacheResolver:

using FluentFTP;
using SixLabors.ImageSharp.Web;
using SixLabors.ImageSharp.Web.Resolvers;

namespace MyFilesystem.FTPFilesystem
{
    /// <summary>
    public class FTPImageCacheResolver(FTPSettings settings, string cacheKey, ImageCacheMetadataObject metadata) : IImageCacheResolver
    {
        private readonly FTPSettings _settings = settings;
        private readonly string _cacheKey = cacheKey;
        private readonly ImageCacheMetadataObject _metadata = metadata;

        public Task<ImageCacheMetadata> GetMetaDataAsync()
        {
            return Task.FromResult(new ImageCacheMetadata(
                _metadata.SourceLastWriteTimeUtc,
                _metadata.CacheLastWriteTimeUtc,
                _metadata.ContentType,
                _metadata.CacheControlMaxAge,
                _metadata.ContentLength
            ));
        }

        public Task<Stream> OpenReadAsync()
        {
            using var ftp = new FtpClient(_settings.Host, _settings.Username, _settings.Password, _settings.Port);
            ftp.Connect();
            return Task.FromResult(ftp.OpenRead(_cacheKey));
        }
    }
}

For the last time we tell Umbraco to use this by modifying the AddFtpFileSystem extenstion function in FTPFileSystemExtensions like so:

using Microsoft.Extensions.DependencyInjection.Extensions;
using SixLabors.ImageSharp.Web.Caching;
using SixLabors.ImageSharp.Web.DependencyInjection;
using Umbraco.Cms.Infrastructure.DependencyInjection;
using Umbraco.Cms.Web.Common.ApplicationBuilder;

namespace MyFilesystem.FTPFilesystem
{
    public static class FTPFileSystemExtensions
    {
        public static IUmbracoBuilder AddFtpFileSystem(this IUmbracoBuilder builder)
        {
            builder.Services
                .AddOptions<FTPSettings>()
                .BindConfiguration($"FTPSettings");

            builder.Services.TryAddSingleton<FTPFilesystemMiddleware>();
            builder.Services.TryAddSingleton<IFTPFilesystem, FTPFilesystem>();
            builder.Services.AddUnique<IImageCache, FTPImageCache>();
            builder.Services.AddImageSharp().ClearProviders().AddProvider<FTPImageProvider>().SetCache<FTPImageCache>();

            builder.SetMediaFileSystem(provider => provider.GetRequiredService<IFTPFilesystem>());

            return builder;
        }

        public static IUmbracoApplicationBuilderContext UseFtpFileSystem(this IUmbracoApplicationBuilderContext builder)
        {
            if (builder == null) throw new ArgumentNullException(nameof(builder));

            UseFtpFileSystem(builder.AppBuilder);

            return builder;
        }

        public static IApplicationBuilder UseFtpFileSystem(this IApplicationBuilder app)
        {
            if (app == null) throw new ArgumentNullException(nameof(app));

            app.UseMiddleware<FTPFilesystemMiddleware>();

            return app;
        }
    }
}

Et voila there you have it a working FTP filesystem for Umbraco with working caching and resizing for your media files.

You can find the full example project on my GitHub: Umbraco-Remote-Storage