Mass refactoring, added static file management

This commit is contained in:
the1mason 2024-09-27 02:28:12 +05:00
parent d70addf7ca
commit 5812cc3641
45 changed files with 653 additions and 212 deletions

2
.gitignore vendored
View File

@ -2,7 +2,7 @@
*.db
# BLOGS
blogs/
md/
# 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

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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");
}
}

View File

@ -1,7 +1,7 @@
using System.Data;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Abstractions.Repositories.Files;
using FastBlog.Core.Db;
using FastBlog.Core.Errors.Blogs;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Options;
using FastBlog.Core.Repositories;
@ -19,13 +19,16 @@ public static class DependencyInjection
{
// applicatrion
services.AddSingleton<BlogService>();
services.AddSingleton<FileService>();
// options
services.Configure<FileStoreOptions>(configuration.GetSection("FileStore"));
// infrastructure
services.AddSingleton<IBlogMetaRepository, BlogMetaRepository>();
services.AddSingleton<IBlogSourceRepository, BlogSourceRepository>();
services.AddSingleton<IBlogFileRepository, BlogBlogFileRepository>();
services.AddSingleton<IFileMetaRepository, FileMetaRepository>();
services.AddSingleton<IFileRepository, FileRepository>();
// storage
var connectionString = configuration.GetConnectionString("Sqlite");
@ -52,7 +55,7 @@ public static class DependencyInjection
var blogService = scope.ServiceProvider.GetRequiredService<BlogService>();
var mainPage = await blogService.Get(null);
if (mainPage.IsOk)
if (mainPage is not null)
return;
await blogService.UpdateBlog(new Blog

View File

@ -1,8 +0,0 @@
namespace FastBlog.Core.Errors;
public enum FileError
{
NotFound,
AlreadyExists,
InvalidDirectory
}

View File

@ -0,0 +1,7 @@
namespace FastBlog.Core.Models.Blogs;
public class Blog
{
public required string Text { get; init; }
public required BlogMeta Metadata { get; init; }
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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; }
}

View File

@ -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;
}
}

View File

@ -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, []);
}
}

View File

@ -2,5 +2,6 @@
public class FileStoreOptions
{
public required string SourcePath { get; init; }
public required string BlogSourcePath { get; init; }
public required string StaticFilesPath { get; init; }
}

View File

@ -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);
}
}

View File

