diff --git a/OliverBooth/Data/CdnAssetType.cs b/OliverBooth/Data/CdnAssetType.cs
new file mode 100644
index 0000000..d3fd40c
--- /dev/null
+++ b/OliverBooth/Data/CdnAssetType.cs
@@ -0,0 +1,22 @@
+namespace OliverBooth.Data;
+
+///
+/// An enumeration of CDN asset types.
+///
+public enum CdnAssetType
+{
+ ///
+ /// An image on a blog post.
+ ///
+ BlogImage,
+
+ ///
+ /// A multimedia asset (audio and video) on a blog post.
+ ///
+ BlogMedia,
+
+ ///
+ /// A raw (typically binary) asset on a blog post.
+ ///
+ BlogRaw
+}
diff --git a/OliverBooth/OliverBooth.csproj b/OliverBooth/OliverBooth.csproj
index a8e1cde..03dea62 100644
--- a/OliverBooth/OliverBooth.csproj
+++ b/OliverBooth/OliverBooth.csproj
@@ -31,6 +31,8 @@
+
+
diff --git a/OliverBooth/Program.cs b/OliverBooth/Program.cs
index cc0672a..50f3e3a 100644
--- a/OliverBooth/Program.cs
+++ b/OliverBooth/Program.cs
@@ -1,5 +1,8 @@
+using System.Security.Authentication;
using Asp.Versioning;
using AspNetCore.ReCaptcha;
+using FluentFTP;
+using FluentFTP.Logging;
using Markdig;
using OliverBooth.Data.Blog;
using OliverBooth.Data.Web;
@@ -8,6 +11,7 @@ using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp;
using OliverBooth.Services;
using Serilog;
+using Serilog.Extensions.Logging;
using X10D.Hosting.DependencyInjection;
Log.Logger = new LoggerConfiguration()
@@ -43,6 +47,25 @@ builder.Services.AddApiVersioning(options =>
builder.Services.AddDbContextFactory();
builder.Services.AddDbContextFactory();
builder.Services.AddHttpClient();
+builder.Services.AddTransient(provider =>
+{
+ var configuration = provider.GetRequiredService();
+ string? host = configuration["Cdn:Ftp:Host"];
+ string? username = configuration["Cdn:Ftp:Username"];
+ string? password = configuration["Cdn:Ftp:Password"];
+
+ if (string.IsNullOrWhiteSpace(host) || string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+ {
+ throw new AuthenticationException("Configuration value missing for CDN FTP.");
+ }
+
+ var client = new AsyncFtpClient(host, username, password);
+ var loggerFactory = new SerilogLoggerFactory(Log.Logger);
+ client.Logger = new FtpLogAdapter(loggerFactory.CreateLogger("FTP"));
+ return client;
+});
+
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
diff --git a/OliverBooth/Services/CdnService.cs b/OliverBooth/Services/CdnService.cs
new file mode 100644
index 0000000..228796a
--- /dev/null
+++ b/OliverBooth/Services/CdnService.cs
@@ -0,0 +1,109 @@
+using System.ComponentModel;
+using FluentFTP;
+using OliverBooth.Data;
+
+namespace OliverBooth.Services;
+
+internal sealed class CdnService : ICdnService
+{
+ private readonly ILogger _logger;
+ private readonly IConfiguration _configuration;
+ private readonly IAsyncFtpClient _ftpClient;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger.
+ /// The configuration.
+ /// The FTP client.
+ public CdnService(ILogger logger, IConfiguration configuration, IAsyncFtpClient ftpClient)
+ {
+ _logger = logger;
+ _configuration = configuration;
+ _ftpClient = ftpClient;
+ }
+
+ ///
+ public Task CreateAssetAsync(FileStream stream, CdnAssetType assetType)
+ {
+ string filename = Path.GetFileName(stream.Name);
+ return CreateAssetAsync(filename, stream, assetType);
+ }
+
+ ///
+ public async Task CreateAssetAsync(string filename, Stream stream, CdnAssetType assetType)
+ {
+ if (filename is null)
+ {
+ throw new ArgumentNullException(nameof(filename));
+ }
+
+ if (string.IsNullOrWhiteSpace(filename))
+ {
+ throw new ArgumentException("Filename cannot be empty");
+ }
+
+ if (stream is null)
+ {
+ throw new ArgumentNullException(nameof(stream));
+ }
+
+ if (!stream.CanRead)
+ {
+ throw new ArgumentException("The provided stream cannot be read.");
+ }
+
+ if (!Enum.IsDefined(assetType))
+ {
+ throw new InvalidEnumArgumentException(nameof(assetType), (int)assetType, typeof(CdnAssetType));
+ }
+
+ DateTimeOffset now = DateTimeOffset.UtcNow;
+ string basePath = _configuration["Cdn:Ftp:ChRoot"]!;
+ string? relativePath = _configuration[$"Cdn:AssetTypeMap:{assetType:G}"];
+ string remotePath = $"{basePath}{relativePath}/{now:yyyy\\/MM}/{filename}";
+ _logger.LogDebug("Base path is {Path}", basePath);
+ _logger.LogDebug("Relative path is {Path}", relativePath);
+ _logger.LogDebug("Full remote path is {Path}", remotePath);
+
+ _logger.LogInformation("Connecting to FTP server");
+ await _ftpClient.AutoConnect();
+
+ _logger.LogInformation("Asset will be at {RemotePath}", remotePath);
+ await _ftpClient.UploadStream(stream, remotePath, FtpRemoteExists.Skip, true);
+
+ _logger.LogInformation("Asset upload complete. Disconnecting");
+ await _ftpClient.Disconnect();
+
+ return GetUri(now, filename, assetType);
+ }
+
+ ///
+ public Uri GetUri(DateOnly date, string filename, CdnAssetType assetType)
+ {
+ if (filename is null)
+ {
+ throw new ArgumentNullException(nameof(filename));
+ }
+
+ if (string.IsNullOrWhiteSpace(filename))
+ {
+ throw new ArgumentException("Filename cannot be empty");
+ }
+
+ if (!Enum.IsDefined(assetType))
+ {
+ throw new InvalidEnumArgumentException(nameof(assetType), (int)assetType, typeof(CdnAssetType));
+ }
+
+ string? relativePath = _configuration[$"Cdn:AssetTypeMap:{assetType:G}"];
+ string url = $"{_configuration["Cdn:BaseUrl"]}{relativePath}/{date:yyyy\\/MM)}/{filename}";
+ return new Uri(url);
+ }
+
+ ///
+ public Uri GetUri(DateTimeOffset date, string filename, CdnAssetType assetType)
+ {
+ return GetUri(DateOnly.FromDateTime(date.DateTime), filename, assetType);
+ }
+}
diff --git a/OliverBooth/Services/ICdnService.cs b/OliverBooth/Services/ICdnService.cs
new file mode 100644
index 0000000..65f951a
--- /dev/null
+++ b/OliverBooth/Services/ICdnService.cs
@@ -0,0 +1,77 @@
+using System.ComponentModel;
+using OliverBooth.Data;
+
+namespace OliverBooth.Services;
+
+///
+/// Represents a service which communicates with the CDN server.
+///
+public interface ICdnService
+{
+ ///
+ /// Asynchronously uploads a new asset to the CDN server.
+ ///
+ /// A stream containing the data to upload.
+ /// The type of the asset.
+ /// The URI of the newly-created asset.
+ /// is .
+ /// The is not readable.
+ ///
+ /// is not a valid .
+ ///
+ Task CreateAssetAsync(FileStream stream, CdnAssetType assetType);
+
+ ///
+ /// Asynchronously uploads a new asset to the CDN server.
+ ///
+ /// The filename of the asset.
+ /// A stream containing the data to upload.
+ /// The type of the asset.
+ /// The URI of the newly-created asset.
+ ///
+ /// is .
+ /// -or-
+ /// is .
+ ///
+ ///
+ /// is empty, or contains only whitespace.
+ /// -or-
+ /// The is not readable.
+ ///
+ ///
+ /// is not a valid .
+ ///
+ Task CreateAssetAsync(string filename, Stream stream, CdnAssetType assetType);
+
+ ///
+ /// Gets the resolved asset URI for the specified date and filename.
+ ///
+ /// The date of the asset.
+ /// The filename of the asset.
+ /// The type of the asset.
+ /// The resolved asset URI.
+ /// is .
+ ///
+ /// is empty, or contains only whitespace.
+ ///
+ ///
+ /// is not a valid .
+ ///
+ Uri GetUri(DateOnly date, string filename, CdnAssetType assetType);
+
+ ///
+ /// Gets the resolved asset URI for the specified date and filename.
+ ///
+ /// The date of the asset.
+ /// The filename of the asset.
+ /// The type of the asset.
+ /// The resolved asset URI.
+ /// is .
+ ///
+ /// is empty, or contains only whitespace.
+ ///
+ ///
+ /// is not a valid .
+ ///
+ Uri GetUri(DateTimeOffset date, string filename, CdnAssetType assetType);
+}
\ No newline at end of file