This commit is contained in:
the1mason 2025-01-01 16:17:20 +05:00
parent a2c2db053e
commit ad00f6580d
28 changed files with 497 additions and 35 deletions

View File

@ -0,0 +1,12 @@
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Abstractions.Repositories.Users;
public interface ISessionRepository
{
Task<Session?> Get(string token);
Task<Session?> Set(Session session);
Task<Session?> Touch(string token);
Task<bool> Deactivate(string token);
Task<bool> RemoveAll(int userId);
}

View File

@ -0,0 +1,14 @@
using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Abstractions.Repositories.Users;
public interface IUserRepository
{
Task<User?> Get(int id);
Task<UserPassword?> GetByName(string username);
Task<bool> SetPassword(int id, string newPassword);
Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest);
Task<User> CreateUser(NewUser newUser);
Task<bool> DeleteUser(int userId);
}

View File

@ -0,0 +1,30 @@
using FluentMigrator;
namespace FastBlog.Core.Db.Migrations;
[Migration(202410222104, "Add sessions")]
public class AddSessions : Migration
{
public override void Up()
{
Create.Table("Users")
.WithColumn("Id").AsInt32().PrimaryKey().Unique().Identity()
.WithColumn("Username").AsString().Unique()
.WithColumn("PasswordHash").AsString()
.WithColumn("Deleted").AsBoolean().WithDefaultValue(false);
Create.Table("Sessions")
.WithColumn("Id").AsInt32().PrimaryKey().Unique().Identity()
.WithColumn("Token").AsString().Unique()
.WithColumn("LastUsed").AsDateTime()
.WithColumn("CreatedAt").AsDateTime()
.WithColumn("Active").AsBoolean().WithDefaultValue(true).Indexed("IX_Sessions_Active")
.WithColumn("UserId").AsInt32().ForeignKey("Users", "Id");
}
public override void Down()
{
Delete.Table("Users");
Delete.Table("Sessions");
}
}

View File

@ -1,6 +1,7 @@
using System.Data;
using FastBlog.Core.Abstractions.Repositories.Blogs;
using FastBlog.Core.Abstractions.Repositories.Files;
using FastBlog.Core.Abstractions.Repositories.Users;
using FastBlog.Core.Db;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Options;
@ -17,18 +18,23 @@ public static class DependencyInjection
{
public static IServiceCollection AddCore(this IServiceCollection services, IConfiguration configuration)
{
// applicatrion
// application
services.AddSingleton<BlogService>();
services.AddSingleton<FileService>();
services.AddSingleton<UserService>();
services.AddSingleton<SessionService>();
// options
services.Configure<FileStoreOptions>(configuration.GetSection("FileStore"));
services.Configure<SessionOptions>(configuration.GetSection("Sessions"));
// infrastructure
services.AddSingleton<IBlogMetaRepository, BlogMetaRepository>();
services.AddSingleton<IBlogFileRepository, BlogFileRepository>();
services.AddSingleton<IFileMetaRepository, FileMetaRepository>();
services.AddSingleton<IFileRepository, FileRepository>();
services.AddSingleton<ISessionRepository,SessionRepository>();
services.AddSingleton<IUserRepository, UserRepository>();
// storage
var connectionString = configuration.GetConnectionString("Sqlite");

View File

@ -7,6 +7,9 @@
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.Caching.Memory">
<HintPath>..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.4\Microsoft.Extensions.Caching.Memory.dll</HintPath>
</Reference>
<Reference Include="Microsoft.Extensions.Configuration.Abstractions">
<HintPath>..\..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.4\Microsoft.Extensions.Configuration.Abstractions.dll</HintPath>
</Reference>
@ -16,6 +19,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Dapper.SqlBuilder" Version="2.0.78" />
<PackageReference Include="FluentMigrator" Version="5.2.0" />

View File

@ -0,0 +1,7 @@
namespace FastBlog.Core.Models.Users;
public sealed class NewUser
{
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
}

View File

@ -0,0 +1,12 @@
namespace FastBlog.Core.Models.Users;
public class Session
{
public int Id { get; set; }
public string Token { get; set; } = null!;
public DateTime LastUsed { get; set; }
public DateTime CreatedAt { get; set; }
public int UserId { get; set; }
public string? UserName { get; set; }
public bool Active { get; set; } = true;
}

View File

@ -0,0 +1,19 @@
namespace FastBlog.Core.Models.Users;
public class User
{
public int Id { get; set; }
public string Username { get; set; } = null!;
public bool Deleted { get; set; }
public static User? FromUserPassword(UserPassword? userDb)
{
if (userDb == null) return null;
return new User
{
Id = userDb.Id,
Username = userDb.Username,
Deleted = userDb.Deleted
};
}
}

View File

@ -0,0 +1,9 @@
namespace FastBlog.Core.Models.Users;
public class UserPassword
{
public int Id { get; set; }
public string Username { get; set; } = null!;
public string PasswordHash { get; set; } = null!;
public bool Deleted { get; set; }
}

View File

@ -0,0 +1,26 @@
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Options;
public class SessionOptions
{
public int SlidingExpirationSeconds { get; set; }
public int AbsoluteExpirationSeconds { get; set; }
public bool EnableAbsoluteExpiration { get; set; }
public bool EnableSlidingExpiration { get; set; }
public int TouchAfterSeconds { get; set; }
public (bool SlidingValid, bool AbsoluteValid) Validate(Session session)
{
(bool slidingValid, bool absoluteValid) = (true, true);
if (EnableAbsoluteExpiration)
slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) < DateTime.UtcNow;
if (EnableSlidingExpiration)
absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) < DateTime.UtcNow;
return (slidingValid, absoluteValid);
}
}

