feat: add CDN backend API
This commit is contained in:
parent
0a86721db2
commit
e11c8327ec
|
@ -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
|
||||
}
|
|
@ -31,6 +31,8 @@
|
|||
<PackageReference Include="Asp.Versioning.Mvc" Version="8.0.0"/>
|
||||
<PackageReference Include="AspNetCore.ReCaptcha" Version="1.8.1"/>
|
||||
<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="Humanizer.Core" Version="2.14.1"/>
|
||||
<PackageReference Include="MailKit" Version="4.3.0"/>
|
||||
|
|
|
@ -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<BlogContext>();
|
||||
builder.Services.AddDbContextFactory<WebContext>();
|
||||
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<ITemplateService, TemplateService>();
|
||||
builder.Services.AddSingleton<IBlogPostService, BlogPostService>();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
Loading…
Reference in New Issue