From e11c8327ece93b7d4019e2efc273fa5e4111e152 Mon Sep 17 00:00:00 2001 From: Oliver Booth Date: Thu, 29 Feb 2024 18:10:04 +0000 Subject: [PATCH] feat: add CDN backend API --- OliverBooth/Data/CdnAssetType.cs | 22 ++++++ OliverBooth/OliverBooth.csproj | 2 + OliverBooth/Program.cs | 23 ++++++ OliverBooth/Services/CdnService.cs | 109 ++++++++++++++++++++++++++++ OliverBooth/Services/ICdnService.cs | 77 ++++++++++++++++++++ 5 files changed, 233 insertions(+) create mode 100644 OliverBooth/Data/CdnAssetType.cs create mode 100644 OliverBooth/Services/CdnService.cs create mode 100644 OliverBooth/Services/ICdnService.cs 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