View File

@ -0,0 +1,65 @@
using Dapper;
using FastBlog.Core.Abstractions.Repositories.Users;
using FastBlog.Core.Db;
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Repositories;
public sealed class SessionRepository(SqliteConnectionFactory connectionFactory) : ISessionRepository
{
public async Task<Session?> Get(string token)
{
using var connection = connectionFactory.Create();
const string sql =
"""
SELECT s.*, u.UserName
FROM Sessions s
JOIN Users u ON s.UserId = u.Id
WHERE s.Token = @Token AND s.Active = true AND u.Deleted = false
""";
return await connection.QueryFirstOrDefaultAsync<Session>(sql, new { Token = token });
}
public async Task<Session?> Set(Session session)
{
using var connection = connectionFactory.Create();
const string sql =
"""
INSERT INTO Sessions (Token, UserId, Expires, Active)
VALUES (@Token, @UserId, @Expires, @Active)
ON CONFLICT(Token) DO UPDATE SET Expires = @Expires
RETURNING *
""";
return await connection.QueryFirstOrDefaultAsync<Session>(sql, session);
}
public async Task<Session?> Touch(string token)
{
using var connection = connectionFactory.Create();
const string sql =
"""
UPDATE Sessions
SET Expires = @Expires
WHERE Token = @Token
RETURNING *
""";
return await connection.QueryFirstOrDefaultAsync<Session>(sql,
new { Token = token, Expires = DateTime.UtcNow.AddHours(1) });
}
public async Task<bool> Deactivate(string token)
{
using var connection = connectionFactory.Create();
const string sql = "UPDATE Sessions SET Active = false WHERE Token = @Token";
var result = await connection.ExecuteAsync(sql, new { Token = token });
return result > 0;
}
public async Task<bool> RemoveAll(int userId)
{
using var connection = connectionFactory.Create();
const string sql = "DELETE FROM Sessions WHERE UserId = @UserId";
var result = await connection.ExecuteAsync(sql, new { UserId = userId });
return result > 0;
}
}

View File