@ -1,7 +1,6 @@
using Dapper;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Db;
using FastBlog.Core.Errors.Blogs;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Blogs;
using Microsoft.Data.Sqlite;
@ -10,7 +9,7 @@ namespace FastBlog.Core.Repositories;
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();
@ -28,19 +27,11 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
""";
if (slug is null)
{
var resultNull = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
if (resultNull is null) return BlogError.NotFound;
return resultNull;
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sqlNull);
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { slug });
}
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();
@ -50,13 +41,10 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
and deleted = 0
""";
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { id });
if (result is null) return BlogError.NotFound;
return result;
return await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, new { id });
}
public async Task<Result<BlogMeta, BlogError>> Add(BlogMeta meta)
public async Task<Result<BlogMeta, BusinessError>> Add(BlogMeta meta)
{
using var connection = connectionFactory.Create();
@ -67,20 +55,18 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
""";
try
{
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, meta);
if (result is null) return BlogError.NotFound;
return result;
return await connection.QueryFirstAsync<BlogMeta>(sql, meta);
}
catch (SqliteException ex)
{
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;
}
}
public async Task<Result<BlogMeta, BlogError>> Update(BlogMeta meta)
public async Task<Result<BlogMeta, BusinessError>> Update(BlogMeta meta)
{
using var connection = connectionFactory.Create();
@ -93,15 +79,12 @@ public sealed class BlogMetaRepository(SqliteConnectionFactory connectionFactory
try
{
var result = await connection.QueryFirstOrDefaultAsync<BlogMeta>(sql, meta);
if (result is null) return BlogError.NotFound;
return result;
return await connection.QueryFirstAsync<BlogMeta>(sql, meta);
}
catch (SqliteException ex)
{
if (ex.SqliteErrorCode == 19) // Unique constraint violation
return BlogError.AlreadyExists;
return new BusinessError("already_exists","Slug already exists");
throw;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -1,74 +1,67 @@
using System.Diagnostics;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Errors;
using FastBlog.Core.Errors.Blogs;
using FastBlog.Core.Abstractions.Repositories.Files;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Blogs;
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);
if (metaResult.IsError)
return metaResult.AsError;
var sourceResult = await sourceRepository.Read(metaResult.AsOk.SourceLocation);
if (metaResult is null)
return null;
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
{
Metadata = metaResult.AsOk,
Text = sourceResult.AsOk
Metadata = metaResult,
Text = sourceResult
};
}
public async Task<Result<Blog, BlogError>> GetForEdit(int id)
public async Task<Blog?> GetForEdit(int id)
{
var metaResult = await metaRepository.GetForEdit(id);
if (metaResult.IsError)
return metaResult.AsError;
var sourceResult = await sourceRepository.Read(metaResult.AsOk.SourceLocation);
if (sourceResult.IsError)
{
throw new FileNotFoundException("Blog with metadata cannot be found");
}
if (metaResult is null)
return null;
var sourceResult = await blogFileRepository.Read(metaResult.SourceLocation);
if (sourceResult is null)
throw new FileNotFoundException("File with metadata cannot be found");
return new Blog
{
Metadata = metaResult.AsOk,
Text = sourceResult.AsOk
Metadata = metaResult,
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);
}
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);
if (metaResult.IsError)
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)
return sourceResult.AsError switch
{
FileError.AlreadyExists => BlogError.AlreadyExists,
FileError.InvalidDirectory => throw new Exception(
"Invalid configuration: Directory for blog storage not found"),
_ => throw new UnreachableException()
};
return sourceResult.AsError;
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);
if (oldMeta.IsError)
return BlogError.NotFound;
if (oldMeta is null)
return new BusinessError("not_found", "Blog not found");
var toDelete = oldMeta.AsOk.SourceLocation;
var toDelete = oldMeta.SourceLocation;
var metaResult = await metaRepository.Update(blog.Metadata);
if (metaResult.IsError)
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)
return sourceResult.AsError switch
{
FileError.AlreadyExists => BlogError.AlreadyExists,
FileError.InvalidDirectory => throw new Exception(
"Invalid configuration: Directory for blog storage not found"),
_ => throw new UnreachableException()
};
return sourceResult.AsError;
return new Blog
{

View File

@ -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;
}
}

View File

@ -1,5 +1,5 @@
using System.Diagnostics;
using FastBlog.Core.Errors.Blogs;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Services;
using FastBlog.Web.Models;
@ -8,8 +8,8 @@ using Microsoft.AspNetCore.Mvc;
namespace FastBlog.Web.Controllers;
[Route("")]
[Route("blog")]
public class BlogController(BlogService service) : Controller
[Route("blogs")]
public class BlogsController(BlogService service) : Controller
{
[HttpGet]
[Route("{*slug}")]
@ -20,16 +20,10 @@ public class BlogController(BlogService service) : Controller
var blog = await service.Get(slug);
if (blog.IsError)
{
return blog.AsError switch
{
BlogError.NotFound => NotFound(),
_ => throw new ArgumentOutOfRangeException()
};
}
if (blog is null)
return NotFound();
return View(blog.AsOk);
return View(blog);
}
[HttpGet]
@ -38,16 +32,10 @@ public class BlogController(BlogService service) : Controller
{
var blog = await service.Get(null);
if (blog.IsError)
{
return blog.AsError switch
{
BlogError.NotFound => NotFound(),
_ => throw new ArgumentOutOfRangeException()
};
}
if (blog is null)
return NotFound();
return View(blog.AsOk);
return View(blog);
}
[HttpGet]
@ -73,17 +61,10 @@ public class BlogController(BlogService service) : Controller
);
}
var result = await service.GetForEdit(id.Value);
if (result.IsError)
{
return result.AsError switch
{
BlogError.NotFound => NotFound(),
_ => throw new UnreachableException()
};
}
var blog = await service.GetForEdit(id.Value);
var blog = result.AsOk;
if (blog is null)
return NotFound();
return View(new EditBlog
{
@ -127,14 +108,15 @@ public class BlogController(BlogService service) : Controller
if (result.IsError)
{
return result.AsError switch
return result.AsError.ShortCode switch
{
BlogError.AlreadyExists => Conflict(),
_ => throw new ArgumentOutOfRangeException()
"already_exists" => Conflict(),
"not_found" => NotFound(),
_ => throw result.AsError
};
}
return Redirect($"/blog/{editBlog.Slug}");
return Redirect($"/blogs/{editBlog.Slug}");
}
[HttpPost]

View File

@ -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();
}
}

View File

@ -18,38 +18,6 @@
</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\Privacy.cshtml" />
</ItemGroup>

View File

@ -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;
}
}

View File

@ -28,7 +28,7 @@ app.UseAuthorization();
app.MapControllerRoute(
name: "default",
pattern: "{controller=Blog}/{action=Index}/{slug?}");
pattern: "{controller=Blogs}/{action=Index}/{slug?}");
await app.Services.MigrateUp(app.Configuration);

View File

@ -13,7 +13,7 @@ else
<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"/>
<div>
<label for="title">Title:</label>

View File

@ -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>

View File

@ -7,9 +7,17 @@
},
"AllowedHosts": "*",
"FileStore": {
"SourcePath": "blogs"
"BlogSourcePath": "md",
"StaticFilesPath": "wwwroot/static"
},
"ConnectionStrings": {
"Sqlite": "Data Source=app.db"
},
"Display": {
"Title": "FastBlog",
"Links": {
"GitHub": "https://github.com",
"Mail": "mailto:mail@example.com"
}
}
}

View File

@ -3,13 +3,50 @@
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 {
--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 {
font-size: 14px;
}
html {
@ -22,12 +59,9 @@ body {
}
.nav-container {
margin-left: 20px;
margin-right: 20px;
margin-bottom: 20px;
margin-top: 0px;
padding-top: 0px;
padding-bottom: 0px;
margin: 0 20px 20px;
padding-top: 0;
padding-bottom: 0;
}
.nav-container li {
@ -36,9 +70,9 @@ body {
}
.nav-container li a {
font-size: 17px;
font-size: 16px;
color: inherit;
font-family: 'Fira Code', Roboto, sans-serif !important;
font-family: 'Fira Code', Roboto, sans-serif;
}
@ -54,7 +88,11 @@ body {
}
.markdown p {
font-size: 17px;
font-size: 16px;
}
.markdown a {
color: #C3EAFF;
}
.markdown hr {
@ -70,3 +108,22 @@ body {
border: 7px solid rgba(0, 0, 0, 0);
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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB