diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index 303af7a..abd2b80 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -1,10 +1,11 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using VoidCat.Model; namespace VoidCat.Controllers.Admin; [Route("admin")] -[Authorize(Policy = "Admin")] +[Authorize(Policy = Policies.RequireAdmin)] public class AdminController : Controller { diff --git a/VoidCat/Controllers/AuthController.cs b/VoidCat/Controllers/AuthController.cs new file mode 100644 index 0000000..4924a18 --- /dev/null +++ b/VoidCat/Controllers/AuthController.cs @@ -0,0 +1,77 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Controllers; + +[Route("auth")] +public class AuthController : Controller +{ + private readonly IUserManager _manager; + private readonly VoidSettings _settings; + + public AuthController(IUserManager userStore, VoidSettings settings) + { + _manager = userStore; + _settings = settings; + } + + [HttpPost] + [Route("login")] + public async Task Login([FromBody] LoginRequest req) + { + try + { + var user = await _manager.Login(req.Username, req.Password); + var token = CreateToken(user); + var tokenWriter = new JwtSecurityTokenHandler(); + return new(tokenWriter.WriteToken(token), null); + } + catch (Exception ex) + { + return new(null, ex.Message); + } + } + + [HttpPost] + [Route("register")] + public async Task Register([FromBody] LoginRequest req) + { + try + { + var newUser = await _manager.Register(req.Username, req.Password); + var token = CreateToken(newUser); + var tokenWriter = new JwtSecurityTokenHandler(); + return new(tokenWriter.WriteToken(token), null); + } + catch (Exception ex) + { + return new(null, ex.Message); + } + } + + private JwtSecurityToken CreateToken(VoidUser user) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new Claim[] + { + new(ClaimTypes.Sid, user.Id.ToString()), + new(ClaimTypes.Expiration, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()), + new(ClaimTypes.AuthorizationDecision, string.Join(",", user.Roles)) + }; + + return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, expires: DateTime.UtcNow.AddHours(6), + signingCredentials: credentials); + } + + + public record LoginRequest(string Username, string Password); + + public record LoginResponse(string? Jwt, string? Error = null); +} diff --git a/VoidCat/Model/Bandwidth.cs b/VoidCat/Model/Bandwidth.cs new file mode 100644 index 0000000..d700f98 --- /dev/null +++ b/VoidCat/Model/Bandwidth.cs @@ -0,0 +1,3 @@ +namespace VoidCat.Model; + +public sealed record Bandwidth(ulong Ingress, ulong Egress); \ No newline at end of file diff --git a/VoidCat/Model/EgressRequest.cs b/VoidCat/Model/EgressRequest.cs new file mode 100644 index 0000000..1f1bbb4 --- /dev/null +++ b/VoidCat/Model/EgressRequest.cs @@ -0,0 +1,7 @@ +using VoidCat.Services.Abstractions; + +namespace VoidCat.Model; + +public sealed record EgressRequest(Guid Id, IEnumerable Ranges) +{ +} \ No newline at end of file diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index fd1c5d4..e8c607e 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -1,3 +1,6 @@ +using System.Security.Cryptography; +using System.Text; + namespace VoidCat.Model; public static class Extensions @@ -18,6 +21,135 @@ public static class Extensions { var h = headers .FirstOrDefault(a => a.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)); + return !string.IsNullOrEmpty(h.Value.ToString()) ? h.Value.ToString() : default; } -} \ No newline at end of file + + public static string ToHex(this byte[] data) + { + return BitConverter.ToString(data).Replace("-", string.Empty).ToLower(); + } + + private static int HexToInt(char c) + { + switch (c) + { + case '0': + return 0; + case '1': + return 1; + case '2': + return 2; + case '3': + return 3; + case '4': + return 4; + case '5': + return 5; + case '6': + return 6; + case '7': + return 7; + case '8': + return 8; + case '9': + return 9; + case 'a': + case 'A': + return 10; + case 'b': + case 'B': + return 11; + case 'c': + case 'C': + return 12; + case 'd': + case 'D': + return 13; + case 'e': + case 'E': + return 14; + case 'f': + case 'F': + return 15; + default: + throw new FormatException("Unrecognized hex char " + c); + } + } + + private static readonly byte[,] ByteLookup = new byte[,] + { + // low nibble + { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f + }, + // high nibble + { + 0x00, 0x10, 0x20, 0x30, 0x40, 0x50, 0x60, 0x70, + 0x80, 0x90, 0xa0, 0xb0, 0xc0, 0xd0, 0xe0, 0xf0 + } + }; + + private static byte[] FromHex(this string input) + { + var result = new byte[(input.Length + 1) >> 1]; + var lastCell = result.Length - 1; + var lastChar = input.Length - 1; + for (var i = 0; i < input.Length; i++) + { + result[lastCell - (i >> 1)] |= ByteLookup[i & 1, HexToInt(input[lastChar - i])]; + } + + return result; + } + + public static string HashPassword(this string password) + { + return password.HashPassword("pbkdf2"); + } + + public static string HashPassword(this string password, string algo, string? saltHex = null) + { + var bytes = Encoding.UTF8.GetBytes(password); + switch (algo) + { + case "sha256": + { + var hash = SHA256.Create().ComputeHash(bytes); + return $"sha256:${hash.ToHex()}"; + } + case "sha512": + { + var hash = SHA512.Create().ComputeHash(bytes); + return $"sha512:${hash.ToHex()}"; + } + case "pbkdf2": + { + const int saltSize = 32; + const int iterations = 310_000; + + var salt = new byte[saltSize]; + if (saltHex == default) + { + RandomNumberGenerator.Fill(salt); + } + else + { + salt = saltHex.FromHex(); + } + + var pbkdf2 = new Rfc2898DeriveBytes(bytes, salt, iterations); + return $"pbkdf2:{salt.ToHex()}:${pbkdf2.GetBytes(salt.Length).ToHex()}"; + } + } + + throw new ArgumentException("Unknown algo", nameof(algo)); + } + + public static bool CheckPassword(this VoidUser vu, string password) + { + var hashParts = vu.PasswordHash.Split(":"); + return vu.PasswordHash == password.HashPassword(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null); + } +} diff --git a/VoidCat/Model/IngressPayload.cs b/VoidCat/Model/IngressPayload.cs new file mode 100644 index 0000000..081254d --- /dev/null +++ b/VoidCat/Model/IngressPayload.cs @@ -0,0 +1,9 @@ +namespace VoidCat.Model; + +public sealed record IngressPayload(Stream InStream, VoidFileMeta Meta, string Hash) +{ + public Guid? Id { get; init; } + public Guid? EditSecret { get; init; } + + public bool IsAppend => Id.HasValue && EditSecret.HasValue; +} \ No newline at end of file diff --git a/VoidCat/Model/Roles.cs b/VoidCat/Model/Roles.cs new file mode 100644 index 0000000..de15645 --- /dev/null +++ b/VoidCat/Model/Roles.cs @@ -0,0 +1,12 @@ +namespace VoidCat.Model; + +public static class Roles +{ + public const string User = "User"; + public const string Admin = "Admin"; +} + +public static class Policies +{ + public const string RequireAdmin = "RequireAdmin"; +} \ No newline at end of file diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/VoidUser.cs new file mode 100644 index 0000000..e549612 --- /dev/null +++ b/VoidCat/Model/VoidUser.cs @@ -0,0 +1,6 @@ +namespace VoidCat.Model; + +public sealed record VoidUser(Guid Id, string Email, string PasswordHash) +{ + public IEnumerable Roles { get; init; } = Enumerable.Empty(); +} diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index 41a96eb..b7e5b89 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -11,6 +11,8 @@ using VoidCat.Services.InMemory; using VoidCat.Services.Migrations; using VoidCat.Services.Paywall; using VoidCat.Services.Redis; +using VoidCat.Services.Stats; +using VoidCat.Services.Users; var builder = WebApplication.CreateBuilder(args); var services = builder.Services; @@ -38,6 +40,7 @@ services.AddControllers().AddNewtonsoftJson((opt) => opt.SerializerSettings.ConstructorHandling = ConstructorHandling.AllowNonPublicDefaultConstructor; opt.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Ignore; }); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -52,6 +55,14 @@ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }; }); +services.AddAuthorization((opt) => +{ + opt.AddPolicy(Policies.RequireAdmin, (auth) => + { + auth.RequireRole(Roles.Admin); + }); +}); + // void.cat services // services.AddVoidMigrations(); @@ -67,20 +78,24 @@ services.AddTransient(); // paywall services.AddVoidPaywall(); +// users +services.AddTransient(); +services.AddTransient(); + if (useRedis) { + services.AddTransient(); services.AddTransient(); services.AddTransient(svc => svc.GetRequiredService()); services.AddTransient(svc => svc.GetRequiredService()); - services.AddTransient(); } else { services.AddMemoryCache(); + services.AddTransient(); services.AddTransient(); services.AddTransient(svc => svc.GetRequiredService()); services.AddTransient(svc => svc.GetRequiredService()); - services.AddTransient(); } var app = builder.Build(); @@ -103,4 +118,4 @@ app.UseEndpoints(ep => ep.MapFallbackToFile("index.html"); }); -app.Run(); \ No newline at end of file +app.Run(); diff --git a/VoidCat/Services/Abstractions/IAggregateStatsCollector.cs b/VoidCat/Services/Abstractions/IAggregateStatsCollector.cs new file mode 100644 index 0000000..0f6d987 --- /dev/null +++ b/VoidCat/Services/Abstractions/IAggregateStatsCollector.cs @@ -0,0 +1,5 @@ +namespace VoidCat.Services.Abstractions; + +public interface IAggregateStatsCollector : IStatsCollector +{ +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/ICache.cs b/VoidCat/Services/Abstractions/ICache.cs new file mode 100644 index 0000000..2bac6d6 --- /dev/null +++ b/VoidCat/Services/Abstractions/ICache.cs @@ -0,0 +1,10 @@ +namespace VoidCat.Services.Abstractions; + +public interface ICache +{ + ValueTask Get(string key); + ValueTask Set(string key, T value, TimeSpan? expire = null); + + ValueTask GetList(string key); + ValueTask AddToList(string key, string value); +} diff --git a/VoidCat/Services/Abstractions/IFileStore.cs b/VoidCat/Services/Abstractions/IFileStore.cs index 09068cf..e6eccea 100644 --- a/VoidCat/Services/Abstractions/IFileStore.cs +++ b/VoidCat/Services/Abstractions/IFileStore.cs @@ -11,34 +11,4 @@ public interface IFileStore ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts); IAsyncEnumerable ListFiles(); -} - -public sealed record IngressPayload(Stream InStream, VoidFileMeta Meta, string Hash) -{ - public Guid? Id { get; init; } - public Guid? EditSecret { get; init; } - - public bool IsAppend => Id.HasValue && EditSecret.HasValue; -} - -public sealed record EgressRequest(Guid Id, IEnumerable Ranges) -{ -} - -public sealed record RangeRequest(long? TotalSize, long? Start, long? End) -{ - private const long DefaultBufferSize = 1024L * 512L; - - public long? Size - => Start.HasValue ? (End ?? Math.Min(TotalSize!.Value, Start.Value + DefaultBufferSize)) - Start.Value : End; - - public bool IsForFullFile - => Start is 0 && !End.HasValue; - - /// - /// Return Content-Range header content for this range - /// - /// - public string ToContentRange() - => $"bytes {Start}-{End ?? (Start + Size - 1L)}/{TotalSize?.ToString() ?? "*"}"; } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallFactory.cs b/VoidCat/Services/Abstractions/IPaywallFactory.cs index fa34c0f..fcfe9a1 100644 --- a/VoidCat/Services/Abstractions/IPaywallFactory.cs +++ b/VoidCat/Services/Abstractions/IPaywallFactory.cs @@ -1,4 +1,3 @@ -using VoidCat.Model; using VoidCat.Model.Paywall; namespace VoidCat.Services.Abstractions; @@ -6,20 +5,4 @@ namespace VoidCat.Services.Abstractions; public interface IPaywallFactory { ValueTask CreateProvider(PaywallServices svc); -} - -public interface IPaywallProvider -{ - ValueTask CreateOrder(PublicVoidFile file); - - ValueTask GetOrderStatus(Guid id); -} - -public interface IPaywallStore -{ - ValueTask GetOrder(Guid id); - ValueTask SaveOrder(PaywallOrder order); - - ValueTask GetConfig(Guid id); - ValueTask SetConfig(Guid id, PaywallConfig config); -} +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallProvider.cs b/VoidCat/Services/Abstractions/IPaywallProvider.cs new file mode 100644 index 0000000..5fc3c69 --- /dev/null +++ b/VoidCat/Services/Abstractions/IPaywallProvider.cs @@ -0,0 +1,11 @@ +using VoidCat.Model; +using VoidCat.Model.Paywall; + +namespace VoidCat.Services.Abstractions; + +public interface IPaywallProvider +{ + ValueTask CreateOrder(PublicVoidFile file); + + ValueTask GetOrderStatus(Guid id); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallStore.cs b/VoidCat/Services/Abstractions/IPaywallStore.cs new file mode 100644 index 0000000..ed9eddc --- /dev/null +++ b/VoidCat/Services/Abstractions/IPaywallStore.cs @@ -0,0 +1,12 @@ +using VoidCat.Model.Paywall; + +namespace VoidCat.Services.Abstractions; + +public interface IPaywallStore +{ + ValueTask GetOrder(Guid id); + ValueTask SaveOrder(PaywallOrder order); + + ValueTask GetConfig(Guid id); + ValueTask SetConfig(Guid id, PaywallConfig config); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IStatsCollector.cs b/VoidCat/Services/Abstractions/IStatsCollector.cs index 9a74ed8..5440466 100644 --- a/VoidCat/Services/Abstractions/IStatsCollector.cs +++ b/VoidCat/Services/Abstractions/IStatsCollector.cs @@ -1,19 +1,7 @@ namespace VoidCat.Services.Abstractions; -public interface IAggregateStatsCollector : IStatsCollector -{ -} - public interface IStatsCollector { ValueTask TrackIngress(Guid id, ulong amount); ValueTask TrackEgress(Guid id, ulong amount); -} - -public interface IStatsReporter -{ - ValueTask GetBandwidth(); - ValueTask GetBandwidth(Guid id); -} - -public sealed record Bandwidth(ulong Ingress, ulong Egress); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IStatsReporter.cs b/VoidCat/Services/Abstractions/IStatsReporter.cs new file mode 100644 index 0000000..470f8c7 --- /dev/null +++ b/VoidCat/Services/Abstractions/IStatsReporter.cs @@ -0,0 +1,9 @@ +using VoidCat.Model; + +namespace VoidCat.Services.Abstractions; + +public interface IStatsReporter +{ + ValueTask GetBandwidth(); + ValueTask GetBandwidth(Guid id); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserManager.cs b/VoidCat/Services/Abstractions/IUserManager.cs index 7c41a62..6303be0 100644 --- a/VoidCat/Services/Abstractions/IUserManager.cs +++ b/VoidCat/Services/Abstractions/IUserManager.cs @@ -1,10 +1,9 @@ -namespace VoidCat.Services.Abstractions; +using VoidCat.Model; + +namespace VoidCat.Services.Abstractions; public interface IUserManager { - ValueTask Get(string email, string password); - ValueTask Get(Guid id); - ValueTask Set(VoidUser user); + ValueTask Login(string username, string password); + ValueTask Register(string username, string password); } - -public sealed record VoidUser(Guid Id, string Email, string PasswordHash); \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserStore.cs b/VoidCat/Services/Abstractions/IUserStore.cs new file mode 100644 index 0000000..dfe91de --- /dev/null +++ b/VoidCat/Services/Abstractions/IUserStore.cs @@ -0,0 +1,11 @@ +using VoidCat.Model; + +namespace VoidCat.Services.Abstractions; + +public interface IUserStore +{ + ValueTask LookupUser(string email); + ValueTask Get(Guid id); + ValueTask Set(VoidUser user); + IAsyncEnumerable ListUsers(CancellationToken cts); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/RangeRequest.cs b/VoidCat/Services/Abstractions/RangeRequest.cs new file mode 100644 index 0000000..1938130 --- /dev/null +++ b/VoidCat/Services/Abstractions/RangeRequest.cs @@ -0,0 +1,19 @@ +namespace VoidCat.Services.Abstractions; + +public sealed record RangeRequest(long? TotalSize, long? Start, long? End) +{ + private const long DefaultBufferSize = 1024L * 512L; + + public long? Size + => Start.HasValue ? (End ?? Math.Min(TotalSize!.Value, Start.Value + DefaultBufferSize)) - Start.Value : End; + + public bool IsForFullFile + => Start is 0 && !End.HasValue; + + /// + /// Return Content-Range header content for this range + /// + /// + public string ToContentRange() + => $"bytes {Start}-{End ?? (Start + Size - 1L)}/{TotalSize?.ToString() ?? "*"}"; +} \ No newline at end of file diff --git a/VoidCat/Services/InMemory/InMemoryCache.cs b/VoidCat/Services/InMemory/InMemoryCache.cs new file mode 100644 index 0000000..175a19e --- /dev/null +++ b/VoidCat/Services/InMemory/InMemoryCache.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.Caching.Memory; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.InMemory; + +public class InMemoryCache : ICache +{ + private readonly IMemoryCache _cache; + + public InMemoryCache(IMemoryCache cache) + { + _cache = cache; + } + + public ValueTask Get(string key) + { + return ValueTask.FromResult(_cache.Get(key)); + } + + public ValueTask Set(string key, T value, TimeSpan? expire = null) + { + if (expire.HasValue) + { + _cache.Set(key, value, expire.Value); + } + else + { + _cache.Set(key, value); + } + + return ValueTask.CompletedTask; + } + + public ValueTask GetList(string key) + { + return ValueTask.FromResult(_cache.Get(key)); + } + + public ValueTask AddToList(string key, string value) + { + var list = new HashSet(GetList(key).Result); + list.Add(value); + _cache.Set(key, list.ToArray()); + return ValueTask.CompletedTask; + } +} diff --git a/VoidCat/Services/InMemory/InMemoryPaywallStore.cs b/VoidCat/Services/InMemory/InMemoryPaywallStore.cs deleted file mode 100644 index bb21e30..0000000 --- a/VoidCat/Services/InMemory/InMemoryPaywallStore.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using VoidCat.Model.Paywall; -using VoidCat.Services.Abstractions; - -namespace VoidCat.Services.InMemory; - -public class InMemoryPaywallStore : IPaywallStore -{ - private readonly IMemoryCache _cache; - - public InMemoryPaywallStore(IMemoryCache cache) - { - _cache = cache; - } - - public ValueTask GetConfig(Guid id) - { - return ValueTask.FromResult(_cache.Get(id) as PaywallConfig); - } - - public ValueTask SetConfig(Guid id, PaywallConfig config) - { - _cache.Set(id, config); - return ValueTask.CompletedTask; - } - - public ValueTask GetOrder(Guid id) - { - return ValueTask.FromResult(_cache.Get(id) as PaywallOrder); - } - - public ValueTask SaveOrder(PaywallOrder order) - { - _cache.Set(order.Id, order, - order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5)); - return ValueTask.CompletedTask; - } -} \ No newline at end of file diff --git a/VoidCat/Services/InMemory/InMemoryStatsController.cs b/VoidCat/Services/InMemory/InMemoryStatsController.cs index 7fd1042..0a61b90 100644 --- a/VoidCat/Services/InMemory/InMemoryStatsController.cs +++ b/VoidCat/Services/InMemory/InMemoryStatsController.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Caching.Memory; +using VoidCat.Model; using VoidCat.Services.Abstractions; namespace VoidCat.Services.InMemory; diff --git a/VoidCat/Services/Paywall/PaywallFactory.cs b/VoidCat/Services/Paywall/PaywallFactory.cs index 7995e9a..b038bcc 100644 --- a/VoidCat/Services/Paywall/PaywallFactory.cs +++ b/VoidCat/Services/Paywall/PaywallFactory.cs @@ -27,6 +27,7 @@ public static class Paywall public static IServiceCollection AddVoidPaywall(this IServiceCollection services) { services.AddTransient(); + services.AddTransient(); // strike services.AddTransient(); diff --git a/VoidCat/Services/Paywall/PaywallStore.cs b/VoidCat/Services/Paywall/PaywallStore.cs new file mode 100644 index 0000000..b9627a2 --- /dev/null +++ b/VoidCat/Services/Paywall/PaywallStore.cs @@ -0,0 +1,44 @@ +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +public class PaywallStore : IPaywallStore +{ + private readonly ICache _cache; + + public PaywallStore(ICache database) + { + _cache = database; + } + + public async ValueTask GetConfig(Guid id) + { + var cfg = await _cache.Get(ConfigKey(id)); + return cfg?.Service switch + { + PaywallServices.None => await _cache.Get(ConfigKey(id)), + PaywallServices.Strike => await _cache.Get(ConfigKey(id)), + _ => default + }; + } + + public async ValueTask SetConfig(Guid id, PaywallConfig config) + { + await _cache.Set(ConfigKey(id), config); + } + + public async ValueTask GetOrder(Guid id) + { + return await _cache.Get(OrderKey(id)); + } + + public async ValueTask SaveOrder(PaywallOrder order) + { + await _cache.Set(OrderKey(order.Id), order, + order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5)); + } + + private string ConfigKey(Guid id) => $"paywall:config:{id}"; + private string OrderKey(Guid id) => $"paywall:order:{id}"; +} diff --git a/VoidCat/Services/Redis/RedisCache.cs b/VoidCat/Services/Redis/RedisCache.cs new file mode 100644 index 0000000..3907e38 --- /dev/null +++ b/VoidCat/Services/Redis/RedisCache.cs @@ -0,0 +1,37 @@ +using Newtonsoft.Json; +using StackExchange.Redis; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Redis; + +public class RedisCache : ICache +{ + private readonly IDatabase _db; + + public RedisCache(IDatabase db) + { + _db = db; + } + + public async ValueTask Get(string key) + { + var json = await _db.StringGetAsync(key); + return json.HasValue ? JsonConvert.DeserializeObject(json) : default; + } + + public async ValueTask Set(string key, T value, TimeSpan? expire = null) + { + var json = JsonConvert.SerializeObject(value); + await _db.StringSetAsync(key, json, expire); + } + + public async ValueTask GetList(string key) + { + return (await _db.SetMembersAsync(key)).ToStringArray(); + } + + public async ValueTask AddToList(string key, string value) + { + await _db.SetAddAsync(key, value); + } +} diff --git a/VoidCat/Services/Redis/RedisPaywallStore.cs b/VoidCat/Services/Redis/RedisPaywallStore.cs deleted file mode 100644 index 3a986da..0000000 --- a/VoidCat/Services/Redis/RedisPaywallStore.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Newtonsoft.Json; -using StackExchange.Redis; -using VoidCat.Model.Paywall; -using VoidCat.Services.Abstractions; - -namespace VoidCat.Services.Redis; - -public class RedisPaywallStore : IPaywallStore -{ - private readonly IDatabase _database; - - public RedisPaywallStore(IDatabase database) - { - _database = database; - } - - public async ValueTask GetConfig(Guid id) - { - var json = await _database.StringGetAsync(ConfigKey(id)); - var cfg = json.HasValue ? JsonConvert.DeserializeObject(json) : default; - return cfg?.Service switch - { - PaywallServices.None => JsonConvert.DeserializeObject(json), - PaywallServices.Strike => JsonConvert.DeserializeObject(json), - _ => default - }; - } - - public async ValueTask SetConfig(Guid id, PaywallConfig config) - { - await _database.StringSetAsync(ConfigKey(id), JsonConvert.SerializeObject(config)); - } - - public async ValueTask GetOrder(Guid id) - { - var json = await _database.StringGetAsync(OrderKey(id)); - return json.HasValue ? JsonConvert.DeserializeObject(json) : default; - } - - public async ValueTask SaveOrder(PaywallOrder order) - { - await _database.StringSetAsync(OrderKey(order.Id), JsonConvert.SerializeObject(order), - order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5)); - } - - private RedisKey ConfigKey(Guid id) => $"paywall:config:{id}"; - private RedisKey OrderKey(Guid id) => $"paywall:order:{id}"; - - internal class PaywallBlank - { - public PaywallServices Service { get; init; } - } -} \ No newline at end of file diff --git a/VoidCat/Services/Redis/RedisStatsController.cs b/VoidCat/Services/Redis/RedisStatsController.cs index a13b106..cff830b 100644 --- a/VoidCat/Services/Redis/RedisStatsController.cs +++ b/VoidCat/Services/Redis/RedisStatsController.cs @@ -1,4 +1,5 @@ using StackExchange.Redis; +using VoidCat.Model; using VoidCat.Services.Abstractions; namespace VoidCat.Services.Redis; diff --git a/VoidCat/Services/AggregateStatsCollector.cs b/VoidCat/Services/Stats/AggregateStatsCollector.cs similarity index 95% rename from VoidCat/Services/AggregateStatsCollector.cs rename to VoidCat/Services/Stats/AggregateStatsCollector.cs index b00d305..bfdebd2 100644 --- a/VoidCat/Services/AggregateStatsCollector.cs +++ b/VoidCat/Services/Stats/AggregateStatsCollector.cs @@ -1,6 +1,6 @@ using VoidCat.Services.Abstractions; -namespace VoidCat.Services; +namespace VoidCat.Services.Stats; public class AggregateStatsCollector : IAggregateStatsCollector { diff --git a/VoidCat/Services/PrometheusStatsCollector.cs b/VoidCat/Services/Stats/PrometheusStatsCollector.cs similarity index 95% rename from VoidCat/Services/PrometheusStatsCollector.cs rename to VoidCat/Services/Stats/PrometheusStatsCollector.cs index 0ec9a62..444e376 100644 --- a/VoidCat/Services/PrometheusStatsCollector.cs +++ b/VoidCat/Services/Stats/PrometheusStatsCollector.cs @@ -1,7 +1,7 @@ using Prometheus; using VoidCat.Services.Abstractions; -namespace VoidCat.Services; +namespace VoidCat.Services.Stats; public class PrometheusStatsCollector : IStatsCollector { diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs new file mode 100644 index 0000000..14f2a39 --- /dev/null +++ b/VoidCat/Services/Users/UserManager.cs @@ -0,0 +1,35 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users; + +public class UserManager : IUserManager +{ + private readonly IUserStore _store; + + public UserManager(IUserStore store) + { + _store = store; + } + + public async ValueTask Login(string email, string password) + { + var userId = await _store.LookupUser(email); + if (!userId.HasValue) throw new InvalidOperationException("User does not exist"); + + var user = await _store.Get(userId.Value); + if (!(user?.CheckPassword(password) ?? false)) throw new InvalidOperationException("User does not exist"); + + return user; + } + + public async ValueTask Register(string email, string password) + { + var existingUser = await _store.LookupUser(email); + if (existingUser != default) throw new InvalidOperationException("User already exists"); + + var newUser = new VoidUser(Guid.NewGuid(), email, password.HashPassword()); + await _store.Set(newUser); + return newUser; + } +} diff --git a/VoidCat/Services/Users/UserStore.cs b/VoidCat/Services/Users/UserStore.cs new file mode 100644 index 0000000..3054569 --- /dev/null +++ b/VoidCat/Services/Users/UserStore.cs @@ -0,0 +1,55 @@ +using System.Runtime.CompilerServices; +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users; + +public class UserStore : IUserStore +{ + private const string UserList = "users"; + private readonly ICache _cache; + + public UserStore(ICache cache) + { + _cache = cache; + } + + public async ValueTask LookupUser(string email) + { + return await _cache.Get(MapKey(email)); + } + + public async ValueTask Get(Guid id) + { + return await _cache.Get(MapKey(id)); + } + + public async ValueTask Set(VoidUser user) + { + await _cache.Set(MapKey(user.Id), user); + await _cache.AddToList(UserList, user.Id.ToString()); + await _cache.Set(MapKey(user.Email), user.Id.ToString()); + } + + public async IAsyncEnumerable ListUsers([EnumeratorCancellation] CancellationToken cts = default) + { + var users = (await _cache.GetList(UserList))?.Select(Guid.Parse); + if (users != default) + { + while (!cts.IsCancellationRequested) + { + var loadUsers = await Task.WhenAll(users.Select(async a => await Get(a))); + foreach (var user in loadUsers) + { + if (user != default) + { + yield return user; + } + } + } + } + } + + private static string MapKey(Guid id) => $"user:{id}"; + private static string MapKey(string email) => $"user:email:{email}"; +} diff --git a/VoidCat/spa/src/Login.js b/VoidCat/spa/src/Login.js index 0dc9c3e..7376335 100644 --- a/VoidCat/spa/src/Login.js +++ b/VoidCat/spa/src/Login.js @@ -10,7 +10,7 @@ export function Login(props) { async function login(e) { e.target.disabled = true; - let req = await fetch("/login", { + let req = await fetch("/auth/login", { method: "POST", body: JSON.stringify({ username, password