@ -0,0 +1,61 @@
using Dapper;
using FastBlog.Core.Abstractions.Repositories.Users;
using FastBlog.Core.Db;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Repositories;
public sealed class UserRepository(SqliteConnectionFactory connectionFactory) : IUserRepository
{
public async Task<User?> Get(int id)
{
const string sql = "select * from Users where id = @id";
using var connection = connectionFactory.Create();
return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id });
}
public async Task<UserPassword?> GetByName(string username)
{
const string sql = "select * from Users where username = @username and deleted is false";
using var connection = connectionFactory.Create();
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { username });
}
public async Task<bool> SetPassword(int id, string newPassword)
{
const string sql = "update Users set passwordHash = @newPassword where id = @id";
using var connection = connectionFactory.Create();
return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0;
}
public async Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest)
{
const string sql = """
select COUNT(*) from Users;
select * from Users
order by CreatedAt desc
limit @Amount offset @Offset;
""";
using var connection = connectionFactory.Create();
await using var multi = await connection.QueryMultipleAsync(sql, pagedRequest);
var total = await multi.ReadFirstAsync<int>();
var result = (await multi.ReadAsync<User>()).ToArray();
return PagedResponse<User>.Create(total, total, pagedRequest.Offset, result);
}
public async Task<User> CreateUser(NewUser newUser)
{
const string sql = "insert into Users (username, passwordHash, email) values (@Username, @Password, @Email) returning *";
using var connection = connectionFactory.Create();
return await connection.QueryFirstAsync<User>(sql, newUser);
}
public async Task<bool> DeleteUser(int userId)
{
const string sql = "update Users set deleted = true where id = @userId";
using var connection = connectionFactory.Create();
return await connection.ExecuteAsync(sql, new { userId }) > 0;
}
}

View File

@ -0,0 +1,57 @@
using System.Security.Cryptography;
using FastBlog.Core.Abstractions.Repositories.Users;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
using FastBlog.Core.Options;
using Microsoft.Extensions.Options;
namespace FastBlog.Core.Services;
public sealed class SessionService(
ISessionRepository repository,
UserService userService,
IOptionsMonitor<SessionOptions> options)
{
public async Task<Result<Session?, BusinessError>> Get(string token)
{
var session = await repository.Get(token);
if (session is null)
return null as Session;
(bool slidingValid, bool absoluteValid) = options.CurrentValue.Validate(session);
if(!slidingValid || !absoluteValid)
{
await repository.Deactivate(token);
return new BusinessError("session-expired");
}
session.LastUsed = DateTime.UtcNow;
if(session.LastUsed - session.CreatedAt > TimeSpan.FromSeconds(options.CurrentValue.TouchAfterSeconds))
await repository.Touch(token);
return session;
}
public async Task<Session?> Authenticate(string username, string password)
{
var user = await userService.GetByCredentials(username, password);
if (user is null)
return null;
var session = new Session
{
UserId = user.Id,
Token = RandomNumberGenerator.GetHexString(64, true),
CreatedAt = DateTime.UtcNow,
LastUsed = DateTime.UtcNow
};
return await repository.Set(session);
}
public Task<bool> Deactivate(string token) => repository.Deactivate(token);
public Task<bool> RemoveAll(int userId) => repository.RemoveAll(userId);
}

View File

@ -0,0 +1,31 @@
using FastBlog.Core.Abstractions.Repositories.Users;
using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
namespace FastBlog.Core.Services;
public sealed class UserService(IUserRepository repository)
{
public Task<User?> Get(int id) => repository.Get(id);
public async Task<User?> GetByCredentials(string username, string password)
{
var user = await repository.GetByName(username);
if (user is null) return null;
return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user);
}
public Task<bool> SetPassword(int id, string newPassword) =>
repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword));
public Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest);
public Task<User> CreateUser(NewUser newUser)
{
newUser.Password = BCrypt.Net.BCrypt.HashPassword(newUser.Password);
return repository.CreateUser(newUser);
}
public async Task<bool> DeleteUser(int userId) => await repository.DeleteUser(userId);
}

View File

