Added user list, updated stuff
This commit is contained in:
parent
f503166d9c
commit
1bba76b71a
|
|
@ -6,6 +6,7 @@ namespace FastBlog.Core.Abstractions.Repositories.Users;
|
||||||
public interface IUserRepository
|
public interface IUserRepository
|
||||||
{
|
{
|
||||||
Task<User?> Get(int id);
|
Task<User?> Get(int id);
|
||||||
|
Task<UserPassword?> GetWithPassword(int id);
|
||||||
Task<UserPassword?> GetByName(string username);
|
Task<UserPassword?> GetByName(string username);
|
||||||
Task<bool> SetPassword(int id, string newPassword);
|
Task<bool> SetPassword(int id, string newPassword);
|
||||||
Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest);
|
Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest);
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ public static class DependencyInjection
|
||||||
{
|
{
|
||||||
var userService = provider.GetRequiredService<UserService>();
|
var userService = provider.GetRequiredService<UserService>();
|
||||||
var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0));
|
var existingUserResult = await userService.GetUsers(new PagedRequest(1, 0));
|
||||||
if (existingUserResult.Amount is 1)
|
if (existingUserResult.Amount >= 1)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var newUser = new NewUser
|
var newUser = new NewUser
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,16 @@
|
||||||
<PackageReference Include="SQLite" Version="3.13.0" />
|
<PackageReference Include="SQLite" Version="3.13.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Remove="obj\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Remove="obj\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Remove="obj\**" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,10 @@ public class SessionOptions
|
||||||
(bool slidingValid, bool absoluteValid) = (true, true);
|
(bool slidingValid, bool absoluteValid) = (true, true);
|
||||||
|
|
||||||
if (EnableAbsoluteExpiration)
|
if (EnableAbsoluteExpiration)
|
||||||
slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) < DateTime.UtcNow;
|
slidingValid = session.CreatedAt.AddSeconds(AbsoluteExpirationSeconds) > DateTime.UtcNow;
|
||||||
|
|
||||||
if (EnableSlidingExpiration)
|
if (EnableSlidingExpiration)
|
||||||
absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) < DateTime.UtcNow;
|
absoluteValid = session.LastUsed.AddSeconds(SlidingExpirationSeconds) > DateTime.UtcNow;
|
||||||
|
|
||||||
return (slidingValid, absoluteValid);
|
return (slidingValid, absoluteValid);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,23 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
|
||||||
return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id });
|
return await connection.QueryFirstOrDefaultAsync<User>(sql, new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<UserPassword?> GetWithPassword(int id)
|
||||||
|
{
|
||||||
|
const string sql = "select * from Users where id = @id and deleted is false";
|
||||||
|
using var connection = connectionFactory.Create();
|
||||||
|
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { id });
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<UserPassword?> GetByName(string username)
|
public async Task<UserPassword?> GetByName(string username)
|
||||||
{
|
{
|
||||||
const string sql = "select * from Users where username = @username and deleted is false";
|
const string sql = "select * from Users where username = @username";
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { username });
|
return await connection.QueryFirstOrDefaultAsync<UserPassword>(sql, new { username });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> SetPassword(int id, string newPassword)
|
public async Task<bool> SetPassword(int id, string newPassword)
|
||||||
{
|
{
|
||||||
const string sql = "update Users set passwordHash = @newPassword where id = @id";
|
const string sql = "update Users set passwordHash = @newPassword where id = @id and deleted = 0";
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0;
|
return await connection.ExecuteAsync(sql, new { id, newPassword }) > 0;
|
||||||
}
|
}
|
||||||
|
|
@ -34,6 +41,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
|
||||||
const string sql = """
|
const string sql = """
|
||||||
select COUNT(*) from Users;
|
select COUNT(*) from Users;
|
||||||
select * from Users
|
select * from Users
|
||||||
|
where deleted = 0
|
||||||
limit @Amount offset @Offset;
|
limit @Amount offset @Offset;
|
||||||
""";
|
""";
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
|
|
@ -53,7 +61,7 @@ public sealed class UserRepository(SqliteConnectionFactory connectionFactory) :
|
||||||
|
|
||||||
public async Task<bool> DeleteUser(int userId)
|
public async Task<bool> DeleteUser(int userId)
|
||||||
{
|
{
|
||||||
const string sql = "update Users set deleted = true where id = @userId";
|
const string sql = "update Users set deleted = true where id = @userId and deleted = 0";
|
||||||
using var connection = connectionFactory.Create();
|
using var connection = connectionFactory.Create();
|
||||||
return await connection.ExecuteAsync(sql, new { userId }) > 0;
|
return await connection.ExecuteAsync(sql, new { userId }) > 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,41 @@ public sealed class UserService(IUserRepository repository)
|
||||||
{
|
{
|
||||||
public Task<User?> Get(int id) => repository.Get(id);
|
public Task<User?> Get(int id) => repository.Get(id);
|
||||||
|
|
||||||
|
public async Task<User?> GetByName(string username)
|
||||||
|
{
|
||||||
|
return User.FromUserPassword(await repository.GetByName(username));
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<User?> GetByCredentials(string username, string password)
|
public async Task<User?> GetByCredentials(string username, string password)
|
||||||
{
|
{
|
||||||
var user = await repository.GetByName(username);
|
var user = await repository.GetByName(username);
|
||||||
if (user is null) return null;
|
if (user is null || user.Deleted) return null;
|
||||||
|
|
||||||
return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user);
|
return !BCrypt.Net.BCrypt.Verify(password, user.PasswordHash) ? null : User.FromUserPassword(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<bool> SetPassword(int id, string newPassword) =>
|
public sealed record ChangePasswordResult(bool Success, string Message);
|
||||||
repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword));
|
public async Task<ChangePasswordResult> ChangePassword(int id, string oldPassword, string newPassword)
|
||||||
|
{
|
||||||
|
var user = await repository.GetWithPassword(id);
|
||||||
|
if (user is null)
|
||||||
|
return new(false, "Unknown user");
|
||||||
|
if (!BCrypt.Net.BCrypt.Verify(oldPassword, user.PasswordHash))
|
||||||
|
return new(false, "Wrong password");
|
||||||
|
if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)))
|
||||||
|
return new(false, "Couldn't update password");
|
||||||
|
return new(true, "Successfully updated the password");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ChangePasswordResult> SetPassword(int id, string newPassword)
|
||||||
|
{
|
||||||
|
var user = await repository.GetWithPassword(id);
|
||||||
|
if (user is null)
|
||||||
|
return new(false, "Unknown user");
|
||||||
|
if (!await repository.SetPassword(id, BCrypt.Net.BCrypt.HashPassword(newPassword)))
|
||||||
|
return new(false, "Couldn't update password");
|
||||||
|
return new(true, "Successfully updated the password");
|
||||||
|
}
|
||||||
|
|
||||||
public Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest);
|
public Task<PagedResponse<User>> GetUsers(PagedRequest pagedRequest) => repository.GetUsers(pagedRequest);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ public class FilesController(FileService fileService) : Controller
|
||||||
private const long MaxFileSize = 8L * 1024L * 1024L * 1024L;
|
private const long MaxFileSize = 8L * 1024L * 1024L * 1024L;
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
|
[SimpleAuth]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var files = await fileService.GetFiles(new PagedRequest(25, 0));
|
var files = await fileService.GetFiles(new PagedRequest(25, 0));
|
||||||
|
|
@ -20,6 +21,7 @@ public class FilesController(FileService fileService) : Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{offset:int}")]
|
[HttpGet("{offset:int}")]
|
||||||
|
[SimpleAuth]
|
||||||
public async Task<IActionResult> Page(int offset)
|
public async Task<IActionResult> Page(int offset)
|
||||||
{
|
{
|
||||||
var files = await fileService.GetFiles(new PagedRequest(25, offset));
|
var files = await fileService.GetFiles(new PagedRequest(25, offset));
|
||||||
|
|
@ -27,6 +29,7 @@ public class FilesController(FileService fileService) : Controller
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{metaId:int}")]
|
[HttpDelete("{metaId:int}")]
|
||||||
|
[SimpleAuth]
|
||||||
public async Task<IActionResult> Delete(int metaId)
|
public async Task<IActionResult> Delete(int metaId)
|
||||||
{
|
{
|
||||||
var result = await fileService.DeleteFile(metaId);
|
var result = await fileService.DeleteFile(metaId);
|
||||||
|
|
@ -36,6 +39,7 @@ public class FilesController(FileService fileService) : Controller
|
||||||
|
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
|
[SimpleAuth]
|
||||||
public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath)
|
public async Task<IActionResult> Upload([FromForm(Name = "sourcePath")] string? sourcePath)
|
||||||
{
|
{
|
||||||
// TODO: Validate path to prevent directory traversal
|
// TODO: Validate path to prevent directory traversal
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
namespace FastBlog.Web.Controllers;
|
namespace FastBlog.Web.Controllers;
|
||||||
|
|
||||||
public class RateLimitController : Controller
|
public class TooManyRequestsController : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("/RateLimited")]
|
[HttpGet("/too-many-requests")]
|
||||||
public IActionResult Index()
|
public IActionResult Index()
|
||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,41 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Globalization;
|
using FastBlog.Core.Models;
|
||||||
|
using FastBlog.Core.Models.Users;
|
||||||
using FastBlog.Core.Services;
|
using FastBlog.Core.Services;
|
||||||
|
using FastBlog.Web.Middlewares;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
namespace FastBlog.Web.Controllers;
|
namespace FastBlog.Web.Controllers;
|
||||||
|
|
||||||
|
[EnableRateLimiting("fixed")]
|
||||||
public sealed class UsersController(UserService userService, SessionService sessionService) : Controller
|
public sealed class UsersController(UserService userService, SessionService sessionService) : Controller
|
||||||
{
|
{
|
||||||
|
public UserService UserService { get; } = userService;
|
||||||
|
|
||||||
public sealed record LoginRequest(string? Login, string? Password);
|
public sealed record LoginRequest(string? Login, string? Password);
|
||||||
|
|
||||||
|
public sealed record CreateRequest(string Login, string Password);
|
||||||
|
|
||||||
public sealed record LoginModel(string? Login, string? Password, List<string> Errors);
|
public sealed record LoginModel(string? Login, string? Password, List<string> Errors);
|
||||||
|
|
||||||
[HttpGet("/users/login")]
|
[HttpGet("/users/login")]
|
||||||
public async Task<IActionResult> Login()
|
public IActionResult LogIn()
|
||||||
{
|
{
|
||||||
|
if (HttpContext.IsUser())
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
return View(new LoginModel(null, null, []));
|
return View(new LoginModel(null, null, []));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Htmx]
|
||||||
|
[EnableRateLimiting("fixed")]
|
||||||
[HttpPost("/users/login")]
|
[HttpPost("/users/login")]
|
||||||
public async Task<IActionResult> Login(LoginRequest request)
|
public async Task<IActionResult> LogIn(LoginRequest request)
|
||||||
{
|
{
|
||||||
|
if (HttpContext.IsUser())
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
List<string> errors = [];
|
List<string> errors = [];
|
||||||
// validation
|
// validation
|
||||||
if (string.IsNullOrWhiteSpace(request.Login)
|
if (string.IsNullOrWhiteSpace(request.Login)
|
||||||
|
|
@ -36,7 +52,7 @@ public sealed class UsersController(UserService userService, SessionService sess
|
||||||
|
|
||||||
if (errors.Count > 0)
|
if (errors.Count > 0)
|
||||||
{
|
{
|
||||||
return View(new LoginModel(request.Login, request.Password, errors));
|
return PartialView(new LoginModel(request.Login, request.Password, errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug.Assert(request.Login is not null);
|
Debug.Assert(request.Login is not null);
|
||||||
|
|
@ -48,11 +64,124 @@ public sealed class UsersController(UserService userService, SessionService sess
|
||||||
if (session is null)
|
if (session is null)
|
||||||
{
|
{
|
||||||
errors.Add("Unknown user or incorrect password");
|
errors.Add("Unknown user or incorrect password");
|
||||||
return View(new LoginModel(request.Login, request.Password, errors));
|
return PartialView(new LoginModel(request.Login, request.Password, errors));
|
||||||
}
|
}
|
||||||
|
|
||||||
UpdateCookie(HttpContext, "session-t", session.Token);
|
UpdateCookie(HttpContext, "session-t", session.Token);
|
||||||
return Redirect("/");
|
|
||||||
|
HttpContext.Response.Headers["HX-Redirect"] = "/";
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[Htmx]
|
||||||
|
[HttpDelete("/users/logout")]
|
||||||
|
public async ValueTask<IActionResult> LogOut()
|
||||||
|
{
|
||||||
|
if (!HttpContext.Request.Cookies.TryGetValue("session-t", out var sessionT))
|
||||||
|
return Redirect("/");
|
||||||
|
|
||||||
|
await sessionService.Deactivate(sessionT);
|
||||||
|
HttpContext.Response.Cookies.Delete("session-t");
|
||||||
|
HttpContext.Response.Headers["HX-Redirect"] = "/";
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("/users")]
|
||||||
|
[SimpleAuth]
|
||||||
|
public async Task<IActionResult> Page([FromQuery] int page = 0)
|
||||||
|
{
|
||||||
|
var users = await userService.GetUsers(new PagedRequest(25, page));
|
||||||
|
return View("Index", users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpDelete("/users/{id:int}")]
|
||||||
|
[SimpleAuth]
|
||||||
|
public async Task<IActionResult> Delete(int id)
|
||||||
|
{
|
||||||
|
_ = await userService.DeleteUser(id);
|
||||||
|
HttpContext.Response.Headers["HX-Redirect"] = "/users";
|
||||||
|
return Ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record UserViewModel(User User, bool Success = true, string? Message = null);
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[HttpGet("/users/me")]
|
||||||
|
public IActionResult GetCurrent()
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out var user))
|
||||||
|
{
|
||||||
|
return Redirect("/users/login");
|
||||||
|
}
|
||||||
|
|
||||||
|
return View("Profile", new UserViewModel(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[HttpPost("/users")]
|
||||||
|
public async Task<IActionResult> Create(CreateRequest createRequest)
|
||||||
|
{
|
||||||
|
if (await userService.GetByName(createRequest.Login) is not null)
|
||||||
|
{
|
||||||
|
var u = await userService.GetUsers(new PagedRequest(25, 0));
|
||||||
|
var v = View("Index", u);
|
||||||
|
v.ViewData.Add("error", "User already exists");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
var user = await userService.CreateUser(new NewUser
|
||||||
|
{
|
||||||
|
Password = createRequest.Password,
|
||||||
|
Username = createRequest.Login
|
||||||
|
});
|
||||||
|
var users = await userService.GetUsers(new PagedRequest(25, 0));
|
||||||
|
return View("Index", users);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[HttpGet("/users/{id:int}")]
|
||||||
|
public async Task<IActionResult> Get(int id)
|
||||||
|
{
|
||||||
|
var user = await userService.Get(id);
|
||||||
|
if (user is null)
|
||||||
|
return Redirect("~/");
|
||||||
|
return View("Profile", new UserViewModel(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public sealed record ChangePasswordModel(string OldPassword, string NewPassword);
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[HttpPost("/users/{id:int}/change-password")]
|
||||||
|
public async Task<IActionResult> ChangePassword(int id, ChangePasswordModel model)
|
||||||
|
{
|
||||||
|
var user = await userService.Get(id);
|
||||||
|
if (user is null)
|
||||||
|
return Redirect("~/");
|
||||||
|
var result = await userService.ChangePassword(id, model.OldPassword, model.NewPassword);
|
||||||
|
return View("Profile", new UserViewModel(user, result.Success, result.Message));
|
||||||
|
}
|
||||||
|
|
||||||
|
[SimpleAuth]
|
||||||
|
[HttpPost("/users/{id:int}/set-password")]
|
||||||
|
public async Task<IActionResult> SetPassword(int id, string newPassword)
|
||||||
|
{
|
||||||
|
if (!HttpContext.TryGetUser(out var currentUser))
|
||||||
|
{
|
||||||
|
return Redirect("~/");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.Id == id)
|
||||||
|
{
|
||||||
|
return View("Profile", new UserViewModel(currentUser, false, "Provide the old password too"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var user = await userService.Get(id);
|
||||||
|
if (user is null)
|
||||||
|
return Redirect("~/");
|
||||||
|
var result = await userService.SetPassword(id, newPassword);
|
||||||
|
return View("Profile", new UserViewModel(user, result.Success, result.Message));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void UpdateCookie(HttpContext context, string key, string value)
|
private static void UpdateCookie(HttpContext context, string key, string value)
|
||||||
|
|
@ -62,8 +191,8 @@ public sealed class UsersController(UserService userService, SessionService sess
|
||||||
{
|
{
|
||||||
HttpOnly = true,
|
HttpOnly = true,
|
||||||
SameSite = SameSiteMode.Strict,
|
SameSite = SameSiteMode.Strict,
|
||||||
|
Secure = true,
|
||||||
IsEssential = true
|
IsEssential = true
|
||||||
// TODO: more options w/ configs, ill think bout that later
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
namespace FastBlog.Web;
|
namespace FastBlog.Web;
|
||||||
|
|
||||||
public class HtmxAttribute
|
/// <summary>
|
||||||
{
|
/// Marks methods used by HTMX requests<br/>
|
||||||
|
/// Use Redirect() with caution!<br/>
|
||||||
}
|
/// Does not affect behaviour<br/>
|
||||||
|
/// </summary>
|
||||||
|
[AttributeUsage(AttributeTargets.Method)]
|
||||||
|
public sealed class HtmxAttribute : Attribute;
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using FastBlog.Core.Models.Users;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using FastBlog.Core.Models.Users;
|
||||||
using FastBlog.Core.Services;
|
using FastBlog.Core.Services;
|
||||||
|
|
||||||
namespace FastBlog.Web.Middlewares;
|
namespace FastBlog.Web.Middlewares;
|
||||||
|
|
@ -12,7 +13,7 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next)
|
||||||
|
|
||||||
if (sessionToken is null && meta is not null)
|
if (sessionToken is null && meta is not null)
|
||||||
{
|
{
|
||||||
context.Response.Redirect("/user/login");
|
context.Response.Redirect("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,10 +25,10 @@ public class SimpleAuthMiddleware(SessionService service, RequestDelegate next)
|
||||||
|
|
||||||
var result = await service.Get(sessionToken!);
|
var result = await service.Get(sessionToken!);
|
||||||
|
|
||||||
if (result.IsError)
|
if (result.IsError || result.AsOk is null)
|
||||||
{
|
{
|
||||||
context.Response.Cookies.Delete("session");
|
context.Response.Cookies.Delete("session-t");
|
||||||
context.Response.Redirect("/user/login");
|
context.Response.Redirect("/users/login");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ internal class SimpleAuthAttribute : Attribute;
|
||||||
|
|
||||||
public static class SimpleAuthExtensions
|
public static class SimpleAuthExtensions
|
||||||
{
|
{
|
||||||
public static bool TryGetUser(this HttpContext context, out User? user)
|
public static bool TryGetUser(this HttpContext context, [NotNullWhen(true)] out User? user)
|
||||||
{
|
{
|
||||||
user = context.Features.Get<User>();
|
user = context.Features.Get<User>();
|
||||||
return user is not null;
|
return user is not null;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
using FastBlog.Core;
|
using FastBlog.Core;
|
||||||
using FastBlog.Web;
|
using FastBlog.Web;
|
||||||
|
using FastBlog.Web.Middlewares;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
|
|
@ -11,8 +13,33 @@ else
|
||||||
builder.Services.AddCore(builder.Configuration);
|
builder.Services.AddCore(builder.Configuration);
|
||||||
builder.Services.Configure<DisplayOptions>(builder.Configuration.GetSection("Display"));
|
builder.Services.Configure<DisplayOptions>(builder.Configuration.GetSection("Display"));
|
||||||
|
|
||||||
|
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
||||||
|
{
|
||||||
|
rateLimiterOptions.AddFixedWindowLimiter(policyName: "fixed", options =>
|
||||||
|
{
|
||||||
|
options.PermitLimit = 30;
|
||||||
|
options.Window = TimeSpan.FromSeconds(300);
|
||||||
|
options.QueueLimit = 0;
|
||||||
|
})
|
||||||
|
.OnRejected += (context, _) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.StatusCode = 200;
|
||||||
|
|
||||||
|
if (context.HttpContext.Request.Headers.ContainsKey("HX-Request"))
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.Headers.Append("HX-Redirect", "/too-many-requests");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.Redirect("/too-many-requests");
|
||||||
|
}
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseExceptionHandler("/Home/Error");
|
app.UseExceptionHandler("/Home/Error");
|
||||||
|
|
@ -23,8 +50,12 @@ app.UseStaticFiles();
|
||||||
|
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
|
|
||||||
|
app.UseRateLimiter();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
app.UseMiddleware<SimpleAuthMiddleware>();
|
||||||
|
|
||||||
app.MapControllerRoute(
|
app.MapControllerRoute(
|
||||||
name: "default",
|
name: "default",
|
||||||
pattern: "{controller=Blogs}/{action=Index}/{slug?}");
|
pattern: "{controller=Blogs}/{action=Index}/{slug?}");
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
@model FastBlog.Core.Models.Blogs.Blog
|
@model FastBlog.Core.Models.Blogs.Blog
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = Model.Metadata.Title;
|
ViewData["Title"] = Model.Metadata.Title;
|
||||||
}
|
}
|
||||||
|
|
||||||
@if(DateTime.UtcNow < Model.Metadata.CreatedAt)
|
@if(DateTime.UtcNow < Model.Metadata.CreatedAt)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "Files";
|
ViewData["Title"] = "Files";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container" id="file-list">
|
<div class="container" id="file-list">
|
||||||
|
|
@ -22,15 +22,6 @@
|
||||||
<input type="file" name="file" required/>
|
<input type="file" name="file" required/>
|
||||||
<label for="upload-path">Path to upload (optional)</label>
|
<label for="upload-path">Path to upload (optional)</label>
|
||||||
<input type="text" id="upload-path" name="sourcePath" placeholder="/subfolders/"/>
|
<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">
|
|
||||||
<button type="submit" class="btn-fw">
|
<button type="submit" class="btn-fw">
|
||||||
Upload
|
Upload
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
@using Microsoft.Extensions.Options
|
@using FastBlog.Web.Middlewares
|
||||||
|
@using Microsoft.Extensions.Options
|
||||||
@inject IOptions<DisplayOptions> options;
|
@inject IOptions<DisplayOptions> options;
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request");
|
ViewContext.HttpContext.Response.Headers.Add("Vary", "Hx-Request");
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true"))
|
@if (ViewContext.HttpContext.Request.Headers["Hx-Request"].Contains("true"))
|
||||||
{
|
{
|
||||||
@RenderBody()
|
@RenderBody()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" data-theme="dark">
|
<html lang="en" data-theme="dark">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -24,20 +22,52 @@ return;
|
||||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
|
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true"/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="~/js/htmx.min.js"></script>
|
<script src="~/lib/htmx/htmx.2.min.js"></script>
|
||||||
<header class="nav-container">
|
<header>
|
||||||
<div>
|
@if (Context.IsUser())
|
||||||
|
{
|
||||||
|
<div class="nav-admin">
|
||||||
|
<nav>
|
||||||
|
<div>
|
||||||
|
<li>
|
||||||
|
<a href="~/edit">New</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="~/files">Files</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="~/users">Users</a>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<li>
|
||||||
|
<a href="/users/me">Profile</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" hx-delete="/users/logout">Logout</a>
|
||||||
|
</li>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="nav-container">
|
||||||
<nav>
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
<li><a href="~/"><h1>@options.Value.Title</h1></a></li>
|
<li>
|
||||||
<li><a href="~/list">Blog</a></li>
|
<a href="~/">
|
||||||
<li><a href="~/edit">New</a></li>
|
<h1>@options.Value.Title</h1>
|
||||||
<li><a href="~/files">Files</a></li>
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="~/list">Blog</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul>
|
<ul>
|
||||||
@foreach (var link in options.Value.Links)
|
@foreach (var link in options.Value.Links)
|
||||||
{
|
{
|
||||||
<li><a href="@link.Value">@link.Key</a></li>
|
<li>
|
||||||
|
<a href="@link.Value">@link.Key</a>
|
||||||
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "Rate limit exceeded";
|
ViewData["Title"] = "Rate limit exceeded";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<article>
|
<article>
|
||||||
<header>Rate limit exceeded!</header>
|
<header>Rate limit exceeded</header>
|
||||||
<div>
|
<div>
|
||||||
<p>You have sent too many requests!</p>
|
<p>You have sent too many requests</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
@using FastBlog.Web.Helpers
|
||||||
|
@model FastBlog.Core.Models.PagedResponse<FastBlog.Core.Models.Users.User>
|
||||||
|
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Users";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="container" id="user-list">
|
||||||
|
<br/>
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Create a user</h3>
|
||||||
|
</header>
|
||||||
|
@if (ViewData.TryGetValue("error", out var errorMsg))
|
||||||
|
{
|
||||||
|
<div class="alert alert-caution">@errorMsg</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="/users"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#user-list">
|
||||||
|
<label for="login">Login</label>
|
||||||
|
<input required type="text" id="login" name="login" placeholder="username"/>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input required type="password" id="password" name="password"/>
|
||||||
|
<button type="submit" class="btn-fw">
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h3>Manage users</h3>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
@if (Model.Data.Length is 0)
|
||||||
|
{
|
||||||
|
<h4>No users have been found</h4>
|
||||||
|
}
|
||||||
|
|
||||||
|
@foreach (var user in Model.Data)
|
||||||
|
{
|
||||||
|
<div class="grid">
|
||||||
|
<div style="display: flex; font-size: 16px">
|
||||||
|
<p class="no-margin" style="margin-right: 10px; font-weight: bold">
|
||||||
|
<a href="~/users/@user.Id/">@user.Username</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr/>
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
<footer>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
hx-get="/users?page=@(Model.Offset - 25)"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list"
|
||||||
|
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) == 0, "disabled")>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
hx-get="/users?page=@(Model.Offset + 25)"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-target="#file-list"
|
||||||
|
@HtmlPropertyHelper.If(Math.Clamp(Model.Offset, 0, Model.Amount) >= Model.Amount - 25, "disabled")>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
@model FastBlog.Web.Controllers.UsersController.LoginModel
|
@model FastBlog.Web.Controllers.UsersController.LoginModel
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewBag.Title = "Login";
|
ViewData["Title"] = "Login";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div id="login-form" class="container">
|
<div id="login-form" class="container">
|
||||||
<form
|
<article >
|
||||||
hx-post="/users/login"
|
<form
|
||||||
hx-swap="outerHTML"
|
hx-post="/users/login"
|
||||||
hx-target="#login-form">
|
hx-swap="outerHTML"
|
||||||
<div>
|
hx-target="#login-form">
|
||||||
<label for="login">Login:</label>
|
<div>
|
||||||
<input type="text" id="login" name="login" value="@Model.Login"
|
<label for="login">Login:</label>
|
||||||
minlength="3"
|
<input type="text" id="login" name="login" value="@Model.Login"
|
||||||
maxlength="10"
|
minlength="3"
|
||||||
required>
|
maxlength="10"
|
||||||
</div>
|
required>
|
||||||
<div>
|
</div>
|
||||||
<label for="login">Password:</label>
|
<div>
|
||||||
<input type="password" id="password" name="password" value="@Model.Login"
|
<label for="login">Password:</label>
|
||||||
minlength="3"
|
<input type="password" id="password" name="password" value="@Model.Password"
|
||||||
required>
|
minlength="3"
|
||||||
</div>
|
required>
|
||||||
<div>
|
</div>
|
||||||
<button type="submit">
|
<div>
|
||||||
<span>Create</span>
|
<button type="submit">
|
||||||
</button>
|
<span>Create</span>
|
||||||
</div>
|
</button>
|
||||||
@foreach (var error in Model.Errors)
|
</div>
|
||||||
{
|
@foreach (var error in Model.Errors)
|
||||||
<div class="alert-caution">error</div>
|
{
|
||||||
}
|
<div class="alert alert-caution">
|
||||||
</form>
|
<p>@error</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1,2 +1,86 @@
|
||||||
@model TModel
|
@using FastBlog.Web.Middlewares
|
||||||
|
@model FastBlog.Web.Controllers.UsersController.UserViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = $"{Model.User.Username} - Профиль";
|
||||||
|
bool isSelf = Context.TryGetUser(out var user) && user.Id == Model.User.Id;
|
||||||
|
}
|
||||||
|
<div class="container">
|
||||||
|
<div class="grid">
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<h2>@Model.User.Username</h2>
|
||||||
|
</header>
|
||||||
|
<div class="grid">
|
||||||
|
@if (isSelf)
|
||||||
|
{
|
||||||
|
<p>This is your profile, @Model.User.Username</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
<p>Now editing @Model.User.Username's profile</p>
|
||||||
|
@if (!Model.User.Deleted)
|
||||||
|
{
|
||||||
|
<button hx-delete="/users/@Model.User.Id">Delete profile</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
@if (isSelf)
|
||||||
|
{
|
||||||
|
<article>
|
||||||
|
<header>Password</header>
|
||||||
|
<div>
|
||||||
|
<form action="~/users/@Model.User.Id/change-password" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="oldPassword">Old password:</label>
|
||||||
|
<input type="password" id="oldPassword" name="oldPassword" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="newPassword">New password:</label>
|
||||||
|
<input type="password" id="newPassword" name="newPassword" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">
|
||||||
|
<span>Update</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@if (Model.Message is not null)
|
||||||
|
{
|
||||||
|
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
|
||||||
|
<p>@Model.Message</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<article>
|
||||||
|
<header>Password</header>
|
||||||
|
<div>
|
||||||
|
<form action="~/users/@Model.User.Id/set-password" method="post">
|
||||||
|
<div>
|
||||||
|
<label for="newPassword">New password:</label>
|
||||||
|
<input type="password" id="newPassword" name="newPassword" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="submit">
|
||||||
|
<span>Set</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
@if (Model.Message is not null)
|
||||||
|
{
|
||||||
|
<div class="alert @(Model.Success ? "alert-tip" : "alert-caution")">
|
||||||
|
<p>@Model.Message</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
@ -93,6 +93,23 @@ body {
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body>header {
|
||||||
|
margin: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-admin {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
background: #0a121b;
|
||||||
|
border-bottom: solid 1px rgba(255, 255, 255, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-admin li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px
|
||||||
|
}
|
||||||
|
|
||||||
.nav-container {
|
.nav-container {
|
||||||
margin: 0 20px 20px;
|
margin: 0 20px 20px;
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue