Mass refactoring, added static file management
|
|
@ -2,7 +2,7 @@
|
||||||
*.db
|
*.db
|
||||||
|
|
||||||
# BLOGS
|
# BLOGS
|
||||||
blogs/
|
md/
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/csharp,aspnetcore,rider,visualstudiocode
|
# Created by https://www.toptal.com/developers/gitignore/api/csharp,aspnetcore,rider,visualstudiocode
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=csharp,aspnetcore,rider,visualstudiocode
|
# Edit at https://www.toptal.com/developers/gitignore?templates=csharp,aspnetcore,rider,visualstudiocode
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
|
|
||||||
|
public interface IBlogFileRepository
|
||||||
|
{
|
||||||
|
Task<string?> Read(string path);
|
||||||
|
Task<Result<bool, BusinessError>> Write(string path, string text);
|
||||||
|
public Task<bool> Delete(string path);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Blogs;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
|
|
||||||
|
public interface IBlogMetaRepository
|
||||||
|
{
|
||||||
|
Task<BlogMeta?> Get(string? slug);
|
||||||
|
Task<BlogMeta?> GetForEdit(int id);
|
||||||
|
Task<Result<BlogMeta, BusinessError>> Add(BlogMeta meta);
|
||||||
|
Task<Result<BlogMeta, BusinessError>> Update(BlogMeta meta);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Files;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
|
|
||||||
|
public interface IFileMetaRepository
|
||||||
|
{
|
||||||
|
Task<PagedResponse<FileMeta>> List(PagedRequest request);
|
||||||
|
Task<FileMeta?> Get(int id);
|
||||||
|
Task<Result<FileMeta, BusinessError>> Add(FileMeta meta);
|
||||||
|
Task<bool> Delete(FileMeta meta);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
|
|
||||||
|
public interface IFileRepository
|
||||||
|
{
|
||||||
|
Task<Result<bool, BusinessError>> Write(string path, Stream fileStream);
|
||||||
|
public Task<bool> Delete(string path);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
using FluentMigrator;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Db.Migrations;
|
||||||
|
|
||||||
|
[Migration(202409252200, "Add file meta table")]
|
||||||
|
public class AddFileMeta : Migration
|
||||||
|
{
|
||||||
|
public override void Up()
|
||||||
|
{
|
||||||
|
Create.Table("Files")
|
||||||
|
.WithColumn("Id").AsInt32().PrimaryKey().Unique().Identity()
|
||||||
|
.WithColumn("SourceLocation").AsString().Unique()
|
||||||
|
.WithColumn("MimeType").AsString()
|
||||||
|
.WithColumn("CreatedAt").AsDateTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Down()
|
||||||
|
{
|
||||||
|
Delete.Table("Files");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
using System.Data;
|
using System.Data;
|
||||||
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
using FastBlog.Core.Db;
|
using FastBlog.Core.Db;
|
||||||
using FastBlog.Core.Errors.Blogs;
|
|
||||||
using FastBlog.Core.Models.Blogs;
|
using FastBlog.Core.Models.Blogs;
|
||||||
using FastBlog.Core.Options;
|
using FastBlog.Core.Options;
|
||||||
using FastBlog.Core.Repositories;
|
using FastBlog.Core.Repositories;
|
||||||
|
|
@ -19,13 +19,16 @@ public static class DependencyInjection
|
||||||
{
|
{
|
||||||
// applicatrion
|
// applicatrion
|
||||||
services.AddSingleton<BlogService>();
|
services.AddSingleton<BlogService>();
|
||||||
|
services.AddSingleton<FileService>();
|
||||||
|
|
||||||
// options
|
// options
|
||||||
services.Configure<FileStoreOptions>(configuration.GetSection("FileStore"));
|
services.Configure<FileStoreOptions>(configuration.GetSection("FileStore"));
|
||||||
|
|
||||||
// infrastructure
|
// infrastructure
|
||||||
services.AddSingleton<IBlogMetaRepository, BlogMetaRepository>();
|
services.AddSingleton<IBlogMetaRepository, BlogMetaRepository>();
|
||||||
services.AddSingleton<IBlogSourceRepository, BlogSourceRepository>();
|
services.AddSingleton<IBlogFileRepository, BlogBlogFileRepository>();
|
||||||
|
services.AddSingleton<IFileMetaRepository, FileMetaRepository>();
|
||||||
|
services.AddSingleton<IFileRepository, FileRepository>();
|
||||||
|
|
||||||
// storage
|
// storage
|
||||||
var connectionString = configuration.GetConnectionString("Sqlite");
|
var connectionString = configuration.GetConnectionString("Sqlite");
|
||||||
|
|
@ -52,7 +55,7 @@ public static class DependencyInjection
|
||||||
|
|
||||||
var blogService = scope.ServiceProvider.GetRequiredService<BlogService>();
|
var blogService = scope.ServiceProvider.GetRequiredService<BlogService>();
|
||||||
var mainPage = await blogService.Get(null);
|
var mainPage = await blogService.Get(null);
|
||||||
if (mainPage.IsOk)
|
if (mainPage is not null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await blogService.UpdateBlog(new Blog
|
await blogService.UpdateBlog(new Blog
|
||||||
|
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
namespace FastBlog.Core.Errors;
|
|
||||||
|
|
||||||
public enum FileError
|
|
||||||
{
|
|
||||||
NotFound,
|
|
||||||
AlreadyExists,
|
|
||||||
InvalidDirectory
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
namespace FastBlog.Core.Models.Blogs;
|
||||||
|
|
||||||
|
public class Blog
|
||||||
|
{
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public required BlogMeta Metadata { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
namespace FastBlog.Core.Models.Blogs;
|
||||||
|
|
||||||
|
public sealed class BlogMeta
|
||||||
|
{
|
||||||
|
public int? Id { get; init; }
|
||||||
|
public required string Title { get; init; }
|
||||||
|
public required string SourceLocation { get; init; }
|
||||||
|
public string? Slug { get; init; }
|
||||||
|
public bool ShowDetails { get; init; } = true;
|
||||||
|
public string? ImageUrl { get; init; }
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public DateTime ModifiedAt { get; init; }
|
||||||
|
public string? Signature { get; init; }
|
||||||
|
public bool FullWidth { get; init; } = false;
|
||||||
|
public bool Deleted { get; init; } = false;
|
||||||
|
public bool Visible { get; init; } = false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
namespace FastBlog.Core.Models;
|
||||||
|
|
||||||
|
public sealed class BusinessError : Exception
|
||||||
|
{
|
||||||
|
public string ShortCode { get; set; }
|
||||||
|
public BusinessError(string message, string shortCode) : base(message)
|
||||||
|
{
|
||||||
|
ShortCode = shortCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessError(string shortCode) : base(shortCode)
|
||||||
|
{
|
||||||
|
ShortCode = shortCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace FastBlog.Core.Models.Files;
|
||||||
|
|
||||||
|
public sealed class FileMeta
|
||||||
|
{
|
||||||
|
public int? Id { get; init; }
|
||||||
|
public required string SourceLocation { get; init; }
|
||||||
|
public DateTime CreatedAt { get; init; }
|
||||||
|
public string MimeType { get; init; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
namespace FastBlog.Core.Models;
|
||||||
|
|
||||||
|
public class PagedRequest
|
||||||
|
{
|
||||||
|
public int Amount { get; }
|
||||||
|
public int Offset { get; }
|
||||||
|
|
||||||
|
public PagedRequest(int amount, int offset)
|
||||||
|
{
|
||||||
|
if(amount < 1)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(amount), "Amount must be greater than 0");
|
||||||
|
}
|
||||||
|
if(offset < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(offset), "Offset must be greater than or equal to 0");
|
||||||
|
}
|
||||||
|
Amount = amount;
|
||||||
|
Offset = offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
namespace FastBlog.Core.Models;
|
||||||
|
|
||||||
|
public class PagedResponse<T>
|
||||||
|
{
|
||||||
|
public long Total { get; }
|
||||||
|
public int Amount { get; }
|
||||||
|
public int Offset { get; }
|
||||||
|
public T[] Data { get; }
|
||||||
|
|
||||||
|
public PagedResponse(long total, int amount, int offset, T[] data)
|
||||||
|
{
|
||||||
|
Total = total;
|
||||||
|
Amount = amount;
|
||||||
|
Offset = offset;
|
||||||
|
Data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PagedResponse<T> Create(long total, int amount, int offset, T[] data)
|
||||||
|
{
|
||||||
|
return new PagedResponse<T>(total, amount, offset, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PagedResponse<T> Empty()
|
||||||
|
{
|
||||||
|
return new PagedResponse<T>(0, 0, 0, []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,5 +2,6 @@
|
||||||
|
|
||||||
public class FileStoreOptions
|
public class FileStoreOptions
|
||||||
{
|
{
|
||||||
public required string SourcePath { get; init; }
|
public required string BlogSourcePath { get; init; }
|
||||||
|
public required string StaticFilesPath { get; init; }
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Repositories;
|
||||||
|
|
||||||
|
public sealed class BlogBlogFileRepository(IOptionsMonitor<FileStoreOptions> fileOptions) : IBlogFileRepository
|
||||||
|
{
|
||||||
|
public async Task<string?> Read(string path)
|
||||||
|
{
|
||||||
|
var targetPath = Path.Combine(fileOptions.CurrentValue.BlogSourcePath, path);
|
||||||
|
if (!File.Exists(targetPath)) return null;
|
||||||
|
return await File.ReadAllTextAsync(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<bool, BusinessError>> Write(string path, string text)
|
||||||
|
{
|
||||||
|
var targetPath = Path.Combine(fileOptions.CurrentValue.BlogSourcePath, path);
|
||||||
|
|
||||||
|
var directoryPath = Path.GetDirectoryName(targetPath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
Directory.CreateDirectory(directoryPath!);
|
||||||
|
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
return new BusinessError("already_exists", "File already exists");
|
||||||
|
|
||||||
|
await File.WriteAllTextAsync(targetPath, text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<bool> Delete(string path)
|
||||||
|
{
|
||||||
|
var targetPath = Path.Combine(fileOptions.CurrentValue.BlogSourcePath, path);
|
||||||
|
if (!File.Exists(targetPath))
|
||||||
|
return Task.FromResult(false);
|
||||||
|
File.Delete(targetPath);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
using FastBlog.Core.Db;
|
using FastBlog.Core.Db;
|
||||||
using FastBlog.Core.Errors.Blogs;
|
|
||||||
using FastBlog.Core.Models;
|
using FastBlog.Core.Models;
|
||||||
using FastBlog.Core.Models.Blogs;
|
using FastBlog.Core.Models.Blogs;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
@ -10,7 +9,7 @@ namespace FastBlog.Core.Repositories;
|
||||||
|
|
||||||
public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory) : IBlogMetaRepository
|
public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory) : IBlogMetaRepository
|
||||||
{
|
{
|
||||||
public async Task<Result<BlogMeta, BlogError>> Get(string? slug)
|
public async Task<BlogMeta?> Get(string? slug)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
|
@ -28,19 +27,11 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
|
||||||
""";
|
""";
|
||||||
|
|
||||||
if (slug is null)
|
if (slug is null)
|
||||||
{
|
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
|
||||||
var resultNull = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
|
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { slug });
|
||||||
if (resultNull is null) return BlogError.NotFound;
|
|
||||||
return resultNull;
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { slug });
|
|
||||||
if (result is null) return BlogError.NotFound;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<BlogMeta, BlogError>> GetForEdit(int id)
|
public async Task<BlogMeta?> GetForEdit(int id)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
|
@ -50,13 +41,10 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
|
||||||
and deleted = 0
|
and deleted = 0
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { id });
|
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { id });
|
||||||
if (result is null) return BlogError.NotFound;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<BlogMeta, BlogError>> Add(BlogMeta meta)
|
public async Task<Result<BlogMeta, BusinessError>> Add(BlogMeta meta)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
|
@ -67,20 +55,18 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
|
||||||
""";
|
""";
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, meta);
|
return await connection.QueryFirstAsync<BlogMeta>(sql, meta);
|
||||||
if (result is null) return BlogError.NotFound;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
catch (SqliteException ex)
|
catch (SqliteException ex)
|
||||||
{
|
{
|
||||||
if (ex.SqliteErrorCode == 19) // Unique constraint violation
|
if (ex.SqliteErrorCode == 19) // Unique constraint violation
|
||||||
return BlogError.AlreadyExists; // You need to define this error in the BlogError enum
|
return new BusinessError("already_exists","Slug already exists");
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<BlogMeta, BlogError>> Update(BlogMeta meta)
|
public async Task<Result<BlogMeta, BusinessError>> Update(BlogMeta meta)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
|
@ -93,15 +79,12 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, meta);
|
return await connection.QueryFirstAsync<BlogMeta>(sql, meta);
|
||||||
if (result is null) return BlogError.NotFound;
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
catch (SqliteException ex)
|
catch (SqliteException ex)
|
||||||
{
|
{
|
||||||
if (ex.SqliteErrorCode == 19) // Unique constraint violation
|
if (ex.SqliteErrorCode == 19) // Unique constraint violation
|
||||||
return BlogError.AlreadyExists;
|
return new BusinessError("already_exists","Slug already exists");
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
|
||||||
using FastBlog.Core.Errors;
|
|
||||||
using FastBlog.Core.Models;
|
|
||||||
using FastBlog.Core.Options;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace FastBlog.Core.Repositories;
|
|
||||||
|
|
||||||
public sealed class BlogSourceRepository(IOptionsMonitor<FileStoreOptions> fileOptions) : IBlogSourceRepository
|
|
||||||
{
|
|
||||||
public async Task<Result<string, FileError>> Read(string path)
|
|
||||||
{
|
|
||||||
var targetPath = Path.Combine(fileOptions.CurrentValue.SourcePath, path);
|
|
||||||
if (!File.Exists(targetPath))
|
|
||||||
return FileError.NotFound;
|
|
||||||
return await File.ReadAllTextAsync(targetPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<bool, FileError>> Write(string path, string text)
|
|
||||||
{
|
|
||||||
var targetPath = Path.Combine(fileOptions.CurrentValue.SourcePath, path);
|
|
||||||
|
|
||||||
var directoryPath = Path.GetDirectoryName(targetPath);
|
|
||||||
|
|
||||||
if (!Directory.Exists(directoryPath))
|
|
||||||
Directory.CreateDirectory(directoryPath!);
|
|
||||||
|
|
||||||
if (File.Exists(targetPath))
|
|
||||||
return FileError.AlreadyExists;
|
|
||||||
|
|
||||||
await File.WriteAllTextAsync(targetPath, text);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public Result<bool, FileError> DeleteSync(string path)
|
|
||||||
{
|
|
||||||
//TODO: If folder is empty, delete
|
|
||||||
var targetPath = Path.Combine(fileOptions.CurrentValue.SourcePath, path);
|
|
||||||
if (!File.Exists(targetPath))
|
|
||||||
return FileError.NotFound;
|
|
||||||
File.Delete(targetPath);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
using Dapper;
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
|
using FastBlog.Core.Db;
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Files;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Repositories;
|
||||||
|
|
||||||
|
public sealed class FileMetaRepository(SqliteConnectionFactory connectionFactory) : IFileMetaRepository
|
||||||
|
{
|
||||||
|
public async Task<PagedResponse<FileMeta>> List(PagedRequest request)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
select COUNT(*) from Files;
|
||||||
|
select * from Files
|
||||||
|
order by CreatedAt desc
|
||||||
|
limit @Amount offset @Offset;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
await using var multi = await connection.QueryMultipleAsync(sql, request);
|
||||||
|
|
||||||
|
var total = await multi.ReadFirstAsync<long>();
|
||||||
|
var result = (await multi.ReadAsync<FileMeta>()).ToArray();
|
||||||
|
|
||||||
|
return PagedResponse<FileMeta>.Create(total, request.Amount, request.Offset, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<FileMeta?> Get(int id)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
select * from Files
|
||||||
|
where Id = @Id;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var connection = connectionFactory.Create();
|
||||||
|
return await connection.QueryFirstOrDefaultAsync<FileMeta>(sql, new { Id = id });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<FileMeta, BusinessError>> Add(FileMeta meta)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
insert into Files (SourceLocation, CreatedAt, MimeType)
|
||||||
|
values (@SourceLocation, @CreatedAt, @MimeType)
|
||||||
|
returning *;
|
||||||
|
""";
|
||||||
|
|
||||||
|
using var connection = connectionFactory.Create();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await connection.QueryFirstAsync<FileMeta>(sql, meta);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (SqliteException ex)
|
||||||
|
{
|
||||||
|
if (ex.SqliteErrorCode == 19) // Unique constraint violation
|
||||||
|
return new BusinessError("already_exists", "File already exists");
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> Delete(FileMeta meta)
|
||||||
|
{
|
||||||
|
using var connection = connectionFactory.Create();
|
||||||
|
|
||||||
|
const string sql = """
|
||||||
|
delete from Files
|
||||||
|
where Id = @Id;
|
||||||
|
""";
|
||||||
|
|
||||||
|
var result = await connection.ExecuteAsync(sql, meta);
|
||||||
|
return result > 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Options;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Repositories;
|
||||||
|
|
||||||
|
public sealed class FileRepository(IOptionsMonitor<FileStoreOptions> fileOptions) : IFileRepository
|
||||||
|
{
|
||||||
|
public async Task<Result<bool, BusinessError>> Write(string path, Stream fileStream)
|
||||||
|
{
|
||||||
|
var targetPath = Path.Combine(fileOptions.CurrentValue.StaticFilesPath, path);
|
||||||
|
var directoryPath = Path.GetDirectoryName(targetPath);
|
||||||
|
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
Directory.CreateDirectory(directoryPath!);
|
||||||
|
|
||||||
|
if (File.Exists(targetPath))
|
||||||
|
return new BusinessError("already_exists", "File already exists");
|
||||||
|
|
||||||
|
await using var file = File.Create(targetPath);
|
||||||
|
await fileStream.CopyToAsync(file);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
}
|
||||||
|
public Task<bool> Delete(string path)
|
||||||
|
{
|
||||||
|
var targetPath = Path.Combine(fileOptions.CurrentValue.StaticFilesPath, path);
|
||||||
|
if (!File.Exists(targetPath))
|
||||||
|
return Task.FromResult(false);
|
||||||
|
File.Delete(targetPath);
|
||||||
|
return Task.FromResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,74 +1,67 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
using FastBlog.Core.Errors;
|
using FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
using FastBlog.Core.Errors.Blogs;
|
|
||||||
using FastBlog.Core.Models;
|
using FastBlog.Core.Models;
|
||||||
using FastBlog.Core.Models.Blogs;
|
using FastBlog.Core.Models.Blogs;
|
||||||
|
|
||||||
namespace FastBlog.Core.Services;
|
namespace FastBlog.Core.Services;
|
||||||
|
|
||||||
public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogSourceRepository sourceRepository)
|
public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogFileRepository blogFileRepository)
|
||||||
{
|
{
|
||||||
public async Task<Result<Blog, BlogError>> Get(string? slug)
|
public async Task<Blog?> Get(string? slug)
|
||||||
{
|
{
|
||||||
var metaResult = await metaRepository.Get(slug);
|
var metaResult = await metaRepository.Get(slug);
|
||||||
if (metaResult.IsError)
|
if (metaResult is null)
|
||||||
return metaResult.AsError;
|
return null;
|
||||||
var sourceResult = await sourceRepository.Read(metaResult.AsOk.SourceLocation);
|
var sourceResult = await blogFileRepository.Read(metaResult.SourceLocation);
|
||||||
|
|
||||||
if (sourceResult.IsError)
|
if (sourceResult is null)
|
||||||
{
|
{
|
||||||
throw new FileNotFoundException("Blog with metadata cannot be found");
|
throw new FileNotFoundException("File with metadata cannot be found");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Blog
|
return new Blog
|
||||||
{
|
{
|
||||||
Metadata = metaResult.AsOk,
|
Metadata = metaResult,
|
||||||
Text = sourceResult.AsOk
|
Text = sourceResult
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<Blog, BlogError>> GetForEdit(int id)
|
public async Task<Blog?> GetForEdit(int id)
|
||||||
{
|
{
|
||||||
var metaResult = await metaRepository.GetForEdit(id);
|
var metaResult = await metaRepository.GetForEdit(id);
|
||||||
if (metaResult.IsError)
|
|
||||||
return metaResult.AsError;
|
|
||||||
var sourceResult = await sourceRepository.Read(metaResult.AsOk.SourceLocation);
|
|
||||||
|
|
||||||
if (sourceResult.IsError)
|
if (metaResult is null)
|
||||||
{
|
return null;
|
||||||
throw new FileNotFoundException("Blog with metadata cannot be found");
|
|
||||||
}
|
var sourceResult = await blogFileRepository.Read(metaResult.SourceLocation);
|
||||||
|
|
||||||
|
if (sourceResult is null)
|
||||||
|
throw new FileNotFoundException("File with metadata cannot be found");
|
||||||
|
|
||||||
return new Blog
|
return new Blog
|
||||||
{
|
{
|
||||||
Metadata = metaResult.AsOk,
|
Metadata = metaResult,
|
||||||
Text = sourceResult.AsOk
|
Text = sourceResult
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Task<Result<Blog, BlogError>> UpdateBlog(Blog blog)
|
public Task<Result<Blog, BusinessError>> UpdateBlog(Blog blog)
|
||||||
{
|
{
|
||||||
return blog.Metadata.Id is null ? CreateBlog(blog) : UpdateBlogInternal(blog);
|
return blog.Metadata.Id is null ? CreateBlog(blog) : UpdateBlogInternal(blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Result<Blog, BlogError>> CreateBlog(Blog newBlog)
|
private async Task<Result<Blog, BusinessError>> CreateBlog(Blog newBlog)
|
||||||
{
|
{
|
||||||
var metaResult = await metaRepository.Add(newBlog.Metadata);
|
var metaResult = await metaRepository.Add(newBlog.Metadata);
|
||||||
if (metaResult.IsError)
|
if (metaResult.IsError)
|
||||||
return metaResult.AsError;
|
return metaResult.AsError;
|
||||||
|
|
||||||
var sourceResult = await sourceRepository.Write(metaResult.AsOk.SourceLocation, newBlog.Text);
|
var sourceResult = await blogFileRepository.Write(metaResult.AsOk.SourceLocation, newBlog.Text);
|
||||||
|
|
||||||
if (sourceResult.IsError)
|
if (sourceResult.IsError)
|
||||||
return sourceResult.AsError switch
|
return sourceResult.AsError;
|
||||||
{
|
|
||||||
FileError.AlreadyExists => BlogError.AlreadyExists,
|
|
||||||
FileError.InvalidDirectory => throw new Exception(
|
|
||||||
"Invalid configuration: Directory for blog storage not found"),
|
|
||||||
_ => throw new UnreachableException()
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Blog
|
return new Blog
|
||||||
{
|
{
|
||||||
|
|
@ -78,30 +71,24 @@ public sealed class BlogService(IBlogMetaRepository metaRepository, IBlogSourceR
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private async Task<Result<Blog, BlogError>> UpdateBlogInternal(Blog blog)
|
private async Task<Result<Blog, BusinessError>> UpdateBlogInternal(Blog blog)
|
||||||
{
|
{
|
||||||
var oldMeta = await metaRepository.GetForEdit(blog.Metadata.Id!.Value);
|
var oldMeta = await metaRepository.GetForEdit(blog.Metadata.Id!.Value);
|
||||||
if (oldMeta.IsError)
|
if (oldMeta is null)
|
||||||
return BlogError.NotFound;
|
return new BusinessError("not_found", "Blog not found");
|
||||||
|
|
||||||
var toDelete = oldMeta.AsOk.SourceLocation;
|
var toDelete = oldMeta.SourceLocation;
|
||||||
|
|
||||||
var metaResult = await metaRepository.Update(blog.Metadata);
|
var metaResult = await metaRepository.Update(blog.Metadata);
|
||||||
if (metaResult.IsError)
|
if (metaResult.IsError)
|
||||||
return metaResult.AsError;
|
return metaResult.AsError;
|
||||||
|
|
||||||
_ = sourceRepository.DeleteSync(toDelete);
|
_ = await blogFileRepository.Delete(toDelete);
|
||||||
|
|
||||||
var sourceResult = await sourceRepository.Write(metaResult.AsOk.SourceLocation, blog.Text);
|
var sourceResult = await blogFileRepository.Write(metaResult.AsOk.SourceLocation, blog.Text);
|
||||||
|
|
||||||
if (sourceResult.IsError)
|
if (sourceResult.IsError)
|
||||||
return sourceResult.AsError switch
|
return sourceResult.AsError;
|
||||||
{
|
|
||||||
FileError.AlreadyExists => BlogError.AlreadyExists,
|
|
||||||
FileError.InvalidDirectory => throw new Exception(
|
|
||||||
"Invalid configuration: Directory for blog storage not found"),
|
|
||||||
_ => throw new UnreachableException()
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Blog
|
return new Blog
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
using FastBlog.Core.Abstractions.Repositories.Files;
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Files;
|
||||||
|
|
||||||
|
namespace FastBlog.Core.Services;
|
||||||
|
|
||||||
|
public sealed class FileService(IFileMetaRepository metaRepository, IFileRepository repository)
|
||||||
|
{
|
||||||
|
public Task<PagedResponse<FileMeta>> GetFiles(PagedRequest request) => metaRepository.List(request);
|
||||||
|
|
||||||
|
public async Task<bool> DeleteFile(int metaId)
|
||||||
|
{
|
||||||
|
var meta = await metaRepository.Get(metaId);
|
||||||
|
|
||||||
|
if (meta is null)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return await metaRepository.Delete(meta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<FileMeta, BusinessError>> AddFile(FileMeta meta, Func<Stream> streamProvider)
|
||||||
|
{
|
||||||
|
var metaResult = await metaRepository.Add(meta);
|
||||||
|
|
||||||
|
if (metaResult.IsError)
|
||||||
|
return metaResult.AsError;
|
||||||
|
|
||||||
|
await using var stream = streamProvider();
|
||||||
|
var result = await repository.Write(meta.SourceLocation, stream);
|
||||||
|
|
||||||
|
return result.IsError ? metaResult.AsError : metaResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using FastBlog.Core.Errors.Blogs;
|
using FastBlog.Core.Abstractions.Repositories.Blogs;
|
||||||
using FastBlog.Core.Models.Blogs;
|
using FastBlog.Core.Models.Blogs;
|
||||||
using FastBlog.Core.Services;
|
using FastBlog.Core.Services;
|
||||||
using FastBlog.Web.Models;
|
using FastBlog.Web.Models;
|
||||||
|
|
@ -8,8 +8,8 @@ using Microsoft.AspNetCore.Mvc;
|
||||||
namespace FastBlog.Web.Controllers;
|
namespace FastBlog.Web.Controllers;
|
||||||
|
|
||||||
[Route("")]
|
[Route("")]
|
||||||
[Route("blog")]
|
[Route("blogs")]
|
||||||
public class BlogController(BlogService service) : Controller
|
public class BlogsController(BlogService service) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Route("{*slug}")]
|
[Route("{*slug}")]
|
||||||
|
|
@ -20,16 +20,10 @@ public class BlogController(BlogService service) : Controller
|
||||||
|
|
||||||
var blog = await service.Get(slug);
|
var blog = await service.Get(slug);
|
||||||
|
|
||||||
if (blog.IsError)
|
if (blog is null)
|
||||||
{
|
return NotFound();
|
||||||
return blog.AsError switch
|
|
||||||
{
|
|
||||||
BlogError.NotFound => NotFound(),
|
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return View(blog.AsOk);
|
return View(blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -38,16 +32,10 @@ public class BlogController(BlogService service) : Controller
|
||||||
{
|
{
|
||||||
var blog = await service.Get(null);
|
var blog = await service.Get(null);
|
||||||
|
|
||||||
if (blog.IsError)
|
if (blog is null)
|
||||||
{
|
return NotFound();
|
||||||
return blog.AsError switch
|
|
||||||
{
|
|
||||||
BlogError.NotFound => NotFound(),
|
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return View(blog.AsOk);
|
return View(blog);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
|
@ -73,17 +61,10 @@ public class BlogController(BlogService service) : Controller
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await service.GetForEdit(id.Value);
|
var blog = await service.GetForEdit(id.Value);
|
||||||
if (result.IsError)
|
|
||||||
{
|
|
||||||
return result.AsError switch
|
|
||||||
{
|
|
||||||
BlogError.NotFound => NotFound(),
|
|
||||||
_ => throw new UnreachableException()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
var blog = result.AsOk;
|
if (blog is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
return View(new EditBlog
|
return View(new EditBlog
|
||||||
{
|
{
|
||||||
|
|
@ -127,14 +108,15 @@ public class BlogController(BlogService service) : Controller
|
||||||
|
|
||||||
if (result.IsError)
|
if (result.IsError)
|
||||||
{
|
{
|
||||||
return result.AsError switch
|
return result.AsError.ShortCode switch
|
||||||
{
|
{
|
||||||
BlogError.AlreadyExists => Conflict(),
|
"already_exists" => Conflict(),
|
||||||
_ => throw new ArgumentOutOfRangeException()
|
"not_found" => NotFound(),
|
||||||
|
_ => throw result.AsError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return Redirect($"/blog/{editBlog.Slug}");
|
return Redirect($"/blogs/{editBlog.Slug}");
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Files;
|
||||||
|
using FastBlog.Core.Services;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace FastBlog.Web.Controllers;
|
||||||
|
|
||||||
|
[Route("files")]
|
||||||
|
public class FilesController(FileService fileService) : Controller
|
||||||
|
{
|
||||||
|
private const long MaxFileSize = 8L * 1024L * 1024L * 1024L;
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Index()
|
||||||
|
{
|
||||||
|
var files = await fileService.GetFiles(new PagedRequest(25, 0));
|
||||||
|
return View("Index", files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{offset:int}")]
|
||||||
|
public async Task<IActionResult> Page(int offset)
|
||||||
|
{
|
||||||
|
var files = await fileService.GetFiles(new PagedRequest(25, offset));
|
||||||
|
return View("Index", files);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("{metaId:int}")]
|
||||||
|
public async Task<IActionResult> Delete(int metaId)
|
||||||
|
{
|
||||||
|
var result = await fileService.DeleteFile(metaId);
|
||||||
|
return result ? await Index() : NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath)
|
||||||
|
{
|
||||||
|
// TODO: Validate path to prevent directory traversal
|
||||||
|
// rn I am the only user so I dont care for now
|
||||||
|
|
||||||
|
if (HttpContext.Request.Form.Files.Count < 1)
|
||||||
|
return BadRequest("File is required.");
|
||||||
|
|
||||||
|
var file = HttpContext.Request.Form.Files[0];
|
||||||
|
|
||||||
|
if (file.Length > MaxFileSize)
|
||||||
|
return BadRequest("File is too large.");
|
||||||
|
|
||||||
|
var meta = new FileMeta
|
||||||
|
{
|
||||||
|
SourceLocation = Path.Combine(sourcePath ?? "", file.FileName),
|
||||||
|
CreatedAt = DateTime.Now,
|
||||||
|
MimeType = file.ContentType
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = await fileService.AddFile(meta, file.OpenReadStream);
|
||||||
|
|
||||||
|
if (result.IsError)
|
||||||
|
{
|
||||||
|
return result.AsError.ShortCode switch
|
||||||
|
{
|
||||||
|
"already_exists" => Conflict(),
|
||||||
|
_ => throw result.AsError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await Index();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,38 +18,6 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-grid.rtl.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-reboot.rtl.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap-utilities.rtl.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css" />
|
|
||||||
<_ContentIncludedByDefault Remove="wwwroot\lib\bootstrap\dist\css\bootstrap.rtl.min.css.map" />
|
|
||||||
<_ContentIncludedByDefault Remove="Views\Home\Index.cshtml" />
|
<_ContentIncludedByDefault Remove="Views\Home\Index.cshtml" />
|
||||||
<_ContentIncludedByDefault Remove="Views\Home\Privacy.cshtml" />
|
<_ContentIncludedByDefault Remove="Views\Home\Privacy.cshtml" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
namespace FastBlog.Web.Helpers;
|
||||||
|
|
||||||
|
public static class PropertyHelper
|
||||||
|
{
|
||||||
|
public static string If(bool condition, string property)
|
||||||
|
{
|
||||||
|
return condition ? property : string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Blog}/{action=Index}/{slug?}");
|
pattern: "{controller=Blogs}/{action=Index}/{slug?}");
|
||||||
|
|
||||||
await app.Services.MigrateUp(app.Configuration);
|
await app.Services.MigrateUp(app.Configuration);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ else
|
||||||
<h2>Edit "@Model.Title"</h2>
|
<h2>Edit "@Model.Title"</h2>
|
||||||
}
|
}
|
||||||
|
|
||||||
<form action="~/blog/edit/" method="post">
|
<form action="~/blogs/edit/" method="post">
|
||||||
<input type="hidden" id="id" name="id" value="@Model.Id"/>
|
<input type="hidden" id="id" name="id" value="@Model.Id"/>
|
||||||
<div>
|
<div>
|
||||||
<label for="title">Title:</label>
|
<label for="title">Title:</label>
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
@using FastBlog.Web.Helpers
|
||||||
|
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Files.FileMeta>
|
||||||
|
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewBag.Title = "Files";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container" id="file-list">
|
||||||
|
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Upload a file</h3>
|
||||||
|
</header>
|
||||||
|
<form hx-encoding="multipart/form-data"
|
||||||
|
hx-post="/files"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list">
|
||||||
|
<input type="file" name="file" required/>
|
||||||
|
<label for="upload-path">Path to upload (optional)</label>
|
||||||
|
<input type="text" id="upload-path" name="sourcePath" placeholder="/subfolders/"/>
|
||||||
|
<button type="submit" class="btn-fw">
|
||||||
|
Upload
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Manage files</h3>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
@if (Model.Data.Length is 0)
|
||||||
|
{
|
||||||
|
<h4>No files have been found</h4>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var file in Model.Data)
|
||||||
|
{
|
||||||
|
<div style="display: flex">
|
||||||
|
<h3 class="no-margin" style="margin-right: 10px">
|
||||||
|
<a target="_blank" href="/static/@file.SourceLocation">@file.SourceLocation</a>
|
||||||
|
</h3>
|
||||||
|
<h3 class="no-margin">
|
||||||
|
<a class="pico-color-red" hx-delete="/files/@file.Id"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list">
|
||||||
|
[ Delete ]
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-get="/files?page=@(Model.Offset - 25)"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list"
|
||||||
|
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-get="/files?page=@(Model.Offset + 25)"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list"
|
||||||
|
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
@ -7,9 +7,17 @@
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
"FileStore": {
|
"FileStore": {
|
||||||
"SourcePath": "blogs"
|
"BlogSourcePath": "md",
|
||||||
|
"StaticFilesPath": "wwwroot/static"
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"Sqlite": "Data Source=app.db"
|
"Sqlite": "Data Source=app.db"
|
||||||
|
},
|
||||||
|
"Display": {
|
||||||
|
"Title": "FastBlog",
|
||||||
|
"Links": {
|
||||||
|
"GitHub": "https://github.com",
|
||||||
|
"Mail": "mailto:mail@example.com"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,50 @@
|
||||||
src: url('fonts/FiraCode-VF.woff2') format('woff2');
|
src: url('fonts/FiraCode-VF.woff2') format('woff2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Orange color for dark color scheme (Forced) */
|
||||||
|
/* Enabled if forced with data-theme="dark" */
|
||||||
|
[data-theme=dark] {
|
||||||
|
--pico-text-selection-color: rgba(195, 234, 255, 0.37);
|
||||||
|
--pico-primary: #C3EAFF;
|
||||||
|
--pico-primary-background: rgba(126, 209, 255, 0.32);
|
||||||
|
--pico-primary-underline: rgba(195, 234, 255, 0.25);
|
||||||
|
--pico-primary-hover: #C3EAFF;
|
||||||
|
--pico-primary-hover-background: rgba(126, 209, 255, 0.60);
|
||||||
|
--pico-primary-focus: rgba(195, 234, 255, 0.90);
|
||||||
|
--pico-primary-inverse: #fff;
|
||||||
|
|
||||||
|
--pico-secondary: #C3EAFF;
|
||||||
|
--pico-secondary-background: rgba(195, 234, 255, 0.16);
|
||||||
|
--pico-secondary-underline: rgba(195, 234, 255, 0.25);
|
||||||
|
--pico-secondary-hover: #C3EAFF;
|
||||||
|
--pico-secondary-hover-background: rgba(195, 234, 255, 0.40);
|
||||||
|
--pico-secondary-focus: rgba(195, 234, 255, 0.90);
|
||||||
|
--pico-secondary-inverse: #fff;
|
||||||
|
|
||||||
|
--pico-background-color: #0f1a27;
|
||||||
|
--pico-card-background-color: #132232;
|
||||||
|
--pico-card-sectioning-background-color: #17273a;
|
||||||
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 {
|
h1, h2, h3, h4, h5, h6 {
|
||||||
--pico-font-family: 'Fira Code', Roboto, sans-serif;
|
--pico-font-family: 'Fira Code', Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration-color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #C3EAFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:not(:hover) {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|
@ -22,12 +59,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
margin-left: 20px;
|
margin: 0 20px 20px;
|
||||||
margin-right: 20px;
|
padding-top: 0;
|
||||||
margin-bottom: 20px;
|
padding-bottom: 0;
|
||||||
margin-top: 0px;
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container li {
|
.nav-container li {
|
||||||
|
|
@ -36,9 +70,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-container li a {
|
.nav-container li a {
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-family: 'Fira Code', Roboto, sans-serif !important;
|
font-family: 'Fira Code', Roboto, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -54,7 +88,11 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown p {
|
.markdown p {
|
||||||
font-size: 17px;
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown a {
|
||||||
|
color: #C3EAFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.markdown hr {
|
.markdown hr {
|
||||||
|
|
@ -70,3 +108,22 @@ body {
|
||||||
border: 7px solid rgba(0, 0, 0, 0);
|
border: 7px solid rgba(0, 0, 0, 0);
|
||||||
border-radius: 0; box-shadow: 0 0 0 5px #C3EAFF;
|
border-radius: 0; box-shadow: 0 0 0 5px #C3EAFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-fw {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
article header h1,
|
||||||
|
article header h2,
|
||||||
|
article header h3,
|
||||||
|
article header h4,
|
||||||
|
article header h5,
|
||||||
|
article header h6 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-margin {
|
||||||
|
margin: 0;
|
||||||
|
padding: 3px 0;
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 3.8 KiB |