@ -1,6 +1,7 @@
using FastBlog.Core.Models;
using FastBlog.Core.Models.Blogs;
using FastBlog.Core.Services;
using FastBlog.Web.Middlewares;
using FastBlog.Web.Models;
using Microsoft.AspNetCore.Mvc;
@ -37,7 +38,7 @@ public class BlogsController(BlogService service) : Controller
return View(blog);
}
[SimpleAuth]
[HttpGet]
[Route("unpublished/{*slug}")]
public async Task<IActionResult> Unpublished(string? slug)
@ -52,7 +53,8 @@ public class BlogsController(BlogService service) : Controller
return View(blog);
}
[SimpleAuth]
[HttpGet("list/edit")]
public async Task<IActionResult> ListEdit(
[FromQuery(Name = "amount")] int amount = 25,
@ -71,6 +73,7 @@ public class BlogsController(BlogService service) : Controller
return View(await service.ListMetas(new BlogFilter(from,to, query), new PagedRequest(amount, offset)));
}
[SimpleAuth]
[HttpGet]
[Route("edit/{id:int?}")]
public async ValueTask<IActionResult> Edit(int? id)
@ -115,7 +118,8 @@ public class BlogsController(BlogService service) : Controller
Signature = blog.Metadata.Signature
});
}
[SimpleAuth]
[HttpPost]
[Route("edit")]
public async Task<IActionResult> Edit([FromForm] EditBlog editBlog)
@ -152,6 +156,7 @@ public class BlogsController(BlogService service) : Controller
return Redirect($"/blogs/{editBlog.Slug}");
}
[SimpleAuth]
[HttpPost]
[Route("preview")]
public IActionResult Preview([FromForm] EditBlog editBlog)
@ -174,7 +179,8 @@ public class BlogsController(BlogService service) : Controller
}
});
}
[SimpleAuth]
[HttpDelete]
[Route("{id:int}")]
public async Task<IActionResult> Delete(int id)

View File

