Compare commits
3 Commits
c0efc90c31
...
e11c8327ec
Author | SHA1 | Date |
---|---|---|
Oliver Booth | e11c8327ec | |
Oliver Booth | 0a86721db2 | |
Oliver Booth | 148e7eb218 |
|
@ -25,6 +25,12 @@ internal sealed class BlogContext : DbContext
|
||||||
/// <value>The collection of blog posts.</value>
|
/// <value>The collection of blog posts.</value>
|
||||||
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
public DbSet<BlogPost> BlogPosts { get; private set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the collection of blog posts drafts in the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The collection of blog post drafts.</value>
|
||||||
|
public DbSet<BlogPostDraft> BlogPostDrafts { get; private set; } = null!;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
|
@ -37,5 +43,6 @@ internal sealed class BlogContext : DbContext
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
modelBuilder.ApplyConfiguration(new BlogPostConfiguration());
|
||||||
|
modelBuilder.ApplyConfiguration(new BlogPostDraftConfiguration());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,126 @@
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using SmartFormat;
|
||||||
|
|
||||||
|
namespace OliverBooth.Data.Blog;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
internal sealed class BlogPostDraft : IBlogPostDraft
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
[NotMapped]
|
||||||
|
public IBlogAuthor Author { get; internal set; } = null!;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Body { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool EnableComments { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Guid Id { get; private set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool IsRedirect { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string? Password { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Uri? RedirectUrl { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Slug { get; internal set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<string> Tags { get; internal set; } = ArraySegment<string>.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public DateTimeOffset Updated { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public BlogPostVisibility Visibility { get; internal set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public int? WordPressId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the ID of the author of this blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The ID of the author of this blog post.</value>
|
||||||
|
internal Guid AuthorId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the base URL of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus base URL.</value>
|
||||||
|
internal string? DisqusDomain { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the identifier of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus identifier.</value>
|
||||||
|
internal string? DisqusIdentifier { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the URL path of the Disqus comments for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The Disqus URL path.</value>
|
||||||
|
internal string? DisqusPath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructs a <see cref="BlogPostDraft" /> by copying values from an existing <see cref="BlogPost" />.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="post">The existing <see cref="BlogPost" />.</param>
|
||||||
|
/// <returns>The newly-constructed <see cref="BlogPostDraft" />.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
|
||||||
|
public static BlogPostDraft CreateFromBlogPost(BlogPost post)
|
||||||
|
{
|
||||||
|
if (post is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BlogPostDraft
|
||||||
|
{
|
||||||
|
AuthorId = post.AuthorId,
|
||||||
|
Body = post.Body,
|
||||||
|
DisqusDomain = post.DisqusDomain,
|
||||||
|
DisqusIdentifier = post.DisqusIdentifier,
|
||||||
|
DisqusPath = post.DisqusPath,
|
||||||
|
EnableComments = post.EnableComments,
|
||||||
|
IsRedirect = post.IsRedirect,
|
||||||
|
Password = post.Password,
|
||||||
|
RedirectUrl = post.RedirectUrl,
|
||||||
|
Tags = post.Tags,
|
||||||
|
Title = post.Title,
|
||||||
|
Visibility = post.Visibility,
|
||||||
|
WordPressId = post.WordPressId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus domain for the blog post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus domain.</returns>
|
||||||
|
public string GetDisqusDomain()
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(DisqusDomain)
|
||||||
|
? "https://oliverbooth.dev/blog"
|
||||||
|
: Smart.Format(DisqusDomain, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetDisqusIdentifier()
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public string GetDisqusPostId()
|
||||||
|
{
|
||||||
|
return WordPressId?.ToString() ?? Id.ToString();
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
namespace OliverBooth.Data.Blog.Configuration;
|
||||||
|
|
||||||
|
internal sealed class BlogPostDraftConfiguration : IEntityTypeConfiguration<BlogPostDraft>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Configure(EntityTypeBuilder<BlogPostDraft> builder)
|
||||||
|
{
|
||||||
|
builder.ToTable("BlogPostDraft");
|
||||||
|
builder.HasKey(e => new { e.Id, e.Updated });
|
||||||
|
|
||||||
|
builder.Property(e => e.Id);
|
||||||
|
builder.Property(e => e.Updated).IsRequired();
|
||||||
|
builder.Property(e => e.WordPressId).IsRequired(false);
|
||||||
|
builder.Property(e => e.Slug).HasMaxLength(100).IsRequired();
|
||||||
|
builder.Property(e => e.AuthorId).IsRequired();
|
||||||
|
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
|
||||||
|
builder.Property(e => e.Body).IsRequired();
|
||||||
|
builder.Property(e => e.IsRedirect).IsRequired();
|
||||||
|
builder.Property(e => e.RedirectUrl).HasConversion<UriToStringConverter>().HasMaxLength(255).IsRequired(false);
|
||||||
|
builder.Property(e => e.EnableComments).IsRequired();
|
||||||
|
builder.Property(e => e.DisqusDomain).IsRequired(false);
|
||||||
|
builder.Property(e => e.DisqusIdentifier).IsRequired(false);
|
||||||
|
builder.Property(e => e.DisqusPath).IsRequired(false);
|
||||||
|
builder.Property(e => e.Visibility).HasConversion(new EnumToStringConverter<BlogPostVisibility>()).IsRequired();
|
||||||
|
builder.Property(e => e.Password).HasMaxLength(255).IsRequired(false);
|
||||||
|
builder.Property(e => e.Tags).IsRequired()
|
||||||
|
.HasConversion(
|
||||||
|
tags => string.Join(' ', tags.Select(t => t.Replace(' ', '-'))),
|
||||||
|
tags => tags.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(t => t.Replace('-', ' ')).ToArray());
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,103 @@
|
||||||
|
namespace OliverBooth.Data.Blog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a draft of a blog post.
|
||||||
|
/// </summary>
|
||||||
|
public interface IBlogPostDraft
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the author of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The author of the post.</value>
|
||||||
|
IBlogAuthor Author { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the body of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The body of the post.</value>
|
||||||
|
string Body { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether comments are enabled for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// <see langword="true" /> if comments are enabled for the post; otherwise, <see langword="false" />.
|
||||||
|
/// </value>
|
||||||
|
bool EnableComments { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the ID of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The ID of the post.</value>
|
||||||
|
Guid Id { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the post redirects to another URL.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// <see langword="true" /> if the post redirects to another URL; otherwise, <see langword="false" />.
|
||||||
|
/// </value>
|
||||||
|
bool IsRedirect { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the password of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The password of the post.</value>
|
||||||
|
string? Password { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the URL to which the post redirects.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The URL to which the post redirects, or <see langword="null" /> if the post does not redirect.</value>
|
||||||
|
Uri? RedirectUrl { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the slug of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The slug of the post.</value>
|
||||||
|
string Slug { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the tags of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The tags of the post.</value>
|
||||||
|
IReadOnlyList<string> Tags { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the title of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The title of the post.</value>
|
||||||
|
string Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the date and time the post was last updated.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The update date and time.</value>
|
||||||
|
DateTimeOffset Updated { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the visibility of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>The visibility of the post.</value>
|
||||||
|
BlogPostVisibility Visibility { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the WordPress ID of the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <value>
|
||||||
|
/// The WordPress ID of the post, or <see langword="null" /> if the post was not imported from WordPress.
|
||||||
|
/// </value>
|
||||||
|
int? WordPressId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus identifier for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus identifier for the post.</returns>
|
||||||
|
string GetDisqusIdentifier();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the Disqus post ID for the post.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>The Disqus post ID for the post.</returns>
|
||||||
|
string GetDisqusPostId();
|
||||||
|
}
|
|
@ -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="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"/>
|
||||||
|
|
|
@ -11,9 +11,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
<component type="typeof(MarkdownEditor)" render-mode="Server"/>
|
<component type="typeof(MarkdownEditor)" render-mode="Server"/>
|
||||||
|
|
||||||
<input type="hidden" data-blog-pid="@post.Id">
|
<input type="hidden" data-blog-pid="@post.Id">
|
||||||
|
|
||||||
|
<article id="article-preview" style="background: #333; max-width: 700px; margin: 20px auto; padding: 20px;">
|
||||||
|
<div id="editorjs"></div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
@*
|
||||||
|
|
||||||
<div class="d-flex flex-column" style="height: calc(100vh - 35px)">
|
<div class="d-flex flex-column" style="height: calc(100vh - 35px)">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button id="save-button" class="btn btn-primary"><i class="fa-solid fa-floppy-disk fa-fw"></i> Save <span class="text-muted">(Ctrl+S)</span></button>
|
<button id="save-button" class="btn btn-primary"><i class="fa-solid fa-floppy-disk fa-fw"></i> Save <span class="text-muted">(Ctrl+S)</span></button>
|
||||||
|
@ -46,4 +51,4 @@
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>*@
|
|
@ -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>();
|
||||||
|
|
|
@ -40,6 +40,18 @@ internal sealed class BlogPostService : IBlogPostService
|
||||||
return context.BlogPosts.Count();
|
return context.BlogPosts.Count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public IReadOnlyList<IBlogPostDraft> GetDrafts(IBlogPost post)
|
||||||
|
{
|
||||||
|
if (post is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(post));
|
||||||
|
}
|
||||||
|
|
||||||
|
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||||
|
return context.BlogPostDrafts.Where(d => d.Id == post.Id).OrderBy(d => d.Updated).ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1,
|
public IReadOnlyList<IBlogPost> GetAllBlogPosts(int limit = -1,
|
||||||
BlogPostVisibility visibility = BlogPostVisibility.Published)
|
BlogPostVisibility visibility = BlogPostVisibility.Published)
|
||||||
|
@ -170,6 +182,8 @@ internal sealed class BlogPostService : IBlogPostService
|
||||||
}
|
}
|
||||||
|
|
||||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||||
|
BlogPost cached = context.BlogPosts.First(p => p.Id == post.Id);
|
||||||
|
context.BlogPostDrafts.Add(BlogPostDraft.CreateFromBlogPost(cached));
|
||||||
context.Update(post);
|
context.Update(post);
|
||||||
context.SaveChanges();
|
context.SaveChanges();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -36,6 +36,14 @@ public interface IBlogPostService
|
||||||
/// <returns>A collection of blog posts.</returns>
|
/// <returns>A collection of blog posts.</returns>
|
||||||
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
|
IReadOnlyList<IBlogPost> GetBlogPosts(int page, int pageSize = 10);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the drafts of this post, sorted by their update timestamp.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="post">The post whose drafts to return.</param>
|
||||||
|
/// <returns>The drafts of the <paramref name="post" />.</returns>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
|
||||||
|
IReadOnlyList<IBlogPostDraft> GetDrafts(IBlogPost post);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the next blog post from the specified blog post.
|
/// Returns the next blog post from the specified blog post.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -8,7 +8,14 @@
|
||||||
"name": "oliverbooth.dev",
|
"name": "oliverbooth.dev",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/header": "^2.8.1",
|
||||||
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
|
"codex-notifier": "^1.1.2",
|
||||||
|
"codex-tooltip": "^1.0.5"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"gulp-clean-css": "^4.3.0",
|
"gulp-clean-css": "^4.3.0",
|
||||||
"gulp-noop": "^1.0.1",
|
"gulp-noop": "^1.0.1",
|
||||||
|
@ -23,6 +30,38 @@
|
||||||
"webpack-stream": "^7.0.0"
|
"webpack-stream": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codexteam/icons": {
|
||||||
|
"version": "0.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.4.tgz",
|
||||||
|
"integrity": "sha512-V8N/TY2TGyas4wLrPIFq7bcow68b3gu8DfDt1+rrHPtXxcexadKauRJL6eQgfG7Z0LCrN4boLRawR4S9gjIh/Q=="
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/editorjs": {
|
||||||
|
"version": "2.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/editorjs/-/editorjs-2.29.0.tgz",
|
||||||
|
"integrity": "sha512-w2BVboSHokMBd/cAOZn0UU328o3gSZ8XUvFPA2e9+ciIGKILiRSPB70kkNdmhHkuNS3q2px+vdaIFaywBl7wGA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/header": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/header/-/header-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-y0HVXRP7m2W617CWo3fsb5HhXmSLaRpb9GzFx0Vkp/HEm9Dz5YO1s8tC7R8JD3MskwoYh7V0hRFQt39io/r6hA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/header/node_modules/@codexteam/icons": {
|
||||||
|
"version": "0.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codexteam/icons/-/icons-0.0.5.tgz",
|
||||||
|
"integrity": "sha512-s6H2KXhLz2rgbMZSkRm8dsMJvyUNZsEjxobBEg9ztdrb1B2H3pEzY6iTwI4XUPJWJ3c3qRKwV4TrO3J5jUdoQA=="
|
||||||
|
},
|
||||||
|
"node_modules/@editorjs/paragraph": {
|
||||||
|
"version": "2.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@editorjs/paragraph/-/paragraph-2.11.3.tgz",
|
||||||
|
"integrity": "sha512-ON72lhvhgWzPrq4VXpHUeut9bsFeJgVATDeL850FVToOwYHKvdsNpfu0VgxEodhkXgzU/IGl4FzdqC2wd3AJUQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@codexteam/icons": "^0.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@gulp-sourcemaps/identity-map": {
|
"node_modules/@gulp-sourcemaps/identity-map": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz",
|
||||||
|
@ -1099,6 +1138,16 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codex-notifier": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codex-notifier/-/codex-notifier-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-DCp6xe/LGueJ1N5sXEwcBc3r3PyVkEEDNWCVigfvywAkeXcZMk9K41a31tkEFBW0Ptlwji6/JlAb49E3Yrxbtg=="
|
||||||
|
},
|
||||||
|
"node_modules/codex-tooltip": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/codex-tooltip/-/codex-tooltip-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-IuA8LeyLU5p1B+HyhOsqR6oxyFQ11k3i9e9aXw40CrHFTRO2Y1npNBVU3W1SvhKAbUU7R/YikUBdcYFP0RcJag=="
|
||||||
|
},
|
||||||
"node_modules/collection-map": {
|
"node_modules/collection-map": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/collection-map/-/collection-map-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/collection-map/-/collection-map-1.0.0.tgz",
|
||||||
|
|
|
@ -19,6 +19,7 @@
|
||||||
"author": "Oliver Booth",
|
"author": "Oliver Booth",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@editorjs/editorjs": "^2.29.0",
|
||||||
"gulp": "^4.0.2",
|
"gulp": "^4.0.2",
|
||||||
"gulp-clean-css": "^4.3.0",
|
"gulp-clean-css": "^4.3.0",
|
||||||
"gulp-noop": "^1.0.1",
|
"gulp-noop": "^1.0.1",
|
||||||
|
@ -31,5 +32,11 @@
|
||||||
"terser": "^5.19.2",
|
"terser": "^5.19.2",
|
||||||
"vinyl-named": "^1.1.0",
|
"vinyl-named": "^1.1.0",
|
||||||
"webpack-stream": "^7.0.0"
|
"webpack-stream": "^7.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@editorjs/header": "^2.8.1",
|
||||||
|
"@editorjs/paragraph": "^2.11.3",
|
||||||
|
"codex-notifier": "^1.1.2",
|
||||||
|
"codex-tooltip": "^1.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
class SimpleImage {
|
||||||
|
static get toolbox(): { icon: string, title: string } {
|
||||||
|
return {
|
||||||
|
title: "Image",
|
||||||
|
icon: "<svg width=\"17\" height=\"15\" viewBox=\"0 0 336 276\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z\"/></svg>"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): HTMLInputElement {
|
||||||
|
return document.createElement("input");
|
||||||
|
}
|
||||||
|
|
||||||
|
save(blockContent: HTMLInputElement): { url: string } {
|
||||||
|
return {
|
||||||
|
url: blockContent.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SimpleImage;
|
|
@ -3,8 +3,10 @@ import UI from "./MarkdownEditor/UI"
|
||||||
import API from "../app/API";
|
import API from "../app/API";
|
||||||
import "./TabSupport"
|
import "./TabSupport"
|
||||||
import Interop from "./Interop";
|
import Interop from "./Interop";
|
||||||
import MarkdownEditor from "./MarkdownEditor/MarkdownEditor";
|
|
||||||
import SaveButtonMode from "./MarkdownEditor/SaveButtonMode";
|
import SaveButtonMode from "./MarkdownEditor/SaveButtonMode";
|
||||||
|
import EditorJS from "@editorjs/editorjs";
|
||||||
|
import Header from "@editorjs/header";
|
||||||
|
import SimpleImage from "./BlockTools/SimpleImage";
|
||||||
|
|
||||||
(() => {
|
(() => {
|
||||||
getCurrentBlogPost().then(post => {
|
getCurrentBlogPost().then(post => {
|
||||||
|
@ -12,15 +14,30 @@ import SaveButtonMode from "./MarkdownEditor/SaveButtonMode";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
UI.init();
|
// UI.init();
|
||||||
UI.addSaveButtonListener(savePost);
|
// UI.addSaveButtonListener(savePost);
|
||||||
|
|
||||||
const editor = new MarkdownEditor(UI.markdownInput);
|
const editor = new EditorJS({
|
||||||
|
tools: {
|
||||||
|
header: {
|
||||||
|
class: Header,
|
||||||
|
config: {
|
||||||
|
placeholder: 'Heading',
|
||||||
|
levels: [2, 3, 4],
|
||||||
|
defaultLevel: 2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
image: SimpleImage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*const editor = new MarkdownEditor(UI.markdownInput);
|
||||||
editor.addSaveListener(savePost);
|
editor.addSaveListener(savePost);
|
||||||
editor.registerDefaultShortcuts();
|
editor.registerDefaultShortcuts();
|
||||||
editor.registerEvents();
|
editor.registerEvents();*/
|
||||||
|
|
||||||
async function savePost(): Promise<void> {
|
async function savePost(): Promise<void> {
|
||||||
|
return;
|
||||||
UI.setSaveButtonMode(SaveButtonMode.SAVING);
|
UI.setSaveButtonMode(SaveButtonMode.SAVING);
|
||||||
await Interop.invoke("Save", post.id, UI.markdownInput.value);
|
await Interop.invoke("Save", post.id, UI.markdownInput.value);
|
||||||
post = await API.getBlogPost(post.id);
|
post = await API.getBlogPost(post.id);
|
||||||
|
@ -31,7 +48,7 @@ import SaveButtonMode from "./MarkdownEditor/SaveButtonMode";
|
||||||
setTimeout(() => UI.setSaveButtonMode(SaveButtonMode.NORMAL), 2000);
|
setTimeout(() => UI.setSaveButtonMode(SaveButtonMode.NORMAL), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
UI.redraw();
|
// UI.redraw();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function getCurrentBlogPost(): Promise<BlogPost> {
|
async function getCurrentBlogPost(): Promise<BlogPost> {
|
||||||
|
|
Loading…
Reference in New Issue