feat: add CDN backend API

This commit is contained in:
Oliver Booth 2024-02-29 18:10:04 +00:00
parent 0a86721db2
commit e11c8327ec
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
5 changed files with 233 additions and 0 deletions

View File

@ -0,0 +1,22 @@
namespace OliverBooth.Data;
/// <summary>
/// An enumeration of CDN asset types.
/// </summary>
public enum CdnAssetType
{
/// <summary>
/// An image on a blog post.
/// </summary>
BlogImage,
/// <summary>
/// A multimedia asset (audio and video) on a blog post.
/// </summary>
BlogMedia,
/// <summary>
/// A raw (typically binary) asset on a blog post.
/// </summary>
BlogRaw
}

View File

@ -31,6 +31,8 @@
<PackageReference Include="Asp.Versioning.Mvc" Version="8.0.0"/> <PackageReference Include="Asp.Versioning.Mvc" Version="8.0.0"/>
<PackageReference Include="AspNetCore.ReCaptcha" Version="1.8.1"/> <PackageReference Include="AspNetCore.ReCaptcha" Version="1.8.1"/>
<PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/> <PackageReference Include="BCrypt.Net-Core" Version="1.6.0"/>
<PackageReference Include="FluentFTP" Version="49.0.2" />
<PackageReference Include="FluentFTP.Logging" Version="1.0.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.59"/> <PackageReference Include="HtmlAgilityPack" Version="1.11.59"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/> <PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="MailKit" Version="4.3.0"/> <PackageReference Include="MailKit" Version="4.3.0"/>

View File

@ -1,5 +1,8 @@
using System.Security.Authentication;
using Asp.Versioning; using Asp.Versioning;
using AspNetCore.ReCaptcha; using AspNetCore.ReCaptcha;
using FluentFTP;
using FluentFTP.Logging;
using Markdig; using Markdig;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
using OliverBooth.Data.Web; using OliverBooth.Data.Web;
@ -8,6 +11,7 @@ using OliverBooth.Markdown.Template;
using OliverBooth.Markdown.Timestamp; using OliverBooth.Markdown.Timestamp;
using OliverBooth.Services; using OliverBooth.Services;
using Serilog; using Serilog;
using Serilog.Extensions.Logging;
using X10D.Hosting.DependencyInjection; using X10D.Hosting.DependencyInjection;
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
@ -43,6 +47,25 @@ builder.Services.AddApiVersioning(options =>
builder.Services.AddDbContextFactory<BlogContext>(); builder.Services.AddDbContextFactory<BlogContext>();
builder.Services.AddDbContextFactory<WebContext>(); builder.Services.AddDbContextFactory<WebContext>();
builder.Services.AddHttpClient(); builder.Services.AddHttpClient();
builder.Services.AddTransient<IAsyncFtpClient, AsyncFtpClient>(provider =>
{
var configuration = provider.GetRequiredService<IConfiguration>();
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<ICdnService, CdnService>();
builder.Services.AddSingleton<IContactService, ContactService>(); builder.Services.AddSingleton<IContactService, ContactService>();
builder.Services.AddSingleton<ITemplateService, TemplateService>(); builder.Services.AddSingleton<ITemplateService, TemplateService>();
builder.Services.AddSingleton<IBlogPostService, BlogPostService>(); builder.Services.AddSingleton<IBlogPostService, BlogPostService>();

View File

@ -0,0 +1,109 @@
using System.ComponentModel;
using FluentFTP;
using OliverBooth.Data;
namespace OliverBooth.Services;
internal sealed class CdnService : ICdnService
{
private readonly ILogger<CdnService> _logger;
private readonly IConfiguration _configuration;
private readonly IAsyncFtpClient _ftpClient;
/// <summary>
/// Initializes a new instance of the <see cref="CdnService" /> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="ftpClient">The FTP client.</param>
public CdnService(ILogger<CdnService> logger, IConfiguration configuration, IAsyncFtpClient ftpClient)
{
_logger = logger;
_configuration = configuration;
_ftpClient = ftpClient;
}
/// <inheritdoc />
public Task<Uri> CreateAssetAsync(FileStream stream, CdnAssetType assetType)
{
string filename = Path.GetFileName(stream.Name);
return CreateAssetAsync(filename, stream, assetType);
}
/// <inheritdoc />
public async Task<Uri> 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);
}
/// <inheritdoc />
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);
}
/// <inheritdoc />
public Uri GetUri(DateTimeOffset date, string filename, CdnAssetType assetType)
{
return GetUri(DateOnly.FromDateTime(date.DateTime), filename, assetType);
}
}