@ -1,10 +1,12 @@
using FastBlog.Core.Models;
using FastBlog.Core.Models.Files;
using FastBlog.Core.Services;
using FastBlog.Web.Middlewares;
using Microsoft.AspNetCore.Mvc;
namespace FastBlog.Web.Controllers;
[SimpleAuth]
[Route("files")]
public class FilesController(FileService fileService) : Controller
{
@ -37,7 +39,7 @@ public class FilesController(FileService fileService) : Controller
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
// rn I am the only user so I dont care
if (HttpContext.Request.Form.Files.Count < 1)
return BadRequest("File is required.");

View File

@ -0,0 +1,10 @@
using FastBlog.Core.Services;
using Microsoft.AspNetCore.Mvc;
namespace FastBlog.Web.Controllers;
public sealed class UsersController(UserService userService, SessionService sessionService) : Controller
{
}

View File

@ -17,4 +17,9 @@
<PackageReference Include="Pek.Markdig.HighlightJs" Version="0.5.1" />
</ItemGroup>
<ItemGroup>
<Folder Include="md\" />
<Folder Include="wwwroot\static\" />
</ItemGroup>
</Project>

View File

@ -1,9 +1,9 @@
namespace FastBlog.Web.Helpers;
public static class PropertyHelper
{
public static string If(bool condition, string property)
{
return condition ? property : string.Empty;
}
namespace FastBlog.Web.Helpers;
public static class HtmlPropertyHelper
{
public static string If(bool condition, string property)
{
return condition ? property : string.Empty;
}
}

View File

@ -0,0 +1,60 @@
using FastBlog.Core.Models;
using FastBlog.Core.Models.Users;
using FastBlog.Core.Services;
namespace FastBlog.Web.Middlewares;
public class SimpleAuthMiddleware(SessionService service, RequestDelegate next)
{
public async Task Invoke(HttpContext context)
{
var meta = context.GetEndpoint()?.Metadata.GetMetadata<SimpleAuthAttribute>();
var session = context.Request.Cookies["session"];
if (session is null && meta is not null)
{
context.Response.Redirect("/");
return;
}
if (session is null && meta is null)
{
await next(context);
return;
}
var result = await service.Get(session!);
if (result.IsError)
{
context.Response.Cookies.Delete("session");
context.Response.Redirect("/");
return;
}
context.Features.Set(new User
{
Id = result.AsOk!.UserId,
Username = result.AsOk.UserName!,
Deleted = false
});
await next(context);
}
}
internal class SimpleAuthAttribute : Attribute;
public static class SimpleAuthExtensions
{
public static bool TryGetUser(this HttpContext context, out User? user)
{
user = context.Features.Get<User>();
return user is not null;
}
public static bool IsUser(this HttpContext context)
{
return context.Features.Get<User>() is not null;
}
}

View File

@ -1,5 +1,9 @@
@model EditBlog
@{
ViewData["Title"] = "Edit Blog";
}
<link rel="stylesheet" href="/lib/simplemde/simplemde.min.css">
<div class="grid">
@ -7,11 +11,11 @@
<div>
@if (Model.Id is null)
{
<h2>New Blog</h2>
<h2>New Blog</h2>
}
else
{
<h2>Edit "@Model.Title"</h2>
<h2>Edit "@Model.Title"</h2>
}
<form action="~/blogs/edit/" method="post">
@ -68,11 +72,11 @@
<button type="submit">
@if (Model.Id is null)
{
<span>Create</span>
<span>Create</span>
}
else
{
<span>Update</span>
<span>Update</span>
}
</button>
</div>

View File

@ -1,14 +1,18 @@
@model FastBlog.Core.Models.Blogs.Blog
@using FastBlog.Web.Middlewares
@model FastBlog.Core.Models.Blogs.Blog
@{
ViewBag.Title = Model.Metadata.Title;
ViewData["Title"] = Model.Metadata.Title;
}
@await Html.PartialAsync("Blogs/Content", Model)
<br>
@if(Context.IsUser())
{
<div class="container">
<hr/>
@await Html.PartialAsync("Blogs/ManageCard", Model.Metadata)
</div>
</div>
}

View File

@ -3,7 +3,7 @@
@{
ViewBag.Title = "Blogs";
ViewData["Title"] = "Blogs";
}
<div class="container" id="file-list">
@ -107,14 +107,14 @@
hx-get="/edit-list?offset=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/edit-list?offset=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>

View File

@ -1,15 +1,11 @@
@using FastBlog.Web.Helpers
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Blogs.BlogMeta>
@{
ViewBag.Title = "Blogs";
ViewData["Title"] = "Blogs";
}
<div class="container" id="file-list">
<br/>
<article>
<header>
<a href="/edit">
@ -107,14 +103,14 @@
hx-get="/edit-list?offset=@(Model.Offset - 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
Previous
</button>
<button
hx-get="/edit-list?offset=@(Model.Offset + 25)"
hx-swap="outerHTML"
hx-target="#file-list"
@PropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>

View File

@ -1,5 +1,9 @@
@model FastBlog.Core.Models.Blogs.Blog
@{
ViewData["Title"] = "Preview of " + Model.Metadata.Title;
}
@await Html.PartialAsync("Blogs/Content", Model)
<hr/>

View File

@ -22,6 +22,18 @@
<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/"/>
<input type="radio" id="age1" name="age" value="30">
<label for="age1">0 - 30</label><br>
<input type="radio" id="age2" name="age" value="60">
<label for="age2">31 - 60</label><br>
<input type="radio" id="age3" name="age" value="100">
<label for="age3">61 - 100</label><br><br>
<input type="submit" value="Submit">
@Html.RadioButtonFor()
<button type="submit" class="btn-fw">
Upload
</button>
@ -74,14 +86,14 @@
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")>
@HtmlPropertyHelper.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")>
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
Next
</button>
</div>

View File

@ -30,7 +30,7 @@ return;
<nav>
<ul>
<li><a href="~/"><h1>@options.Value.Title</h1></a></li>
<li><a href="~/blogs/list">Blog</a></li>
<li><a href="~/list">Blog</a></li>
<li><a href="~/edit">New</a></li>
<li><a href="~/files">Files</a></li>
</ul>

View File

@ -19,5 +19,11 @@
"GitHub": "https://github.com",
"Mail": "mailto:mail@example.com"
}
},
"Sessions": {
"SlidingExpirationSeconds": 2592000,
"AbsoluteExpirationSeconds": 7776000,
"EnableAbsoluteExpiration": true,
"EnableSlidingExpiration": true
}
}
}