From 045399d1a2b66ca572a939c59aafd5ddb529c471 Mon Sep 17 00:00:00 2001 From: Kieran Date: Wed, 8 Jun 2022 17:17:53 +0100 Subject: [PATCH] Refactor stores --- VoidCat/Controllers/Admin/AdminController.cs | 15 +++- VoidCat/Controllers/UploadController.cs | 17 ++++- VoidCat/Controllers/UserController.cs | 2 +- VoidCat/Model/EmailVerificationCode.cs | 13 ++-- VoidCat/Model/Extensions.cs | 4 +- VoidCat/Model/VoidFileMeta.cs | 6 ++ VoidCat/Model/VoidUser.cs | 62 +++-------------- VoidCat/Pages/EmailCode.cshtml | 4 +- VoidCat/Program.cs | 11 +-- .../Services/Abstractions/IFileInfoManager.cs | 11 +++ .../Abstractions/IFileMetadataStore.cs | 37 ++++++++++ VoidCat/Services/Abstractions/IFileStore.cs | 24 ++++++- .../Abstractions/IPublicPrivateStore.cs | 32 ++++++++- VoidCat/Services/Abstractions/IUserStore.cs | 34 +++++++++ .../Background/VirusScannerService.cs | 9 +-- VoidCat/Services/Files/FileInfoManager.cs | 4 ++ VoidCat/Services/Files/FileStorageStartup.cs | 2 +- .../Files/LocalDiskFileMetadataStore.cs | 65 +++++++++++++---- .../Services/Files/LocalDiskFileStorage.cs | 58 +++------------- ...aStore.cs => PostgresFileMetadataStore.cs} | 54 ++++++++++++++- VoidCat/Services/Files/S3FileMetadataStore.cs | 59 +++++++++++----- VoidCat/Services/Files/S3FileStore.cs | 3 +- VoidCat/Services/Files/StreamFileStore.cs | 21 ++---- .../Migrations/FluentMigrationRunner.cs | 6 +- VoidCat/Services/Migrations/IMigration.cs | 21 +++++- .../Services/Migrations/MetadataMigrator.cs | 6 +- .../Services/Migrations/PopulateMetadataId.cs | 31 +++++++++ .../Migrations/UserLookupKeyHashMigration.cs | 6 +- ...rification.cs => BaseEmailVerification.cs} | 38 +++++----- .../Services/Users/CacheEmailVerification.cs | 36 ++++++++++ .../Users/{UserStore.cs => CacheUserStore.cs} | 43 +++++++++--- .../Users/PostgresEmailVerification.cs | 45 ++++++++++++ VoidCat/Services/Users/PostgresUserStore.cs | 69 ++++++++++++++++--- VoidCat/Services/Users/UserManager.cs | 17 +++-- VoidCat/Services/Users/UsersStartup.cs | 6 +- 35 files changed, 635 insertions(+), 236 deletions(-) rename VoidCat/Services/Files/{PostgreFileMetadataStore.cs => PostgresFileMetadataStore.cs} (59%) create mode 100644 VoidCat/Services/Migrations/PopulateMetadataId.cs rename VoidCat/Services/Users/{EmailVerification.cs => BaseEmailVerification.cs} (62%) create mode 100644 VoidCat/Services/Users/CacheEmailVerification.cs rename VoidCat/Services/Users/{UserStore.cs => CacheUserStore.cs} (78%) create mode 100644 VoidCat/Services/Users/PostgresEmailVerification.cs diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index d69726e..61dd484 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -10,14 +10,17 @@ namespace VoidCat.Controllers.Admin; public class AdminController : Controller { private readonly IFileStore _fileStore; + private readonly IFileMetadataStore _fileMetadata; private readonly IFileInfoManager _fileInfo; private readonly IUserStore _userStore; - public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo) + public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo, + IFileMetadataStore fileMetadata) { _fileStore = fileStore; _userStore = userStore; _fileInfo = fileInfo; + _fileMetadata = fileMetadata; } /// @@ -29,7 +32,15 @@ public class AdminController : Controller [Route("file")] public async Task> ListFiles([FromBody] PagedRequest request) { - return await (await _fileStore.ListFiles(request)).GetResults(); + var files = await _fileMetadata.ListFiles(request); + + return new() + { + Page = files.Page, + PageSize = files.PageSize, + TotalResults = files.TotalResults, + Results = (await files.Results.SelectAwait(a => _fileInfo.Get(a.Id)).ToListAsync())! + }; } /// diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 6edb1dc..398b0b3 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -17,15 +17,17 @@ namespace VoidCat.Controllers private readonly IPaywallStore _paywall; private readonly IPaywallFactory _paywallFactory; private readonly IFileInfoManager _fileInfo; + private readonly IUserUploadsStore _userUploads; public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall, - IPaywallFactory paywallFactory, IFileInfoManager fileInfo) + IPaywallFactory paywallFactory, IFileInfoManager fileInfo, IUserUploadsStore userUploads) { _storage = storage; _metadata = metadata; _paywall = paywall; _paywallFactory = paywallFactory; _fileInfo = fileInfo; + _userUploads = userUploads; } /// @@ -76,6 +78,15 @@ namespace VoidCat.Controllers Hash = digest }, HttpContext.RequestAborted); + // save metadata + await _metadata.Set(vf.Id, vf.Metadata!); + + // attach file upload to user + if (uid.HasValue) + { + await _userUploads.AddFile(uid!.Value, vf); + } + if (cli) { var urlBuilder = new UriBuilder(Request.IsHttps ? "https" : "http", Request.Host.Host, @@ -126,7 +137,9 @@ namespace VoidCat.Controllers Id = gid, IsAppend = true }, HttpContext.RequestAborted); - + + // update file size + await _metadata.Set(vf.Id, vf.Metadata!); return UploadResult.Success(vf); } catch (Exception ex) diff --git a/VoidCat/Controllers/UserController.cs b/VoidCat/Controllers/UserController.cs index 39fd734..2cff0db 100644 --- a/VoidCat/Controllers/UserController.cs +++ b/VoidCat/Controllers/UserController.cs @@ -134,7 +134,7 @@ public class UserController : Controller if (!await _emailVerification.VerifyCode(user, token)) return BadRequest(); user.Flags |= VoidUserFlags.EmailVerified; - await _store.Set(user.Id, user); + await _store.UpdateProfile(user.ToPublic()); return Accepted(); } diff --git a/VoidCat/Model/EmailVerificationCode.cs b/VoidCat/Model/EmailVerificationCode.cs index f8ee28c..767c415 100644 --- a/VoidCat/Model/EmailVerificationCode.cs +++ b/VoidCat/Model/EmailVerificationCode.cs @@ -1,8 +1,9 @@ namespace VoidCat.Model; -public class EmailVerificationCode -{ - public Guid Id { get; init; } = Guid.NewGuid(); - public Guid UserId { get; init; } - public DateTimeOffset Expires { get; init; } -} \ No newline at end of file +/// +/// Email verification token +/// +/// +/// +/// +public sealed record EmailVerificationCode(Guid User, Guid Code, DateTime Expires); \ No newline at end of file diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index 11f7bbf..b50a05e 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -206,7 +206,7 @@ public static class Extensions public static bool CheckPassword(this InternalVoidUser vu, string password) { - var hashParts = vu.PasswordHash.Split(":"); - return vu.PasswordHash == password.Hash(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null); + var hashParts = vu.Password.Split(":"); + return vu.Password == password.Hash(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null); } } \ No newline at end of file diff --git a/VoidCat/Model/VoidFileMeta.cs b/VoidCat/Model/VoidFileMeta.cs index eef18ba..808b3bd 100644 --- a/VoidCat/Model/VoidFileMeta.cs +++ b/VoidCat/Model/VoidFileMeta.cs @@ -25,6 +25,12 @@ public record VoidFileMeta : IVoidFileMeta /// public int Version { get; init; } = IVoidFileMeta.CurrentVersion; + /// + /// Internal Id of the file + /// + [JsonConverter(typeof(Base58GuidConverter))] + public Guid Id { get; set; } + /// /// Filename /// diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/VoidUser.cs index f824451..904789f 100644 --- a/VoidCat/Model/VoidUser.cs +++ b/VoidCat/Model/VoidUser.cs @@ -9,16 +9,11 @@ namespace VoidCat.Model; /// public abstract class VoidUser { - protected VoidUser(Guid id) - { - Id = id; - } - /// /// Unique Id of the user /// [JsonConverter(typeof(Base58GuidConverter))] - public Guid Id { get; } + public Guid Id { get; init; } /// /// Roles assigned to this user which grant them extra permissions @@ -56,12 +51,14 @@ public abstract class VoidUser /// public PublicVoidUser ToPublic() { - return new(Id) + return new() { + Id = Id, Roles = Roles, Created = Created, LastLogin = LastLogin, - Avatar = Avatar + Avatar = Avatar, + Flags = Flags }; } } @@ -71,24 +68,10 @@ public abstract class VoidUser /// public sealed class InternalVoidUser : PrivateVoidUser { - /// - public InternalVoidUser(Guid id, string email, string passwordHash) : base(id, email) - { - PasswordHash = passwordHash; - } - - /// - public InternalVoidUser(Guid Id, string Email, string Password, DateTime Created, DateTime LastLogin, - string Avatar, - string DisplayName, int Flags) : base(Id, Email) - { - PasswordHash = Password; - } - /// /// A password hash for the user in the format /// - public string PasswordHash { get; } + public string Password { get; init; } = null!; } /// @@ -96,44 +79,15 @@ public sealed class InternalVoidUser : PrivateVoidUser /// public class PrivateVoidUser : VoidUser { - /// - public PrivateVoidUser(Guid id, string email) : base(id) - { - Email = email; - } - /// - /// Full constructor for Dapper + /// Users email address /// - /// - /// - /// - /// - /// - /// - /// - /// - public PrivateVoidUser(Guid Id, String Email, string Password, DateTime Created, DateTime LastLogin, string Avatar, - string DisplayName, int Flags) : base(Id) - { - this.Email = Email; - this.Created = Created; - this.LastLogin = LastLogin; - this.Avatar = Avatar; - this.DisplayName = DisplayName; - this.Flags = (VoidUserFlags) Flags; - } - - public string Email { get; } + public string Email { get; init; } = null!; } /// public sealed class PublicVoidUser : VoidUser { - /// - public PublicVoidUser(Guid id) : base(id) - { - } } [Flags] diff --git a/VoidCat/Pages/EmailCode.cshtml b/VoidCat/Pages/EmailCode.cshtml index 1dcccd9..b6ca55d 100644 --- a/VoidCat/Pages/EmailCode.cshtml +++ b/VoidCat/Pages/EmailCode.cshtml @@ -1,4 +1,5 @@ @using VoidCat.Model +@using VoidCat.Services.Users @model VoidCat.Model.EmailVerificationCode @@ -30,7 +31,8 @@