View File

@ -0,0 +1,77 @@
using System.ComponentModel;
using OliverBooth.Data;
namespace OliverBooth.Services;
/// <summary>
/// Represents a service which communicates with the CDN server.
/// </summary>
public interface ICdnService
{
/// <summary>
/// Asynchronously uploads a new asset to the CDN server.
/// </summary>
/// <param name="stream">A stream containing the data to upload.</param>
/// <param name="assetType">The type of the asset.</param>
/// <returns>The URI of the newly-created asset.</returns>
/// <exception cref="ArgumentNullException"><paramref name="stream" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentException">The <paramref name="stream" /> is not readable.</exception>
/// <exception cref="InvalidEnumArgumentException">
/// <paramref name="assetType" /> is not a valid <see cref="CdnAssetType" />.
/// </exception>
Task<Uri> CreateAssetAsync(FileStream stream, CdnAssetType assetType);
/// <summary>
/// Asynchronously uploads a new asset to the CDN server.
/// </summary>
/// <param name="filename">The filename of the asset.</param>
/// <param name="stream">A stream containing the data to upload.</param>
/// <param name="assetType">The type of the asset.</param>
/// <returns>The URI of the newly-created asset.</returns>
/// <exception cref="ArgumentNullException">
/// <para><paramref name="filename" /> is <see langword="null" />.</para>
/// -or-
/// <para><paramref name="stream" /> is <see langword="null" />.</para>
/// </exception>
/// <exception cref="ArgumentException">
/// <para><paramref name="filename" /> is empty, or contains only whitespace.</para>
/// -or-
/// <para>The <paramref name="stream" /> is not readable.</para>
/// </exception>
/// <exception cref="InvalidEnumArgumentException">
/// <paramref name="assetType" /> is not a valid <see cref="CdnAssetType" />.
/// </exception>
Task<Uri> CreateAssetAsync(string filename, Stream stream, CdnAssetType assetType);
/// <summary>
/// Gets the resolved asset URI for the specified date and filename.
/// </summary>
/// <param name="date">The date of the asset.</param>
/// <param name="filename">The filename of the asset.</param>
/// <param name="assetType">The type of the asset.</param>
/// <returns>The resolved asset URI.</returns>
/// <exception cref="ArgumentNullException"><paramref name="filename" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentException">
/// <paramref name="filename" /> is empty, or contains only whitespace.
/// </exception>
/// <exception cref="InvalidEnumArgumentException">
/// <paramref name="assetType" /> is not a valid <see cref="CdnAssetType" />.
/// </exception>
Uri GetUri(DateOnly date, string filename, CdnAssetType assetType);
/// <summary>
/// Gets the resolved asset URI for the specified date and filename.
/// </summary>
/// <param name="date">The date of the asset.</param>
/// <param name="filename">The filename of the asset.</param>
/// <param name="assetType">The type of the asset.</param>
/// <returns>The resolved asset URI.</returns>
/// <exception cref="ArgumentNullException"><paramref name="filename" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentException">
/// <paramref name="filename" /> is empty, or contains only whitespace.
/// </exception>
/// <exception cref="InvalidEnumArgumentException">
/// <paramref name="assetType" /> is not a valid <see cref="CdnAssetType" />.
/// </exception>
Uri GetUri(DateTimeOffset date, string filename, CdnAssetType assetType);
}