void.cat

Your verification code is below please copy this to complete verification

-
@(Model?.Id.ToBase58() ?? "?????????????")
+
@(Model?.Code.ToBase58() ?? "?????????????")
+

This code will expire in @BaseEmailVerification.HoursExpire hours

\ No newline at end of file diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index def0b98..ae4c94a 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -131,6 +131,7 @@ services.AddAuthorization((opt) => // void.cat services // services.AddTransient(); +services.AddTransient(); // file storage services.AddStorage(voidSettings); @@ -167,8 +168,7 @@ if (!string.IsNullOrEmpty(voidSettings.Postgres)) .ConfigureRunner(r => r.AddPostgres11_0() .WithGlobalConnectionString(voidSettings.Postgres) - .ScanIn(typeof(Program).Assembly).For.Migrations()) - .AddLogging(l => l.AddFluentMigratorConsole()); + .ScanIn(typeof(Program).Assembly).For.Migrations()); } if (useRedis) @@ -196,10 +196,13 @@ var app = builder.Build(); using (var migrationScope = app.Services.CreateScope()) { var migrations = migrationScope.ServiceProvider.GetServices(); + var logger = migrationScope.ServiceProvider.GetRequiredService>(); foreach (var migration in migrations) { - await migration.Migrate(args); - if (migration.ExitOnComplete) + logger.LogInformation("Running migration: {Migration}", migration.GetType().Name); + var res = await migration.Migrate(args); + logger.LogInformation("== Result: {Result}", res.ToString()); + if (res == IMigration.MigrationResult.ExitCompleted) { return; } diff --git a/VoidCat/Services/Abstractions/IFileInfoManager.cs b/VoidCat/Services/Abstractions/IFileInfoManager.cs index 748e282..6b09f3d 100644 --- a/VoidCat/Services/Abstractions/IFileInfoManager.cs +++ b/VoidCat/Services/Abstractions/IFileInfoManager.cs @@ -8,7 +8,18 @@ namespace VoidCat.Services.Abstractions; ///
public interface IFileInfoManager { + /// + /// Get all metadata for a single file + /// + /// + /// ValueTask Get(Guid id); + + /// + /// Get all metadata for multiple files + /// + /// + /// ValueTask> Get(Guid[] ids); /// diff --git a/VoidCat/Services/Abstractions/IFileMetadataStore.cs b/VoidCat/Services/Abstractions/IFileMetadataStore.cs index fe9d3f1..a1ed3c7 100644 --- a/VoidCat/Services/Abstractions/IFileMetadataStore.cs +++ b/VoidCat/Services/Abstractions/IFileMetadataStore.cs @@ -2,11 +2,43 @@ using VoidCat.Model; namespace VoidCat.Services.Abstractions; +/// +/// File metadata contains all data about a file except for the file data itself +/// public interface IFileMetadataStore : IPublicPrivateStore { + /// + /// Get metadata for a single file + /// + /// + /// + /// ValueTask Get(Guid id) where TMeta : VoidFileMeta; + + /// + /// Get metadata for multiple files + /// + /// + /// + /// ValueTask> Get(Guid[] ids) where TMeta : VoidFileMeta; + + /// + /// Update file metadata + /// + /// + /// + /// + /// ValueTask Update(Guid id, TMeta meta) where TMeta : VoidFileMeta; + + /// + /// List all files in the store + /// + /// + /// + /// + ValueTask> ListFiles(PagedRequest request) where TMeta : VoidFileMeta; /// /// Returns basic stats about the file store @@ -14,5 +46,10 @@ public interface IFileMetadataStore : IPublicPrivateStore ValueTask Stats(); + /// + /// Simple stats of the current store + /// + /// + /// public sealed record StoreStats(long Files, ulong Size); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IFileStore.cs b/VoidCat/Services/Abstractions/IFileStore.cs index 2314764..4b1cdc1 100644 --- a/VoidCat/Services/Abstractions/IFileStore.cs +++ b/VoidCat/Services/Abstractions/IFileStore.cs @@ -2,14 +2,28 @@ namespace VoidCat.Services.Abstractions; +/// +/// File binary data store +/// public interface IFileStore { + /// + /// Ingress a file into the system (Upload) + /// + /// + /// + /// ValueTask Ingress(IngressPayload payload, CancellationToken cts); + /// + /// Egress a file from the system (Download) + /// + /// + /// + /// + /// ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts); - ValueTask> ListFiles(PagedRequest request); - /// /// Deletes file data only, metadata must be deleted with /// @@ -17,5 +31,11 @@ public interface IFileStore /// ValueTask DeleteFile(Guid id); + /// + /// Open a filestream for a file on the system + /// + /// + /// + /// ValueTask Open(EgressRequest request, CancellationToken cts); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPublicPrivateStore.cs b/VoidCat/Services/Abstractions/IPublicPrivateStore.cs index f2ca45f..939ffa9 100644 --- a/VoidCat/Services/Abstractions/IPublicPrivateStore.cs +++ b/VoidCat/Services/Abstractions/IPublicPrivateStore.cs @@ -1,10 +1,38 @@ namespace VoidCat.Services.Abstractions; -public interface IPublicPrivateStore +/// +/// Store interface where there is a public and private model +/// +/// +/// +public interface IPublicPrivateStore { + /// + /// Get the public model + /// + /// + /// ValueTask Get(Guid id); - + + /// + /// Get the private model (contains sensitive data) + /// + /// + /// + ValueTask GetPrivate(Guid id); + + /// + /// Set the private obj in the store + /// + /// + /// + /// ValueTask Set(Guid id, TPrivate obj); + /// + /// Delete the object from the store + /// + /// + /// ValueTask Delete(Guid id); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserStore.cs b/VoidCat/Services/Abstractions/IUserStore.cs index 08d96e9..736f575 100644 --- a/VoidCat/Services/Abstractions/IUserStore.cs +++ b/VoidCat/Services/Abstractions/IUserStore.cs @@ -2,11 +2,45 @@ namespace VoidCat.Services.Abstractions; +/// +/// User store +/// public interface IUserStore : IPublicPrivateStore { + /// + /// Get a single user + /// + /// + /// + /// ValueTask Get(Guid id) where T : VoidUser; + /// + /// Lookup a user by their email address + /// + /// + /// ValueTask LookupUser(string email); + + /// + /// List all users in the system + /// + /// + /// ValueTask> ListUsers(PagedRequest request); + + /// + /// Update a users profile + /// + /// + /// ValueTask UpdateProfile(PublicVoidUser newUser); + + /// + /// Updates the last login timestamp for the user + /// + /// + /// + /// + ValueTask UpdateLastLogin(Guid id, DateTime timestamp); } \ No newline at end of file diff --git a/VoidCat/Services/Background/VirusScannerService.cs b/VoidCat/Services/Background/VirusScannerService.cs index c36e58a..379f4c8 100644 --- a/VoidCat/Services/Background/VirusScannerService.cs +++ b/VoidCat/Services/Background/VirusScannerService.cs @@ -1,4 +1,5 @@ -using VoidCat.Services.Abstractions; +using VoidCat.Model; +using VoidCat.Services.Abstractions; using VoidCat.Services.VirusScanner.Exceptions; namespace VoidCat.Services.Background; @@ -7,11 +8,11 @@ public class VirusScannerService : BackgroundService { private readonly ILogger _logger; private readonly IVirusScanner _scanner; - private readonly IFileStore _fileStore; + private readonly IFileMetadataStore _fileStore; private readonly IVirusScanStore _scanStore; public VirusScannerService(ILogger logger, IVirusScanner scanner, IVirusScanStore scanStore, - IFileStore fileStore) + IFileMetadataStore fileStore) { _scanner = scanner; _logger = logger; @@ -28,7 +29,7 @@ public class VirusScannerService : BackgroundService var page = 0; while (true) { - var files = await _fileStore.ListFiles(new(page, 10)); + var files = await _fileStore.ListFiles(new(page, 10)); if (files.Pages < page) break; page++; diff --git a/VoidCat/Services/Files/FileInfoManager.cs b/VoidCat/Services/Files/FileInfoManager.cs index b448ca6..97f544b 100644 --- a/VoidCat/Services/Files/FileInfoManager.cs +++ b/VoidCat/Services/Files/FileInfoManager.cs @@ -3,6 +3,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// public class FileInfoManager : IFileInfoManager { private readonly IFileMetadataStore _metadataStore; @@ -21,6 +22,7 @@ public class FileInfoManager : IFileInfoManager _virusScanStore = virusScanStore; } + /// public async ValueTask Get(Guid id) { var meta = _metadataStore.Get(id); @@ -45,6 +47,7 @@ public class FileInfoManager : IFileInfoManager }; } + /// public async ValueTask> Get(Guid[] ids) { var ret = new List(); @@ -60,6 +63,7 @@ public class FileInfoManager : IFileInfoManager return ret; } + /// public async ValueTask Delete(Guid id) { await _metadataStore.Delete(id); diff --git a/VoidCat/Services/Files/FileStorageStartup.cs b/VoidCat/Services/Files/FileStorageStartup.cs index 85534fb..e1454a9 100644 --- a/VoidCat/Services/Files/FileStorageStartup.cs +++ b/VoidCat/Services/Files/FileStorageStartup.cs @@ -25,7 +25,7 @@ public static class FileStorageStartup { services.AddTransient(); services.AddTransient(); - services.AddTransient(); + services.AddTransient(); } else { diff --git a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs index bc0b3d5..a615344 100644 --- a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs +++ b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs @@ -4,6 +4,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// public class LocalDiskFileMetadataStore : IFileMetadataStore { private const string MetadataDir = "metadata-v3"; @@ -22,11 +23,13 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore } } + /// public ValueTask Get(Guid id) where TMeta : VoidFileMeta { return GetMeta(id); } + /// public async ValueTask> Get(Guid[] ids) where TMeta : VoidFileMeta { var ret = new List(); @@ -42,6 +45,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore return ret; } + /// public async ValueTask Update(Guid id, TMeta meta) where TMeta : VoidFileMeta { var oldMeta = await Get(id); @@ -54,37 +58,69 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore await Set(id, oldMeta); } - public async ValueTask Stats() + /// + public ValueTask> ListFiles(PagedRequest request) where TMeta : VoidFileMeta { - var count = 0; - var size = 0UL; - foreach (var metaFile in Directory.EnumerateFiles(Path.Join(_settings.DataDirectory, MetadataDir), "*.json")) + async IAsyncEnumerable EnumerateFiles() { - try + foreach (var metaFile in + Directory.EnumerateFiles(Path.Join(_settings.DataDirectory, MetadataDir), "*.json")) { var json = await File.ReadAllTextAsync(metaFile); - var meta = JsonConvert.DeserializeObject(json); - + var meta = JsonConvert.DeserializeObject(json); if (meta != null) { - count++; - size += meta.Size; + yield return meta with + { + // TODO: remove after migration decay + Id = Guid.Parse(Path.GetFileNameWithoutExtension(metaFile)) + }; } } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to load metadata file: {File}", metaFile); - } } - return new(count, size); + var results = EnumerateFiles(); + results = (request.SortBy, request.SortOrder) switch + { + (PagedSortBy.Name, PageSortOrder.Asc) => results.OrderBy(a => a.Name), + (PagedSortBy.Size, PageSortOrder.Asc) => results.OrderBy(a => a.Size), + (PagedSortBy.Date, PageSortOrder.Asc) => results.OrderBy(a => a.Uploaded), + (PagedSortBy.Name, PageSortOrder.Dsc) => results.OrderByDescending(a => a.Name), + (PagedSortBy.Size, PageSortOrder.Dsc) => results.OrderByDescending(a => a.Size), + (PagedSortBy.Date, PageSortOrder.Dsc) => results.OrderByDescending(a => a.Uploaded), + _ => results + }; + + return ValueTask.FromResult(new PagedResult + { + Page = request.Page, + PageSize = request.PageSize, + Results = results.Take(request.PageSize).Skip(request.Page * request.PageSize) + }); } + /// + public async ValueTask Stats() + { + var files = await ListFiles(new(0, Int32.MaxValue)); + var count = await files.Results.CountAsync(); + var size = await files.Results.SumAsync(a => (long) a.Size); + return new(count, (ulong) size); + } + + /// public ValueTask Get(Guid id) { return GetMeta(id); } + /// + public ValueTask GetPrivate(Guid id) + { + return GetMeta(id); + } + + /// public async ValueTask Set(Guid id, SecretVoidFileMeta meta) { var path = MapMeta(id); @@ -92,6 +128,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore await File.WriteAllTextAsync(path, json); } + /// public ValueTask Delete(Guid id) { var path = MapMeta(id); diff --git a/VoidCat/Services/Files/LocalDiskFileStorage.cs b/VoidCat/Services/Files/LocalDiskFileStorage.cs index c4329da..6e9aaea 100644 --- a/VoidCat/Services/Files/LocalDiskFileStorage.cs +++ b/VoidCat/Services/Files/LocalDiskFileStorage.cs @@ -4,21 +4,17 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// public class LocalDiskFileStore : StreamFileStore, IFileStore { private const string FilesDir = "files-v1"; private readonly ILogger _logger; private readonly VoidSettings _settings; - private readonly IFileMetadataStore _metadataStore; - private readonly IFileInfoManager _fileInfo; - public LocalDiskFileStore(ILogger logger, VoidSettings settings, IAggregateStatsCollector stats, - IFileMetadataStore metadataStore, IFileInfoManager fileInfo, IUserUploadsStore userUploads) - : base(stats, metadataStore, userUploads) + public LocalDiskFileStore(ILogger logger, VoidSettings settings, IAggregateStatsCollector stats) + : base(stats) { _settings = settings; - _metadataStore = metadataStore; - _fileInfo = fileInfo; _logger = logger; var dir = Path.Combine(_settings.DataDirectory, FilesDir); @@ -28,12 +24,14 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore } } + /// public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts) { await using var fs = await Open(request, cts); await EgressFromStream(fs, request, outStream, cts); } + /// public async ValueTask Ingress(IngressPayload payload, CancellationToken cts) { var fPath = MapPath(payload.Id); @@ -42,49 +40,7 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore return await IngressToStream(fsTemp, payload, cts); } - public ValueTask> ListFiles(PagedRequest request) - { - var files = Directory.EnumerateFiles(Path.Combine(_settings.DataDirectory, FilesDir)) - .Where(a => !Path.HasExtension(a)); - - files = (request.SortBy, request.SortOrder) switch - { - (PagedSortBy.Id, PageSortOrder.Asc) => files.OrderBy(a => - Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty), - (PagedSortBy.Id, PageSortOrder.Dsc) => files.OrderByDescending(a => - Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty), - (PagedSortBy.Name, PageSortOrder.Asc) => files.OrderBy(Path.GetFileNameWithoutExtension), - (PagedSortBy.Name, PageSortOrder.Dsc) => files.OrderByDescending(Path.GetFileNameWithoutExtension), - (PagedSortBy.Size, PageSortOrder.Asc) => files.OrderBy(a => new FileInfo(a).Length), - (PagedSortBy.Size, PageSortOrder.Dsc) => files.OrderByDescending(a => new FileInfo(a).Length), - (PagedSortBy.Date, PageSortOrder.Asc) => files.OrderBy(File.GetCreationTimeUtc), - (PagedSortBy.Date, PageSortOrder.Dsc) => files.OrderByDescending(File.GetCreationTimeUtc), - _ => files - }; - - async IAsyncEnumerable EnumeratePage(IEnumerable page) - { - foreach (var file in page) - { - if (!Guid.TryParse(Path.GetFileNameWithoutExtension(file), out var gid)) continue; - - var loaded = await _fileInfo.Get(gid); - if (loaded != default) - { - yield return loaded; - } - } - } - - return ValueTask.FromResult(new PagedResult() - { - Page = request.Page, - PageSize = request.PageSize, - TotalResults = files.Count(), - Results = EnumeratePage(files.Skip(request.PageSize * request.Page).Take(request.PageSize)) - }); - } - + /// public ValueTask DeleteFile(Guid id) { var fp = MapPath(id); @@ -93,9 +49,11 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore _logger.LogInformation("Deleting file: {Path}", fp); File.Delete(fp); } + return ValueTask.CompletedTask; } + /// public ValueTask Open(EgressRequest request, CancellationToken cts) { var path = MapPath(request.Id); diff --git a/VoidCat/Services/Files/PostgreFileMetadataStore.cs b/VoidCat/Services/Files/PostgresFileMetadataStore.cs similarity index 59% rename from VoidCat/Services/Files/PostgreFileMetadataStore.cs rename to VoidCat/Services/Files/PostgresFileMetadataStore.cs index 213c05a..c32dd92 100644 --- a/VoidCat/Services/Files/PostgreFileMetadataStore.cs +++ b/VoidCat/Services/Files/PostgresFileMetadataStore.cs @@ -5,20 +5,29 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; -public class PostgreFileMetadataStore : IFileMetadataStore +/// +public class PostgresFileMetadataStore : IFileMetadataStore { private readonly NpgsqlConnection _connection; - public PostgreFileMetadataStore(NpgsqlConnection connection) + public PostgresFileMetadataStore(NpgsqlConnection connection) { _connection = connection; } + /// public ValueTask Get(Guid id) { return Get(id); } + /// + public ValueTask GetPrivate(Guid id) + { + return Get(id); + } + + /// public async ValueTask Set(Guid id, SecretVoidFileMeta obj) { await _connection.ExecuteAsync( @@ -38,23 +47,27 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Description"" = :descript }); } + /// public async ValueTask Delete(Guid id) { await _connection.ExecuteAsync("delete from \"Files\" where \"Id\" = :id", new {id}); } + /// public async ValueTask Get(Guid id) where TMeta : VoidFileMeta { return await _connection.QuerySingleOrDefaultAsync(@"select * from ""Files"" where ""Id"" = :id", new {id}); } + /// public async ValueTask> Get(Guid[] ids) where TMeta : VoidFileMeta { var ret = await _connection.QueryAsync("select * from \"Files\" where \"Id\" in :ids", new {ids}); return ret.ToList(); } + /// public async ValueTask Update(Guid id, TMeta meta) where TMeta : VoidFileMeta { var oldMeta = await Get(id); @@ -67,6 +80,43 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Description"" = :descript await Set(id, oldMeta); } + /// + public async ValueTask> ListFiles(PagedRequest request) where TMeta : VoidFileMeta + { + var qInner = @"select {0} from ""Files"" order by ""{1}"" {2}"; + var orderBy = request.SortBy switch + { + PagedSortBy.Date => "Uploaded", + PagedSortBy.Name => "Name", + PagedSortBy.Size => "Size", + _ => "Id" + }; + var orderDirection = request.SortOrder == PageSortOrder.Asc ? "asc" : "desc"; + var count = await _connection.ExecuteScalarAsync(string.Format(qInner, "count(*)", orderBy, + orderDirection)); + + async IAsyncEnumerable Enumerate() + { + var results = await _connection.QueryAsync( + $"{string.Format(qInner, "*", orderBy, orderDirection)} offset @offset limit @limit", + new {offset = request.PageSize * request.Page, limit = request.PageSize}); + + foreach (var meta in results) + { + yield return meta; + } + } + + return new() + { + TotalResults = count, + PageSize = request.PageSize, + Page = request.Page, + Results = Enumerate() + }; + } + + /// public async ValueTask Stats() { var v = await _connection.QuerySingleAsync<(long Files, long Size)>( diff --git a/VoidCat/Services/Files/S3FileMetadataStore.cs b/VoidCat/Services/Files/S3FileMetadataStore.cs index cf3d8a1..860cff9 100644 --- a/VoidCat/Services/Files/S3FileMetadataStore.cs +++ b/VoidCat/Services/Files/S3FileMetadataStore.cs @@ -5,6 +5,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// public class S3FileMetadataStore : IFileMetadataStore { private readonly ILogger _logger; @@ -20,11 +21,13 @@ public class S3FileMetadataStore : IFileMetadataStore _client = _config.CreateClient(); } + /// public ValueTask Get(Guid id) where TMeta : VoidFileMeta { return GetMeta(id); } + /// public async ValueTask> Get(Guid[] ids) where TMeta : VoidFileMeta { var ret = new List(); @@ -40,6 +43,7 @@ public class S3FileMetadataStore : IFileMetadataStore return ret; } + /// public async ValueTask Update(Guid id, TMeta meta) where TMeta : VoidFileMeta { var oldMeta = await GetMeta(id); @@ -52,11 +56,10 @@ public class S3FileMetadataStore : IFileMetadataStore await Set(id, oldMeta); } - public async ValueTask Stats() + /// + public ValueTask> ListFiles(PagedRequest request) where TMeta : VoidFileMeta { - var count = 0; - var size = 0UL; - try + async IAsyncEnumerable Enumerate() { var obj = await _client.ListObjectsV2Async(new() { @@ -69,28 +72,45 @@ public class S3FileMetadataStore : IFileMetadataStore { if (Guid.TryParse(file.Key.Split("metadata_")[1], out var id)) { - var meta = await GetMeta(id); + var meta = await GetMeta(id); if (meta != default) { - count++; - size += meta.Size; + yield return meta; } } } } - catch (AmazonS3Exception aex) - { - _logger.LogError(aex, "Failed to list files: {Error}", aex.Message); - } - return new(count, size); + return ValueTask.FromResult(new PagedResult + { + Page = request.Page, + PageSize = request.PageSize, + Results = Enumerate().Skip(request.PageSize * request.Page).Take(request.PageSize) + }); } + /// + public async ValueTask Stats() + { + var files = await ListFiles(new(0, Int32.MaxValue)); + var count = await files.Results.CountAsync(); + var size = await files.Results.SumAsync(a => (long) a.Size); + return new(count, (ulong) size); + } + + /// public ValueTask Get(Guid id) { return GetMeta(id); } + /// + public ValueTask GetPrivate(Guid id) + { + return GetMeta(id); + } + + /// public async ValueTask Set(Guid id, SecretVoidFileMeta meta) { await _client.PutObjectAsync(new() @@ -102,6 +122,7 @@ public class S3FileMetadataStore : IFileMetadataStore }); } + /// public async ValueTask Delete(Guid id) { await _client.DeleteObjectAsync(_config.BucketName, ToKey(id)); @@ -116,14 +137,18 @@ public class S3FileMetadataStore : IFileMetadataStore using var sr = new StreamReader(obj.ResponseStream); var json = await sr.ReadToEndAsync(); var ret = JsonConvert.DeserializeObject(json); - if (ret != default && _includeUrl) + if (ret != default) { - var ub = new UriBuilder(_config.ServiceUrl!) + ret.Id = id; + if (_includeUrl) { - Path = $"/{_config.BucketName}/{id}" - }; + var ub = new UriBuilder(_config.ServiceUrl!) + { + Path = $"/{_config.BucketName}/{id}" + }; - ret.Url = ub.Uri; + ret.Url = ub.Uri; + } } return ret; diff --git a/VoidCat/Services/Files/S3FileStore.cs b/VoidCat/Services/Files/S3FileStore.cs index 0b27405..29c0bce 100644 --- a/VoidCat/Services/Files/S3FileStore.cs +++ b/VoidCat/Services/Files/S3FileStore.cs @@ -12,8 +12,7 @@ public class S3FileStore : StreamFileStore, IFileStore private readonly S3BlobConfig _config; private readonly IAggregateStatsCollector _statsCollector; - public S3FileStore(VoidSettings settings, IAggregateStatsCollector stats, IFileMetadataStore metadataStore, - IUserUploadsStore userUploads, IFileInfoManager fileInfo) : base(stats, metadataStore, userUploads) + public S3FileStore(VoidSettings settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats) { _fileInfo = fileInfo; _statsCollector = stats; diff --git a/VoidCat/Services/Files/StreamFileStore.cs b/VoidCat/Services/Files/StreamFileStore.cs index 2377626..e20590e 100644 --- a/VoidCat/Services/Files/StreamFileStore.cs +++ b/VoidCat/Services/Files/StreamFileStore.cs @@ -6,19 +6,17 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// +/// File store based on objects +/// public abstract class StreamFileStore { private const int BufferSize = 1_048_576; private readonly IAggregateStatsCollector _stats; - private readonly IFileMetadataStore _metadataStore; - private readonly IUserUploadsStore _userUploads; - protected StreamFileStore(IAggregateStatsCollector stats, IFileMetadataStore metadataStore, - IUserUploadsStore userUploads) + protected StreamFileStore(IAggregateStatsCollector stats) { _stats = stats; - _metadataStore = metadataStore; - _userUploads = userUploads; } protected async ValueTask EgressFromStream(Stream stream, EgressRequest request, Stream outStream, @@ -77,22 +75,17 @@ public abstract class StreamFileStore }; } - await _metadataStore.Set(payload.Id, meta); var vf = new PrivateVoidFile() { Id = payload.Id, Metadata = meta }; - if (meta.Uploader.HasValue) - { - await _userUploads.AddFile(meta.Uploader.Value, vf); - } - return vf; } - - private async Task<(ulong, string)> IngressInternal(Guid id, Stream ingress, Stream outStream, CancellationToken cts) + + private async Task<(ulong, string)> IngressInternal(Guid id, Stream ingress, Stream outStream, + CancellationToken cts) { using var buffer = MemoryPool.Shared.Rent(BufferSize); var total = 0UL; diff --git a/VoidCat/Services/Migrations/FluentMigrationRunner.cs b/VoidCat/Services/Migrations/FluentMigrationRunner.cs index b07fb9b..0b2a79e 100644 --- a/VoidCat/Services/Migrations/FluentMigrationRunner.cs +++ b/VoidCat/Services/Migrations/FluentMigrationRunner.cs @@ -11,11 +11,9 @@ public class FluentMigrationRunner : IMigration _runner = runner; } - public ValueTask Migrate(string[] args) + public ValueTask Migrate(string[] args) { _runner.MigrateUp(); - return ValueTask.CompletedTask; + return ValueTask.FromResult(IMigration.MigrationResult.Completed); } - - public bool ExitOnComplete => false; } \ No newline at end of file diff --git a/VoidCat/Services/Migrations/IMigration.cs b/VoidCat/Services/Migrations/IMigration.cs index bfacb65..cf870cd 100644 --- a/VoidCat/Services/Migrations/IMigration.cs +++ b/VoidCat/Services/Migrations/IMigration.cs @@ -2,6 +2,23 @@ public interface IMigration { - ValueTask Migrate(string[] args); - bool ExitOnComplete { get; } + ValueTask Migrate(string[] args); + + public enum MigrationResult + { + /// + /// Migration was not run + /// + Skipped, + + /// + /// Migration completed successfully, continue to startup + /// + Completed, + + /// + /// Migration completed Successfully, exit application + /// + ExitCompleted + } } \ No newline at end of file diff --git a/VoidCat/Services/Migrations/MetadataMigrator.cs b/VoidCat/Services/Migrations/MetadataMigrator.cs index 5e88924..67e796a 100644 --- a/VoidCat/Services/Migrations/MetadataMigrator.cs +++ b/VoidCat/Services/Migrations/MetadataMigrator.cs @@ -14,7 +14,7 @@ public abstract class MetadataMigrator : IMigration _logger = logger; } - public async ValueTask Migrate(string[] args) + public async ValueTask Migrate(string[] args) { var newMeta = Path.Combine(_settings.DataDirectory, OldPath); if (!Directory.Exists(newMeta)) @@ -51,6 +51,8 @@ public abstract class MetadataMigrator : IMigration } } } + + return IMigration.MigrationResult.Completed; } protected abstract string OldPath { get; } @@ -64,6 +66,4 @@ public abstract class MetadataMigrator : IMigration private string MapNewMeta(Guid id) => Path.ChangeExtension(Path.Join(_settings.DataDirectory, NewPath, id.ToString()), ".json"); - - public bool ExitOnComplete => false; } \ No newline at end of file diff --git a/VoidCat/Services/Migrations/PopulateMetadataId.cs b/VoidCat/Services/Migrations/PopulateMetadataId.cs new file mode 100644 index 0000000..dc55935 --- /dev/null +++ b/VoidCat/Services/Migrations/PopulateMetadataId.cs @@ -0,0 +1,31 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Migrations; + +public class PopulateMetadataId : IMigration +{ + private readonly IFileMetadataStore _metadataStore; + + public PopulateMetadataId(IFileMetadataStore metadataStore) + { + _metadataStore = metadataStore; + } + + public async ValueTask Migrate(string[] args) + { + if (!args.Contains("--add-metadata-id")) + { + return IMigration.MigrationResult.Skipped; + } + + var files = await _metadataStore.ListFiles(new(0, Int32.MaxValue)); + await foreach (var file in files.Results) + { + // read-write file metadata + await _metadataStore.Set(file.Id, file); + } + + return IMigration.MigrationResult.ExitCompleted; + } +} \ No newline at end of file diff --git a/VoidCat/Services/Migrations/UserLookupKeyHashMigration.cs b/VoidCat/Services/Migrations/UserLookupKeyHashMigration.cs index bc43957..16f7df7 100644 --- a/VoidCat/Services/Migrations/UserLookupKeyHashMigration.cs +++ b/VoidCat/Services/Migrations/UserLookupKeyHashMigration.cs @@ -13,7 +13,7 @@ public class UserLookupKeyHashMigration : IMigration _database = database; } - public async ValueTask Migrate(string[] args) + public async ValueTask Migrate(string[] args) { var users = await _database.SetMembersAsync("users"); foreach (var userId in users) @@ -30,6 +30,8 @@ public class UserLookupKeyHashMigration : IMigration await _database.StringSetAsync(MapNew(user.Email), $"\"{userId}\""); } } + + return IMigration.MigrationResult.Completed; } private static RedisKey MapOld(string email) => $"user:email:{email}"; @@ -41,6 +43,4 @@ public class UserLookupKeyHashMigration : IMigration public string Email { get; init; } } - - public bool ExitOnComplete => false; } \ No newline at end of file diff --git a/VoidCat/Services/Users/EmailVerification.cs b/VoidCat/Services/Users/BaseEmailVerification.cs similarity index 62% rename from VoidCat/Services/Users/EmailVerification.cs rename to VoidCat/Services/Users/BaseEmailVerification.cs index 50c7bdc..05ac305 100644 --- a/VoidCat/Services/Users/EmailVerification.cs +++ b/VoidCat/Services/Users/BaseEmailVerification.cs @@ -5,32 +5,28 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; -public class EmailVerification : IEmailVerification +/// +public abstract class BaseEmailVerification : IEmailVerification { - private readonly ICache _cache; + public const int HoursExpire = 1; private readonly VoidSettings _settings; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly RazorPartialToStringRenderer _renderer; - public EmailVerification(ICache cache, ILogger logger, VoidSettings settings, + protected BaseEmailVerification(ILogger logger, VoidSettings settings, RazorPartialToStringRenderer renderer) { - _cache = cache; _logger = logger; _settings = settings; _renderer = renderer; } + /// public async ValueTask SendNewCode(PrivateVoidUser user) { - const int codeExpire = 1; - var code = new EmailVerificationCode() - { - UserId = user.Id, - Expires = DateTimeOffset.UtcNow.AddHours(codeExpire) - }; - await _cache.Set(MapToken(code.Id), code, TimeSpan.FromHours(codeExpire)); - _logger.LogInformation("Saved email verification token for User={Id} Token={Token}", user.Id, code.Id); + var token = new EmailVerificationCode(user.Id, Guid.NewGuid(), DateTime.UtcNow.AddHours(HoursExpire)); + await SaveToken(token); + _logger.LogInformation("Saved email verification token for User={Id} Token={Token}", user.Id, token.Code); // send email try @@ -42,7 +38,7 @@ public class EmailVerification : IEmailVerification sc.EnableSsl = conf?.Server?.Scheme == "tls"; sc.Credentials = new NetworkCredential(conf?.Username, conf?.Password); - var msgContent = await _renderer.RenderPartialToStringAsync("~/Pages/EmailCode.cshtml", code); + var msgContent = await _renderer.RenderPartialToStringAsync("~/Pages/EmailCode.cshtml", token); var msg = new MailMessage(); msg.From = new MailAddress(conf?.Username ?? "no-reply@void.cat"); msg.To.Add(user.Email); @@ -59,22 +55,26 @@ public class EmailVerification : IEmailVerification _logger.LogError(ex, "Failed to send email verification code {Error}", ex.Message); } - return code; + return token; } + /// public async ValueTask VerifyCode(PrivateVoidUser user, Guid code) { - var token = await _cache.Get(MapToken(code)); + var token = await GetToken(user.Id, code); if (token == default) return false; - var isValid = user.Id == token.UserId && token.Expires > DateTimeOffset.UtcNow; + var isValid = user.Id == token.User && + DateTime.SpecifyKind(token.Expires, DateTimeKind.Utc) > DateTimeOffset.UtcNow; if (isValid) { - await _cache.Delete(MapToken(code)); + await DeleteToken(user.Id, code); } return isValid; } - private static string MapToken(Guid id) => $"email-code:{id}"; + protected abstract ValueTask SaveToken(EmailVerificationCode code); + protected abstract ValueTask GetToken(Guid user, Guid code); + protected abstract ValueTask DeleteToken(Guid user, Guid code); } \ No newline at end of file diff --git a/VoidCat/Services/Users/CacheEmailVerification.cs b/VoidCat/Services/Users/CacheEmailVerification.cs new file mode 100644 index 0000000..05923d0 --- /dev/null +++ b/VoidCat/Services/Users/CacheEmailVerification.cs @@ -0,0 +1,36 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users; + +/// +public class CacheEmailVerification : BaseEmailVerification +{ + private readonly ICache _cache; + + public CacheEmailVerification(ICache cache, ILogger logger, VoidSettings settings, + RazorPartialToStringRenderer renderer) : base(logger, settings, renderer) + { + _cache = cache; + } + + /// + protected override ValueTask SaveToken(EmailVerificationCode code) + { + return _cache.Set(MapToken(code.Code), code, code.Expires - DateTime.UtcNow); + } + + /// + protected override ValueTask GetToken(Guid user, Guid code) + { + return _cache.Get(MapToken(code)); + } + + /// + protected override ValueTask DeleteToken(Guid user, Guid code) + { + return _cache.Delete(MapToken(code)); + } + + private static string MapToken(Guid id) => $"email-code:{id}"; +} \ No newline at end of file diff --git a/VoidCat/Services/Users/UserStore.cs b/VoidCat/Services/Users/CacheUserStore.cs similarity index 78% rename from VoidCat/Services/Users/UserStore.cs rename to VoidCat/Services/Users/CacheUserStore.cs index ee71dd4..d684d62 100644 --- a/VoidCat/Services/Users/UserStore.cs +++ b/VoidCat/Services/Users/CacheUserStore.cs @@ -3,28 +3,26 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; -public class UserStore : IUserStore +/// +public class CacheUserStore : IUserStore { private const string UserList = "users"; - private readonly ILogger _logger; + private readonly ILogger _logger; private readonly ICache _cache; - public UserStore(ICache cache, ILogger logger) + public CacheUserStore(ICache cache, ILogger logger) { _cache = cache; _logger = logger; } + /// public async ValueTask LookupUser(string email) { return await _cache.Get(MapKey(email)); } - public async ValueTask Get(Guid id) - { - return await Get(id); - } - + /// public async ValueTask Get(Guid id) where T : VoidUser { try @@ -39,6 +37,19 @@ public class UserStore : IUserStore return default; } + /// + public ValueTask Get(Guid id) + { + return Get(id); + } + + /// + public ValueTask GetPrivate(Guid id) + { + return Get(id); + } + + /// public async ValueTask Set(Guid id, InternalVoidUser user) { if (id != user.Id) throw new InvalidOperationException(); @@ -48,6 +59,7 @@ public class UserStore : IUserStore await _cache.Set(MapKey(user.Email), user.Id.ToString()); } + /// public async ValueTask> ListUsers(PagedRequest request) { var users = (await _cache.GetList(UserList)) @@ -81,6 +93,7 @@ public class UserStore : IUserStore }; } + /// public async ValueTask UpdateProfile(PublicVoidUser newUser) { var oldUser = await Get(newUser.Id); @@ -97,6 +110,18 @@ public class UserStore : IUserStore await Set(newUser.Id, oldUser); } + /// + public async ValueTask UpdateLastLogin(Guid id, DateTime timestamp) + { + var user = await Get(id); + if (user != default) + { + user.LastLogin = timestamp; + await Set(user.Id, user); + } + } + + /// public async ValueTask Delete(Guid id) { var user = await Get(id); @@ -104,7 +129,7 @@ public class UserStore : IUserStore await Delete(user); } - public async ValueTask Delete(PrivateVoidUser user) + private async ValueTask Delete(PrivateVoidUser user) { await _cache.Delete(MapKey(user.Id)); await _cache.RemoveFromList(UserList, user.Id.ToString()); diff --git a/VoidCat/Services/Users/PostgresEmailVerification.cs b/VoidCat/Services/Users/PostgresEmailVerification.cs new file mode 100644 index 0000000..24883db --- /dev/null +++ b/VoidCat/Services/Users/PostgresEmailVerification.cs @@ -0,0 +1,45 @@ +using Dapper; +using Npgsql; +using VoidCat.Model; + +namespace VoidCat.Services.Users; + +/// +public class PostgresEmailVerification : BaseEmailVerification +{ + private readonly NpgsqlConnection _connection; + + public PostgresEmailVerification(ILogger logger, VoidSettings settings, + RazorPartialToStringRenderer renderer, NpgsqlConnection connection) : base(logger, settings, renderer) + { + _connection = connection; + } + + /// + protected override async ValueTask SaveToken(EmailVerificationCode code) + { + await _connection.ExecuteAsync( + @"insert into ""EmailVerification""(""User"", ""Code"", ""Expires"") values(:user, :code, :expires)", + new + { + user = code.User, + code = code.Code, + expires = code.Expires.ToUniversalTime() + }); + } + + /// + protected override async ValueTask GetToken(Guid user, Guid code) + { + return await _connection.QuerySingleOrDefaultAsync( + @"select * from ""EmailVerification"" where ""User"" = :user and ""Code"" = :code", + new {user, code}); + } + + /// + protected override async ValueTask DeleteToken(Guid user, Guid code) + { + await _connection.ExecuteAsync(@"delete from ""EmailVerification"" where ""User"" = :user and ""Code"" = :code", + new {user, code}); + } +} \ No newline at end of file diff --git a/VoidCat/Services/Users/PostgresUserStore.cs b/VoidCat/Services/Users/PostgresUserStore.cs index 0b7e175..b77ef60 100644 --- a/VoidCat/Services/Users/PostgresUserStore.cs +++ b/VoidCat/Services/Users/PostgresUserStore.cs @@ -5,6 +5,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; +/// public class PostgresUserStore : IUserStore { private readonly NpgsqlConnection _connection; @@ -14,40 +15,70 @@ public class PostgresUserStore : IUserStore _connection = connection; } - public ValueTask Get(Guid id) + /// + public async ValueTask Get(Guid id) { - return Get(id); + return await Get(id); } + /// + public async ValueTask GetPrivate(Guid id) + { + return await Get(id); + } + + /// public async ValueTask Set(Guid id, InternalVoidUser obj) { await _connection.ExecuteAsync( @"insert into ""Users""(""Id"", ""Email"", ""Password"", ""LastLogin"", ""DisplayName"", ""Avatar"", ""Flags"") -values(:id, :email, :password, :lastLogin, :displayName, :avatar, :flags) -on conflict (""Id"") do update set ""LastLogin"" = :lastLogin, ""DisplayName"" = :displayName, ""Avatar"" = :avatar, ""Flags"" = :flags", +values(:id, :email, :password, :lastLogin, :displayName, :avatar, :flags)", new { Id = id, email = obj.Email, - password = obj.PasswordHash, + password = obj.Password, displayName = obj.DisplayName, - lastLogin = obj.LastLogin, + lastLogin = obj.LastLogin.ToUniversalTime(), avatar = obj.Avatar, flags = (int) obj.Flags }); + if (obj.Roles.Any(a => a != Roles.User)) + { + foreach (var r in obj.Roles.Where(a => a != Roles.User)) + { + await _connection.ExecuteAsync(@"insert into ""UserRoles""(""User"", ""Role"") values(:user, :role)", + new {user = obj.Id, role = r}); + } + } } + /// public async ValueTask Delete(Guid id) { await _connection.ExecuteAsync(@"delete from ""Users"" where ""Id"" = :id", new {id}); } + /// public async ValueTask Get(Guid id) where T : VoidUser { - return await _connection.QuerySingleOrDefaultAsync(@"select * from ""Users"" where ""Id"" = :id", new {id}); + var user = await _connection.QuerySingleOrDefaultAsync(@"select * from ""Users"" where ""Id"" = :id", + new {id}); + if (user != default) + { + var roles = await _connection.QueryAsync(@"select ""Role"" from ""UserRoles"" where ""User"" = :id", + new {id}); + foreach (var r in roles) + { + user.Roles.Add(r); + } + } + + return user; } + /// public async ValueTask LookupUser(string email) { return await _connection.QuerySingleOrDefaultAsync( @@ -55,6 +86,7 @@ on conflict (""Id"") do update set ""LastLogin"" = :lastLogin, ""DisplayName"" = new {email}); } + /// public async ValueTask> ListUsers(PagedRequest request) { var orderBy = request.SortBy switch @@ -94,10 +126,29 @@ on conflict (""Id"") do update set ""LastLogin"" = :lastLogin, ""DisplayName"" = }; } + /// public async ValueTask UpdateProfile(PublicVoidUser newUser) { + var oldUser = await Get(newUser.Id); + if (oldUser == null) return; + + var emailFlag = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified) ? VoidUserFlags.EmailVerified : 0; + await _connection.ExecuteAsync( - @"update ""Users"" set ""DisplayName"" = @displayName, ""Avatar"" = @avatar where ""Id"" = :id", - new {id = newUser.Id, displayName = newUser.DisplayName, avatar = newUser.Avatar}); + @"update ""Users"" set ""DisplayName"" = @displayName, ""Avatar"" = @avatar, ""Flags"" = :flags where ""Id"" = :id", + new + { + id = newUser.Id, + displayName = newUser.DisplayName, + avatar = newUser.Avatar, + flags = newUser.Flags | emailFlag + }); + } + + /// + public async ValueTask UpdateLastLogin(Guid id, DateTime timestamp) + { + await _connection.ExecuteAsync(@"update ""Users"" set ""LastLogin"" = :timestamp where ""Id"" = :id", + new {id, timestamp}); } } \ No newline at end of file diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index 022f168..8f4a5a6 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -3,6 +3,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Users; +/// public class UserManager : IUserManager { private readonly IUserStore _store; @@ -15,27 +16,33 @@ public class UserManager : IUserManager _emailVerification = emailVerification; } + /// 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); + var user = await _store.GetPrivate(userId.Value); if (!(user?.CheckPassword(password) ?? false)) throw new InvalidOperationException("User does not exist"); - + user.LastLogin = DateTimeOffset.UtcNow; - await _store.Set(user.Id, user); + await _store.UpdateLastLogin(user.Id, DateTime.UtcNow); return user; } + /// public async ValueTask Register(string email, string password) { var existingUser = await _store.LookupUser(email); - if (existingUser != Guid.Empty && existingUser != null) throw new InvalidOperationException("User already exists"); + if (existingUser != Guid.Empty && existingUser != null) + throw new InvalidOperationException("User already exists"); - var newUser = new InternalVoidUser(Guid.NewGuid(), email, password.HashPassword()) + var newUser = new InternalVoidUser { + Id = Guid.NewGuid(), + Email = email, + Password = password.HashPassword(), Created = DateTimeOffset.UtcNow, LastLogin = DateTimeOffset.UtcNow }; diff --git a/VoidCat/Services/Users/UsersStartup.cs b/VoidCat/Services/Users/UsersStartup.cs index b0ef569..53b22e7 100644 --- a/VoidCat/Services/Users/UsersStartup.cs +++ b/VoidCat/Services/Users/UsersStartup.cs @@ -8,14 +8,16 @@ public static class UsersStartup public static void AddUserServices(this IServiceCollection services, VoidSettings settings) { services.AddTransient(); - services.AddTransient(); + if (settings.Postgres != default) { services.AddTransient(); + services.AddTransient(); } else { - services.AddTransient(); + services.AddTransient(); + services.AddTransient(); } } } \ No newline at end of file