From 99907fce8b44e509955a9f0d2824b8505923c028 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 25 Jul 2022 18:59:32 +0100 Subject: [PATCH] Multi-Storage backend support Api Keys support --- VoidCat/Controllers/Admin/AdminController.cs | 24 ++++- VoidCat/Controllers/AuthController.cs | 90 +++++++++++++++++-- VoidCat/Controllers/DownloadController.cs | 5 +- VoidCat/Controllers/InfoController.cs | 11 ++- VoidCat/Controllers/UploadController.cs | 29 ++++-- VoidCat/Controllers/UserController.cs | 6 +- VoidCat/Model/ApiKey.cs | 18 ++++ VoidCat/Model/VoidFileMeta.cs | 5 ++ VoidCat/Model/VoidSettings.cs | 15 +++- VoidCat/Model/VoidUser.cs | 7 +- VoidCat/Services/Abstractions/IApiKeyStore.cs | 16 ++++ VoidCat/Services/Abstractions/IFileStore.cs | 5 ++ VoidCat/Services/Abstractions/IUserStore.cs | 7 ++ .../Background/DeleteUnverifiedAccounts.cs | 3 +- VoidCat/Services/Files/FileStorageStartup.cs | 35 +++++--- VoidCat/Services/Files/FileSystemFactory.cs | 89 ++++++++++++++++++ .../Files/LocalDiskFileMetadataStore.cs | 1 + .../Services/Files/LocalDiskFileStorage.cs | 3 + .../Files/PostgresFileMetadataStore.cs | 21 +++-- VoidCat/Services/Files/S3FileMetadataStore.cs | 12 +-- VoidCat/Services/Files/S3FileStore.cs | 16 +++- .../Migrations/Database/02-MinorVersion1.cs | 37 ++++++++ VoidCat/Services/Migrations/FixSize.cs | 5 +- .../Services/Migrations/MigrateToPostgres.cs | 8 +- VoidCat/Services/Users/CacheApiKeyStore.cs | 20 +++++ VoidCat/Services/Users/CacheUserStore.cs | 12 +++ VoidCat/Services/Users/PostgresApiKeyStore.cs | 59 ++++++++++++ VoidCat/Services/Users/PostgresUserStore.cs | 26 +++++- VoidCat/Services/Users/UsersStartup.cs | 3 +- .../Services/VirusScanner/ClamAvScanner.cs | 9 +- VoidCat/VoidCat.csproj | 2 +- VoidCat/spa/src/Admin/Admin.css | 17 ---- VoidCat/spa/src/Admin/Admin.js | 17 +++- VoidCat/spa/src/Admin/EditUser.js | 46 ++++++++++ VoidCat/spa/src/Admin/UserList.js | 9 +- VoidCat/spa/src/Api.js | 7 +- VoidCat/spa/src/ApiKeyList.js | 69 ++++++++++++++ VoidCat/spa/src/FileList.css | 16 ---- VoidCat/spa/src/FileList.js | 3 +- VoidCat/spa/src/Profile.js | 2 + VoidCat/spa/src/VoidModal.css | 35 ++++++++ VoidCat/spa/src/VoidModal.js | 19 ++++ VoidCat/spa/src/index.css | 25 ++++++ 43 files changed, 753 insertions(+), 111 deletions(-) create mode 100644 VoidCat/Model/ApiKey.cs create mode 100644 VoidCat/Services/Abstractions/IApiKeyStore.cs create mode 100644 VoidCat/Services/Files/FileSystemFactory.cs create mode 100644 VoidCat/Services/Migrations/Database/02-MinorVersion1.cs create mode 100644 VoidCat/Services/Users/CacheApiKeyStore.cs create mode 100644 VoidCat/Services/Users/PostgresApiKeyStore.cs create mode 100644 VoidCat/spa/src/Admin/EditUser.js create mode 100644 VoidCat/spa/src/ApiKeyList.js delete mode 100644 VoidCat/spa/src/FileList.css create mode 100644 VoidCat/spa/src/VoidModal.css create mode 100644 VoidCat/spa/src/VoidModal.js diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index bc3dd20..7ac0e48 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using VoidCat.Model; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Controllers.Admin; @@ -9,13 +10,13 @@ namespace VoidCat.Controllers.Admin; [Authorize(Policy = Policies.RequireAdmin)] public class AdminController : Controller { - private readonly IFileStore _fileStore; + private readonly FileStoreFactory _fileStore; private readonly IFileMetadataStore _fileMetadata; private readonly IFileInfoManager _fileInfo; private readonly IUserStore _userStore; private readonly IUserUploadsStore _userUploads; - public AdminController(IFileStore fileStore, IUserStore userStore, IFileInfoManager fileInfo, + public AdminController(FileStoreFactory fileStore, IUserStore userStore, IFileInfoManager fileInfo, IFileMetadataStore fileMetadata, IUserUploadsStore userUploads) { _fileStore = fileStore; @@ -74,6 +75,7 @@ public class AdminController : Controller var uploads = await _userUploads.ListFiles(a.Id, new(0, int.MaxValue)); return new AdminListedUser(a, uploads.TotalResults); }).ToListAsync(); + return new() { PageSize = request.PageSize, @@ -83,5 +85,21 @@ public class AdminController : Controller }; } + /// + /// Admin update user account + /// + /// + /// + [HttpPost] + [Route("user/{id}")] + public async Task UpdateUser([FromBody] PrivateVoidUser user) + { + var oldUser = await _userStore.Get(user.Id); + if (oldUser == default) return BadRequest(); + + await _userStore.AdminUpdateUser(user); + return Ok(); + } + public record AdminListedUser(PrivateVoidUser User, int Uploads); -} \ No newline at end of file +} diff --git a/VoidCat/Controllers/AuthController.cs b/VoidCat/Controllers/AuthController.cs index fe3b8d0..f4e6fa7 100644 --- a/VoidCat/Controllers/AuthController.cs +++ b/VoidCat/Controllers/AuthController.cs @@ -15,12 +15,17 @@ public class AuthController : Controller private readonly IUserManager _manager; private readonly VoidSettings _settings; private readonly ICaptchaVerifier _captchaVerifier; + private readonly IApiKeyStore _apiKeyStore; + private readonly IUserStore _userStore; - public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier) + public AuthController(IUserManager userStore, VoidSettings settings, ICaptchaVerifier captchaVerifier, IApiKeyStore apiKeyStore, + IUserStore userStore1) { _manager = userStore; _settings = settings; _captchaVerifier = captchaVerifier; + _apiKeyStore = apiKeyStore; + _userStore = userStore1; } /// @@ -39,13 +44,13 @@ public class AuthController : Controller var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage; return new(null, error); } - + // check captcha if (!await _captchaVerifier.Verify(req.Captcha)) { return new(null, "Captcha verification failed"); } - + var user = await _manager.Login(req.Username, req.Password); var token = CreateToken(user); var tokenWriter = new JwtSecurityTokenHandler(); @@ -73,13 +78,13 @@ public class AuthController : Controller var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage; return new(null, error); } - + // check captcha if (!await _captchaVerifier.Verify(req.Captcha)) { return new(null, "Captcha verification failed"); } - + var newUser = await _manager.Register(req.Username, req.Password); var token = CreateToken(newUser); var tokenWriter = new JwtSecurityTokenHandler(); @@ -91,6 +96,74 @@ public class AuthController : Controller } } + /// + /// List api keys for the user + /// + /// + /// + [HttpGet] + [Route("api-key")] + public async Task ListApiKeys() + { + var uid = HttpContext.GetUserId(); + if (uid == default) return Unauthorized(); + + return Json(await _apiKeyStore.ListKeys(uid.Value)); + } + + /// + /// Create a new API key for the logged in user + /// + /// + /// + /// + [HttpPost] + [Route("api-key")] + public async Task CreateApiKey([FromBody] CreateApiKeyRequest request) + { + var uid = HttpContext.GetUserId(); + if (uid == default) return Unauthorized(); + + var user = await _userStore.Get(uid.Value); + if (user == default) return Unauthorized(); + + var expiry = DateTime.SpecifyKind(request.Expiry, DateTimeKind.Utc); + if (expiry > DateTime.UtcNow.AddYears(1)) + { + return BadRequest(); + } + + var key = new ApiKey() + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = new JwtSecurityTokenHandler().WriteToken(CreateApiToken(user, expiry)), + Expiry = expiry + }; + + await _apiKeyStore.Add(key.Id, key); + return Json(key); + } + + private JwtSecurityToken CreateApiToken(VoidUser user, DateTime expiry) + { + var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key)); + var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256); + + var claims = new List() + { + new(ClaimTypes.NameIdentifier, user.Id.ToString()), + new(JwtRegisteredClaimNames.Aud, "API"), + new(JwtRegisteredClaimNames.Exp, new DateTimeOffset(expiry).ToUnixTimeSeconds().ToString()), + new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) + }; + + claims.AddRange(user.Roles.Select(a => new Claim(ClaimTypes.Role, a))); + + return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, + signingCredentials: credentials); + } + private JwtSecurityToken CreateToken(VoidUser user) { var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.JwtSettings.Key)); @@ -102,13 +175,13 @@ public class AuthController : Controller new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).ToUnixTimeSeconds().ToString()), new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) }; + claims.AddRange(user.Roles.Select(a => new Claim(ClaimTypes.Role, a))); return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, signingCredentials: credentials); } - public sealed class LoginRequest { public LoginRequest(string username, string password) @@ -120,7 +193,7 @@ public class AuthController : Controller [Required] [EmailAddress] public string Username { get; } - + [Required] [MinLength(6)] public string Password { get; } @@ -129,4 +202,7 @@ public class AuthController : Controller } public sealed record LoginResponse(string? Jwt, string? Error = null, VoidUser? Profile = null); + + + public sealed record CreateApiKeyRequest(DateTime Expiry); } diff --git a/VoidCat/Controllers/DownloadController.cs b/VoidCat/Controllers/DownloadController.cs index 3240472..60d3c18 100644 --- a/VoidCat/Controllers/DownloadController.cs +++ b/VoidCat/Controllers/DownloadController.cs @@ -3,18 +3,19 @@ using Microsoft.AspNetCore.Mvc; using VoidCat.Model; using VoidCat.Model.Paywall; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Controllers; [Route("d")] public class DownloadController : Controller { - private readonly IFileStore _storage; + private readonly FileStoreFactory _storage; private readonly IFileInfoManager _fileInfo; private readonly IPaywallOrderStore _paywallOrders; private readonly ILogger _logger; - public DownloadController(IFileStore storage, ILogger logger, IFileInfoManager fileInfo, + public DownloadController(FileStoreFactory storage, ILogger logger, IFileInfoManager fileInfo, IPaywallOrderStore paywall) { _storage = storage; diff --git a/VoidCat/Controllers/InfoController.cs b/VoidCat/Controllers/InfoController.cs index 961e33a..a79beaf 100644 --- a/VoidCat/Controllers/InfoController.cs +++ b/VoidCat/Controllers/InfoController.cs @@ -11,14 +11,16 @@ public class InfoController : Controller private readonly IFileMetadataStore _fileMetadata; private readonly VoidSettings _settings; private readonly ITimeSeriesStatsReporter _timeSeriesStats; + private readonly IEnumerable _fileStores; public InfoController(IStatsReporter statsReporter, IFileMetadataStore fileMetadata, VoidSettings settings, - ITimeSeriesStatsReporter stats) + ITimeSeriesStatsReporter stats, IEnumerable fileStores) { _statsReporter = statsReporter; _fileMetadata = fileMetadata; _settings = settings; _timeSeriesStats = stats; + _fileStores = fileStores.Select(a => a.Key); } /// @@ -34,9 +36,10 @@ public class InfoController : Controller return new(bw, storeStats.Size, storeStats.Files, BuildInfo.GetBuildInfo(), _settings.CaptchaSettings?.SiteKey, - await _timeSeriesStats.GetBandwidth(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow)); + await _timeSeriesStats.GetBandwidth(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow), + _fileStores); } public sealed record GlobalInfo(Bandwidth Bandwidth, ulong TotalBytes, long Count, BuildInfo BuildInfo, - string? CaptchaSiteKey, IEnumerable TimeSeriesMetrics); -} \ No newline at end of file + string? CaptchaSiteKey, IEnumerable TimeSeriesMetrics, IEnumerable FileStores); +} diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 871135f..feb590d 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -6,23 +6,26 @@ using Newtonsoft.Json; using VoidCat.Model; using VoidCat.Model.Paywall; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Controllers { [Route("upload")] public class UploadController : Controller { - private readonly IFileStore _storage; + private readonly FileStoreFactory _storage; private readonly IFileMetadataStore _metadata; private readonly IPaywallStore _paywall; private readonly IPaywallFactory _paywallFactory; private readonly IFileInfoManager _fileInfo; private readonly IUserUploadsStore _userUploads; + private readonly IUserStore _userStore; private readonly ITimeSeriesStatsReporter _timeSeriesStats; + private readonly VoidSettings _settings; - public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall, + public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaywallStore paywall, IPaywallFactory paywallFactory, IFileInfoManager fileInfo, IUserUploadsStore userUploads, - ITimeSeriesStatsReporter timeSeriesStats) + ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings) { _storage = storage; _metadata = metadata; @@ -31,6 +34,8 @@ namespace VoidCat.Controllers _fileInfo = fileInfo; _userUploads = userUploads; _timeSeriesStats = timeSeriesStats; + _userStore = userStore; + _settings = settings; } /// @@ -65,13 +70,25 @@ namespace VoidCat.Controllers } } + // detect store for ingress + var store = _settings.DefaultFileStore; + if (uid.HasValue) + { + var user = await _userStore.Get(uid.Value); + if (user?.Storage != default) + { + store = user.Storage!; + } + } + var meta = new SecretVoidFileMeta { MimeType = mime, Name = filename, Description = Request.Headers.GetHeader("V-Description"), Digest = Request.Headers.GetHeader("V-Full-Digest"), - Size = (ulong?) Request.ContentLength ?? 0UL + Size = (ulong?)Request.ContentLength ?? 0UL, + Storage = store }; var digest = Request.Headers.GetHeader("V-Digest"); @@ -160,6 +177,7 @@ namespace VoidCat.Controllers public async Task GetInfo([FromRoute] string id) { if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404); + var uid = HttpContext.GetUserId(); var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid; @@ -240,6 +258,7 @@ namespace VoidCat.Controllers Handle = req.Strike.Handle, Cost = req.Strike.Cost }); + return Ok(); } @@ -303,4 +322,4 @@ namespace VoidCat.Controllers public StrikePaywallConfig? Strike { get; init; } } -} \ No newline at end of file +} diff --git a/VoidCat/Controllers/UserController.cs b/VoidCat/Controllers/UserController.cs index b1ab7e9..2387522 100644 --- a/VoidCat/Controllers/UserController.cs +++ b/VoidCat/Controllers/UserController.cs @@ -12,7 +12,8 @@ public class UserController : Controller private readonly IEmailVerification _emailVerification; private readonly IFileInfoManager _fileInfoManager; - public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification, IFileInfoManager fileInfoManager) + public UserController(IUserStore store, IUserUploadsStore userUploads, IEmailVerification emailVerification, + IFileInfoManager fileInfoManager) { _store = store; _userUploads = userUploads; @@ -42,6 +43,7 @@ public class UserController : Controller { var pUser = await _store.Get(requestedId); if (pUser == default) return NotFound(); + return Json(pUser); } @@ -161,4 +163,4 @@ public class UserController : Controller var gid = id.FromBase58Guid(); return await _store.Get(gid); } -} \ No newline at end of file +} diff --git a/VoidCat/Model/ApiKey.cs b/VoidCat/Model/ApiKey.cs new file mode 100644 index 0000000..2fd6ede --- /dev/null +++ b/VoidCat/Model/ApiKey.cs @@ -0,0 +1,18 @@ +using Newtonsoft.Json; + +namespace VoidCat.Model; + +public sealed class ApiKey +{ + [JsonConverter(typeof(Base58GuidConverter))] + public Guid Id { get; init; } + + [JsonConverter(typeof(Base58GuidConverter))] + public Guid UserId { get; init; } + + public string Token { get; init; } + + public DateTime Expiry { get; init; } + + public DateTime Created { get; init; } +} diff --git a/VoidCat/Model/VoidFileMeta.cs b/VoidCat/Model/VoidFileMeta.cs index f332e79..9baf25e 100644 --- a/VoidCat/Model/VoidFileMeta.cs +++ b/VoidCat/Model/VoidFileMeta.cs @@ -70,6 +70,11 @@ public record VoidFileMeta : IVoidFileMeta /// Time when the file will expire and be deleted /// public DateTimeOffset? Expires { get; set; } + + /// + /// What storage system the file is on + /// + public string? Storage { get; set; } } /// diff --git a/VoidCat/Model/VoidSettings.cs b/VoidCat/Model/VoidSettings.cs index 233a5c8..481edd2 100644 --- a/VoidCat/Model/VoidSettings.cs +++ b/VoidCat/Model/VoidSettings.cs @@ -75,6 +75,16 @@ namespace VoidCat.Model /// Prometheus server for querying metrics /// public Uri? Prometheus { get; init; } + + /// + /// Select where to store metadata, if not set "local-disk" will be used + /// + public string MetadataStore { get; init; } = "local-disk"; + + /// + /// Select which store to use for files storage, if not set "local-disk" will be used + /// + public string DefaultFileStore { get; init; } = "local-disk"; } public sealed class TorSettings @@ -99,17 +109,18 @@ namespace VoidCat.Model public sealed class CloudStorageSettings { - public bool ServeFromCloud { get; init; } - public S3BlobConfig? S3 { get; set; } + public S3BlobConfig[]? S3 { get; init; } } public sealed class S3BlobConfig { + public string Name { get; init; } = null!; public string? AccessKey { get; init; } public string? SecretKey { get; init; } public Uri? ServiceUrl { get; init; } public string? Region { get; init; } public string? BucketName { get; init; } = "void-cat"; + public bool Direct { get; init; } } public sealed class VirusScannerSettings diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/VoidUser.cs index 904789f..e116734 100644 --- a/VoidCat/Model/VoidUser.cs +++ b/VoidCat/Model/VoidUser.cs @@ -82,7 +82,12 @@ public class PrivateVoidUser : VoidUser /// /// Users email address /// - public string Email { get; init; } = null!; + public string Email { get; set; } = null!; + + /// + /// Users storage system for new uploads + /// + public string? Storage { get; set; } } /// diff --git a/VoidCat/Services/Abstractions/IApiKeyStore.cs b/VoidCat/Services/Abstractions/IApiKeyStore.cs new file mode 100644 index 0000000..b063632 --- /dev/null +++ b/VoidCat/Services/Abstractions/IApiKeyStore.cs @@ -0,0 +1,16 @@ +using VoidCat.Model; + +namespace VoidCat.Services.Abstractions; + +/// +/// Api key store +/// +public interface IApiKeyStore : IBasicStore +{ + /// + /// Return a list of Api keys for a given user + /// + /// + /// + ValueTask> ListKeys(Guid id); +} diff --git a/VoidCat/Services/Abstractions/IFileStore.cs b/VoidCat/Services/Abstractions/IFileStore.cs index 4b1cdc1..df2f8d8 100644 --- a/VoidCat/Services/Abstractions/IFileStore.cs +++ b/VoidCat/Services/Abstractions/IFileStore.cs @@ -7,6 +7,11 @@ namespace VoidCat.Services.Abstractions; /// public interface IFileStore { + /// + /// Return key for named instance + /// + string? Key { get; } + /// /// Ingress a file into the system (Upload) /// diff --git a/VoidCat/Services/Abstractions/IUserStore.cs b/VoidCat/Services/Abstractions/IUserStore.cs index 736f575..a0ac290 100644 --- a/VoidCat/Services/Abstractions/IUserStore.cs +++ b/VoidCat/Services/Abstractions/IUserStore.cs @@ -43,4 +43,11 @@ public interface IUserStore : IPublicPrivateStore /// /// ValueTask UpdateLastLogin(Guid id, DateTime timestamp); + + /// + /// Update user account for admin + /// + /// + /// + ValueTask AdminUpdateUser(PrivateVoidUser user); } \ No newline at end of file diff --git a/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs b/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs index aaee1de..59d9c8a 100644 --- a/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs +++ b/VoidCat/Services/Background/DeleteUnverifiedAccounts.cs @@ -1,5 +1,6 @@ using VoidCat.Model; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Services.Background; @@ -23,7 +24,7 @@ public class DeleteUnverifiedAccounts : BackgroundService using var scope = _scopeFactory.CreateScope(); var userStore = scope.ServiceProvider.GetRequiredService(); var userUploads = scope.ServiceProvider.GetRequiredService(); - var fileStore = scope.ServiceProvider.GetRequiredService(); + var fileStore = scope.ServiceProvider.GetRequiredService(); var fileInfoManager = scope.ServiceProvider.GetRequiredService(); var accounts = await userStore.ListUsers(new(0, Int32.MaxValue)); diff --git a/VoidCat/Services/Files/FileStorageStartup.cs b/VoidCat/Services/Files/FileStorageStartup.cs index 2a79380..8d36c3c 100644 --- a/VoidCat/Services/Files/FileStorageStartup.cs +++ b/VoidCat/Services/Files/FileStorageStartup.cs @@ -9,29 +9,42 @@ public static class FileStorageStartup public static void AddStorage(this IServiceCollection services, VoidSettings settings) { services.AddTransient(); - + services.AddTransient(); + if (settings.CloudStorage != default) { - services.AddTransient(); - - // cloud storage - if (settings.CloudStorage.S3 != default) + // S3 storage + foreach (var s3 in settings.CloudStorage.S3 ?? Array.Empty()) { - services.AddSingleton(); - services.AddSingleton(); + services.AddTransient((svc) => + new S3FileStore(s3, svc.GetRequiredService(), + svc.GetRequiredService())); + + if (settings.MetadataStore == s3.Name) + { + services.AddSingleton((svc) => + new S3FileMetadataStore(s3, svc.GetRequiredService>())); + } } } - else if (!string.IsNullOrEmpty(settings.Postgres)) + + if (!string.IsNullOrEmpty(settings.Postgres)) { services.AddTransient(); services.AddTransient(); - services.AddTransient(); + if (settings.MetadataStore == "postgres") + { + services.AddSingleton(); + } } else { services.AddTransient(); services.AddTransient(); - services.AddTransient(); + if (settings.MetadataStore == "local-disk") + { + services.AddSingleton(); + } } } -} \ No newline at end of file +} diff --git a/VoidCat/Services/Files/FileSystemFactory.cs b/VoidCat/Services/Files/FileSystemFactory.cs new file mode 100644 index 0000000..3cb61e9 --- /dev/null +++ b/VoidCat/Services/Files/FileSystemFactory.cs @@ -0,0 +1,89 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Files; + +/// +/// Primary class for accessing implementations +/// +public class FileStoreFactory : IFileStore +{ + private readonly IFileMetadataStore _metadataStore; + private readonly IEnumerable _fileStores; + + public FileStoreFactory(IEnumerable fileStores, IFileMetadataStore metadataStore) + { + _fileStores = fileStores; + _metadataStore = metadataStore; + } + + /// + /// Get files store interface by key + /// + /// + /// + public IFileStore? GetFileStore(string? key) + { + if (key == default && _fileStores.Count() == 1) + { + return _fileStores.First(); + } + + return _fileStores.FirstOrDefault(a => a.Key == key); + } + + /// + public string? Key => null; + + /// + public ValueTask Ingress(IngressPayload payload, CancellationToken cts) + { + var store = GetFileStore(payload.Meta.Storage!); + if (store == default) + { + throw new InvalidOperationException($"Cannot find store '{payload.Meta.Storage}'"); + } + + return store.Ingress(payload, cts); + } + + /// + public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts) + { + var store = await GetStore(request.Id); + await store.Egress(request, outStream, cts); + } + + /// + public async ValueTask DeleteFile(Guid id) + { + var store = await GetStore(id); + await store.DeleteFile(id); + } + + /// + public async ValueTask Open(EgressRequest request, CancellationToken cts) + { + var store = await GetStore(request.Id); + return await store.Open(request, cts); + } + + /// + /// Get file store for a file by id + /// + /// + /// + /// + private async Task GetStore(Guid id) + { + var meta = await _metadataStore.Get(id); + var store = GetFileStore(meta?.Storage); + + if (store == default) + { + throw new InvalidOperationException($"Cannot find store '{meta?.Storage}'"); + } + + return store; + } +} diff --git a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs index faa1df0..ba1cab7 100644 --- a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs +++ b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs @@ -53,6 +53,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.Expires = meta.Expires ?? oldMeta.Expires; + oldMeta.Storage = meta.Storage ?? oldMeta.Storage; await Set(id, oldMeta); } diff --git a/VoidCat/Services/Files/LocalDiskFileStorage.cs b/VoidCat/Services/Files/LocalDiskFileStorage.cs index 6e9aaea..b591916 100644 --- a/VoidCat/Services/Files/LocalDiskFileStorage.cs +++ b/VoidCat/Services/Files/LocalDiskFileStorage.cs @@ -31,6 +31,9 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore await EgressFromStream(fs, request, outStream, cts); } + /// + public string Key => "local-disk"; + /// public async ValueTask Ingress(IngressPayload payload, CancellationToken cts) { diff --git a/VoidCat/Services/Files/PostgresFileMetadataStore.cs b/VoidCat/Services/Files/PostgresFileMetadataStore.cs index 712cf3f..adfa12d 100644 --- a/VoidCat/Services/Files/PostgresFileMetadataStore.cs +++ b/VoidCat/Services/Files/PostgresFileMetadataStore.cs @@ -13,7 +13,10 @@ public class PostgresFileMetadataStore : IFileMetadataStore { _connection = connection; } - + + /// + public string? Key => "postgres"; + /// public ValueTask Get(Guid id) { @@ -32,9 +35,15 @@ public class PostgresFileMetadataStore : IFileMetadataStore await using var conn = await _connection.Get(); await conn.ExecuteAsync( @"insert into -""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"") -values(:id, :name, :size, :uploaded, :description, :mimeType, :digest, :editSecret, :expires) -on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Description"" = :description, ""MimeType"" = :mimeType, ""Expires"" = :expires", +""Files""(""Id"", ""Name"", ""Size"", ""Uploaded"", ""Description"", ""MimeType"", ""Digest"", ""EditSecret"", ""Expires"", ""Storage"") +values(:id, :name, :size, :uploaded, :description, :mimeType, :digest, :editSecret, :expires, :store) +on conflict (""Id"") do update set +""Name"" = :name, +""Size"" = :size, +""Description"" = :description, +""MimeType"" = :mimeType, +""Expires"" = :expires, +""Storage"" = :store", new { id, @@ -45,7 +54,8 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Descrip mimeType = obj.MimeType, digest = obj.Digest, editSecret = obj.EditSecret, - expires = obj.Expires + expires = obj.Expires, + store = obj.Storage }); } @@ -82,6 +92,7 @@ on conflict (""Id"") do update set ""Name"" = :name, ""Size"" = :size, ""Descrip oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.Expires = meta.Expires ?? oldMeta.Expires; + oldMeta.Storage = meta.Storage ?? oldMeta.Storage; await Set(id, oldMeta); } diff --git a/VoidCat/Services/Files/S3FileMetadataStore.cs b/VoidCat/Services/Files/S3FileMetadataStore.cs index ab005ba..10087a2 100644 --- a/VoidCat/Services/Files/S3FileMetadataStore.cs +++ b/VoidCat/Services/Files/S3FileMetadataStore.cs @@ -11,16 +11,17 @@ public class S3FileMetadataStore : IFileMetadataStore private readonly ILogger _logger; private readonly AmazonS3Client _client; private readonly S3BlobConfig _config; - private readonly bool _includeUrl; - public S3FileMetadataStore(VoidSettings settings, ILogger logger) + public S3FileMetadataStore(S3BlobConfig settings, ILogger logger) { _logger = logger; - _includeUrl = settings.CloudStorage?.ServeFromCloud ?? false; - _config = settings.CloudStorage!.S3!; + _config = settings; _client = _config.CreateClient(); } + /// + public string? Key => _config.Name; + /// public ValueTask Get(Guid id) where TMeta : VoidFileMeta { @@ -53,6 +54,7 @@ public class S3FileMetadataStore : IFileMetadataStore oldMeta.Name = meta.Name ?? oldMeta.Name; oldMeta.MimeType = meta.MimeType ?? oldMeta.MimeType; oldMeta.Expires = meta.Expires ?? oldMeta.Expires; + oldMeta.Storage = meta.Storage ?? oldMeta.Storage; await Set(id, oldMeta); } @@ -141,7 +143,7 @@ public class S3FileMetadataStore : IFileMetadataStore if (ret != default) { ret.Id = id; - if (_includeUrl) + if (_config.Direct) { var ub = new UriBuilder(_config.ServiceUrl!) { diff --git a/VoidCat/Services/Files/S3FileStore.cs b/VoidCat/Services/Files/S3FileStore.cs index 032a492..5e8a37a 100644 --- a/VoidCat/Services/Files/S3FileStore.cs +++ b/VoidCat/Services/Files/S3FileStore.cs @@ -5,6 +5,7 @@ using VoidCat.Services.Abstractions; namespace VoidCat.Services.Files; +/// public class S3FileStore : StreamFileStore, IFileStore { private readonly IFileInfoManager _fileInfo; @@ -12,22 +13,28 @@ public class S3FileStore : StreamFileStore, IFileStore private readonly S3BlobConfig _config; private readonly IAggregateStatsCollector _statsCollector; - public S3FileStore(VoidSettings settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats) + public S3FileStore(S3BlobConfig settings, IAggregateStatsCollector stats, IFileInfoManager fileInfo) : base(stats) { _fileInfo = fileInfo; _statsCollector = stats; - _config = settings.CloudStorage!.S3!; + _config = settings; _client = _config.CreateClient(); } + /// + public string Key => _config.Name; + + /// public async ValueTask Ingress(IngressPayload payload, CancellationToken cts) { + if (payload.IsAppend) throw new InvalidOperationException("Cannot append to S3 store"); + var req = new PutObjectRequest { BucketName = _config.BucketName, Key = payload.Id.ToString(), InputStream = payload.InStream, - ContentType = "application/octet-stream", + ContentType = payload.Meta.MimeType ?? "application/octet-stream", AutoResetStreamPosition = false, AutoCloseStream = false, ChecksumAlgorithm = ChecksumAlgorithm.SHA256, @@ -47,6 +54,7 @@ public class S3FileStore : StreamFileStore, IFileStore return HandleCompletedUpload(payload, payload.Meta.Size); } + /// public async ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts) { await using var stream = await Open(request, cts); @@ -108,11 +116,13 @@ public class S3FileStore : StreamFileStore, IFileStore } } + /// public async ValueTask DeleteFile(Guid id) { await _client.DeleteObjectAsync(_config.BucketName, id.ToString()); } + /// public async ValueTask Open(EgressRequest request, CancellationToken cts) { var req = new GetObjectRequest() diff --git a/VoidCat/Services/Migrations/Database/02-MinorVersion1.cs b/VoidCat/Services/Migrations/Database/02-MinorVersion1.cs new file mode 100644 index 0000000..3dff2e4 --- /dev/null +++ b/VoidCat/Services/Migrations/Database/02-MinorVersion1.cs @@ -0,0 +1,37 @@ +using System.Data; +using FluentMigrator; + +namespace VoidCat.Services.Migrations.Database; + +[Migration(20220725_1137)] +public class MinorVersion1 : Migration +{ + public override void Up() + { + Create.Table("ApiKey") + .WithColumn("Id").AsGuid().PrimaryKey() + .WithColumn("UserId").AsGuid().ForeignKey("Users", "Id").OnDelete(Rule.Cascade).Indexed() + .WithColumn("Token").AsString() + .WithColumn("Expiry").AsDateTimeOffset() + .WithColumn("Created").AsDateTimeOffset().WithDefault(SystemMethods.CurrentUTCDateTime); + + Create.Column("Storage") + .OnTable("Files") + .AsString().WithDefaultValue("local-disk"); + + Create.Column("Storage") + .OnTable("Users") + .AsString().WithDefaultValue("local-disk"); + } + + public override void Down() + { + Delete.Table("ApiKey"); + + Delete.Column("Storage") + .FromTable("Files"); + + Delete.Column("Storage") + .FromTable("Users"); + } +} diff --git a/VoidCat/Services/Migrations/FixSize.cs b/VoidCat/Services/Migrations/FixSize.cs index 22869ae..2234d90 100644 --- a/VoidCat/Services/Migrations/FixSize.cs +++ b/VoidCat/Services/Migrations/FixSize.cs @@ -1,5 +1,6 @@ using VoidCat.Model; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Services.Migrations; @@ -8,9 +9,9 @@ public class FixSize : IMigration { private readonly ILogger _logger; private readonly IFileMetadataStore _fileMetadata; - private readonly IFileStore _fileStore; + private readonly FileStoreFactory _fileStore; - public FixSize(ILogger logger, IFileMetadataStore fileMetadata, IFileStore fileStore) + public FixSize(ILogger logger, IFileMetadataStore fileMetadata, FileStoreFactory fileStore) { _logger = logger; _fileMetadata = fileMetadata; diff --git a/VoidCat/Services/Migrations/MigrateToPostgres.cs b/VoidCat/Services/Migrations/MigrateToPostgres.cs index be448cb..9bd867a 100644 --- a/VoidCat/Services/Migrations/MigrateToPostgres.cs +++ b/VoidCat/Services/Migrations/MigrateToPostgres.cs @@ -18,11 +18,11 @@ public class MigrateToPostgres : IMigration private readonly IPaywallStore _paywallStore; private readonly IUserStore _userStore; private readonly IUserUploadsStore _userUploads; - private readonly IFileStore _fileStore; + private readonly FileStoreFactory _fileStore; public MigrateToPostgres(VoidSettings settings, ILogger logger, IFileMetadataStore fileMetadata, ICache cache, IPaywallStore paywallStore, IUserStore userStore, IUserUploadsStore userUploads, - IFileStore fileStore) + FileStoreFactory fileStore) { _logger = logger; _settings = settings; @@ -75,6 +75,7 @@ public class MigrateToPostgres : IMigration { var fs = await _fileStore.Open(new(file.Id, Enumerable.Empty()), CancellationToken.None); + var hash = await SHA256.Create().ComputeHashAsync(fs); file.Digest = hash.ToHex(); } @@ -143,6 +144,7 @@ public class MigrateToPostgres : IMigration Password = privateUser.Password!, Roles = privateUser.Roles }); + _logger.LogInformation("Migrated user {USer}", user.Id); } catch (Exception ex) @@ -163,4 +165,4 @@ public class MigrateToPostgres : IMigration [JsonConverter(typeof(Base58GuidConverter))] public Guid? Uploader { get; set; } } -} \ No newline at end of file +} diff --git a/VoidCat/Services/Users/CacheApiKeyStore.cs b/VoidCat/Services/Users/CacheApiKeyStore.cs new file mode 100644 index 0000000..ff56ca0 --- /dev/null +++ b/VoidCat/Services/Users/CacheApiKeyStore.cs @@ -0,0 +1,20 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users; + +/// +public class CacheApiKeyStore : BasicCacheStore, IApiKeyStore +{ + public CacheApiKeyStore(ICache cache) : base(cache) + { + } + + /// + public ValueTask> ListKeys(Guid id) + { + throw new NotImplementedException(); + } + + protected override string MapKey(Guid id) => $"api-key:{id}"; +} diff --git a/VoidCat/Services/Users/CacheUserStore.cs b/VoidCat/Services/Users/CacheUserStore.cs index 08a898e..716e810 100644 --- a/VoidCat/Services/Users/CacheUserStore.cs +++ b/VoidCat/Services/Users/CacheUserStore.cs @@ -109,6 +109,18 @@ public class CacheUserStore : IUserStore await Set(user.Id, user); } } + + /// + public async ValueTask AdminUpdateUser(PrivateVoidUser user) + { + var oldUser = await Get(user.Id); + if (oldUser == null) return; + + oldUser.Email = user.Email; + oldUser.Storage = user.Storage; + + await Set(oldUser.Id, oldUser); + } /// public async ValueTask Delete(Guid id) diff --git a/VoidCat/Services/Users/PostgresApiKeyStore.cs b/VoidCat/Services/Users/PostgresApiKeyStore.cs new file mode 100644 index 0000000..59ce27b --- /dev/null +++ b/VoidCat/Services/Users/PostgresApiKeyStore.cs @@ -0,0 +1,59 @@ +using Dapper; +using VoidCat.Model; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Users; + +/// +public class PostgresApiKeyStore : IApiKeyStore +{ + private readonly PostgresConnectionFactory _factory; + + public PostgresApiKeyStore(PostgresConnectionFactory factory) + { + _factory = factory; + } + + /// + public async ValueTask Get(Guid id) + { + await using var conn = await _factory.Get(); + return await conn.QuerySingleOrDefaultAsync(@"select * from ""ApiKey"" where ""Id"" = :id", new {id}); + } + + /// + public async ValueTask> Get(Guid[] ids) + { + await using var conn = await _factory.Get(); + return (await conn.QueryAsync(@"select * from ""ApiKey"" where ""Id"" in :ids", new {ids})).ToList(); + } + + /// + public async ValueTask Add(Guid id, ApiKey obj) + { + await using var conn = await _factory.Get(); + await conn.ExecuteAsync(@"insert into ""ApiKey""(""Id"", ""UserId"", ""Token"", ""Expiry"") +values(:id, :userId, :token, :expiry)", new + { + id = obj.Id, + userId = obj.UserId, + token = obj.Token, + expiry = obj.Expiry.ToUniversalTime() + }); + } + + /// + public async ValueTask Delete(Guid id) + { + await using var conn = await _factory.Get(); + await conn.ExecuteAsync(@"delete from ""ApiKey"" where ""Id"" = :id", new {id}); + } + + /// + public async ValueTask> ListKeys(Guid id) + { + await using var conn = await _factory.Get(); + return (await conn.QueryAsync(@"select ""Id"", ""UserId"", ""Expiry"", ""Created"" from ""ApiKey"" where ""UserId"" = :id", new {id})) + .ToList(); + } +} diff --git a/VoidCat/Services/Users/PostgresUserStore.cs b/VoidCat/Services/Users/PostgresUserStore.cs index f449acc..02a88cc 100644 --- a/VoidCat/Services/Users/PostgresUserStore.cs +++ b/VoidCat/Services/Users/PostgresUserStore.cs @@ -43,8 +43,9 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla displayName = obj.DisplayName, lastLogin = obj.LastLogin.ToUniversalTime(), avatar = obj.Avatar, - flags = (int) obj.Flags + flags = (int)obj.Flags }); + if (obj.Roles.Any(a => a != Roles.User)) { foreach (var r in obj.Roles.Where(a => a != Roles.User)) @@ -69,11 +70,13 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla await using var conn = await _connection.Get(); var user = await conn.QuerySingleOrDefaultAsync(@"select * from ""Users"" where ""Id"" = :id", new {id}); + if (user != default) { var roles = await conn.QueryAsync( @"select ""Role"" from ""UserRoles"" where ""User"" = :id", new {id}); + foreach (var r in roles) { user.Roles.Add(r); @@ -106,11 +109,13 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla PagedSortBy.Name => "DisplayName", _ => "Id" }; + var sortBy = request.SortOrder switch { PageSortOrder.Dsc => "desc", _ => "asc" }; + await using var iconn = await _connection.Get(); var users = await iconn.ExecuteReaderAsync( $@"select * from ""Users"" order by ""{orderBy}"" {sortBy} offset :offset limit :limit", @@ -119,6 +124,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla offset = request.PageSize * request.Page, limit = request.PageSize }); + var rowParser = users.GetRowParser(); while (await users.ReadAsync()) { @@ -144,7 +150,7 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla var emailFlag = oldUser.Flags.HasFlag(VoidUserFlags.EmailVerified) ? VoidUserFlags.EmailVerified : 0; await using var conn = await _connection.Get(); await conn.ExecuteAsync( - @"update ""Users"" set ""DisplayName"" = @displayName, ""Avatar"" = @avatar, ""Flags"" = :flags where ""Id"" = :id", + @"update ""Users"" set ""DisplayName"" = :displayName, ""Avatar"" = :avatar, ""Flags"" = :flags where ""Id"" = :id", new { id = newUser.Id, @@ -161,4 +167,18 @@ values(:id, :email, :password, :created, :lastLogin, :displayName, :avatar, :fla await conn.ExecuteAsync(@"update ""Users"" set ""LastLogin"" = :timestamp where ""Id"" = :id", new {id, timestamp}); } -} \ No newline at end of file + + /// + public async ValueTask AdminUpdateUser(PrivateVoidUser user) + { + await using var conn = await _connection.Get(); + await conn.ExecuteAsync( + @"update ""Users"" set ""Email"" = :email, ""Storage"" = :storage where ""Id"" = :id", + new + { + id = user.Id, + email = user.Email, + storage = user.Storage + }); + } +} diff --git a/VoidCat/Services/Users/UsersStartup.cs b/VoidCat/Services/Users/UsersStartup.cs index 53b22e7..1b88548 100644 --- a/VoidCat/Services/Users/UsersStartup.cs +++ b/VoidCat/Services/Users/UsersStartup.cs @@ -9,10 +9,11 @@ public static class UsersStartup { services.AddTransient(); - if (settings.Postgres != default) + if (settings.HasPostgres()) { services.AddTransient(); services.AddTransient(); + services.AddTransient(); } else { diff --git a/VoidCat/Services/VirusScanner/ClamAvScanner.cs b/VoidCat/Services/VirusScanner/ClamAvScanner.cs index 79e2f43..c564e37 100644 --- a/VoidCat/Services/VirusScanner/ClamAvScanner.cs +++ b/VoidCat/Services/VirusScanner/ClamAvScanner.cs @@ -1,6 +1,7 @@ using nClam; using VoidCat.Model; using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; namespace VoidCat.Services.VirusScanner; @@ -11,13 +12,13 @@ public class ClamAvScanner : IVirusScanner { private readonly ILogger _logger; private readonly IClamClient _clam; - private readonly IFileStore _store; + private readonly FileStoreFactory _fileSystemFactory; - public ClamAvScanner(ILogger logger, IClamClient clam, IFileStore store) + public ClamAvScanner(ILogger logger, IClamClient clam, FileStoreFactory fileSystemFactory) { _logger = logger; _clam = clam; - _store = store; + _fileSystemFactory = fileSystemFactory; } /// @@ -25,7 +26,7 @@ public class ClamAvScanner : IVirusScanner { _logger.LogInformation("Starting scan of {Filename}", id); - await using var fs = await _store.Open(new(id, Enumerable.Empty()), cts); + await using var fs = await _fileSystemFactory.Open(new(id, Enumerable.Empty()), cts); var result = await _clam.SendAndScanFileAsync(fs, cts); if (result.Result == ClamScanResults.Error) diff --git a/VoidCat/VoidCat.csproj b/VoidCat/VoidCat.csproj index 23a6728..3ff247f 100644 --- a/VoidCat/VoidCat.csproj +++ b/VoidCat/VoidCat.csproj @@ -10,7 +10,7 @@ True $(DefineConstants);HostSPA $(AssemblyName).xml - 4.0.0 + 4.1.0 diff --git a/VoidCat/spa/src/Admin/Admin.css b/VoidCat/spa/src/Admin/Admin.css index ae1361a..776eaf6 100644 --- a/VoidCat/spa/src/Admin/Admin.css +++ b/VoidCat/spa/src/Admin/Admin.css @@ -9,23 +9,6 @@ padding: 10px; } -.admin table { - width: 100%; - word-break: keep-all; - text-overflow: ellipsis; - white-space: nowrap; - border-collapse: collapse; -} - -.admin table th { - background-color: #222; - text-align: start; -} - -.admin table tr:nth-child(2n) { - background-color: #111; -} - .admin .btn { padding: 5px 8px; border-radius: 3px; diff --git a/VoidCat/spa/src/Admin/Admin.js b/VoidCat/spa/src/Admin/Admin.js index d448c95..a4ba857 100644 --- a/VoidCat/spa/src/Admin/Admin.js +++ b/VoidCat/spa/src/Admin/Admin.js @@ -5,17 +5,20 @@ import {UserList} from "./UserList"; import {Navigate} from "react-router-dom"; import {useApi} from "../Api"; import {VoidButton} from "../VoidButton"; +import {useState} from "react"; +import VoidModal from "../VoidModal"; +import EditUser from "./EditUser"; export function Admin() { const auth = useSelector((state) => state.login.jwt); const {AdminApi} = useApi(); - + const [editUser, setEditUser] = useState(null); async function deleteFile(e, id) { if (window.confirm(`Are you sure you want to delete: ${id}?`)) { let req = await AdminApi.deleteFile(id); if (req.ok) { - + } else { alert("Failed to delete file!"); } @@ -28,7 +31,10 @@ export function Admin() { return (

Users

- + [ + Delete, + setEditUser(i)}>Edit + ]}/>

Files

{ @@ -36,6 +42,11 @@ export function Admin() { deleteFile(e, i.id)}>Delete }}/> + + {editUser !== null ? + + setEditUser(null)}/> + : null}
); } diff --git a/VoidCat/spa/src/Admin/EditUser.js b/VoidCat/spa/src/Admin/EditUser.js new file mode 100644 index 0000000..f37c858 --- /dev/null +++ b/VoidCat/spa/src/Admin/EditUser.js @@ -0,0 +1,46 @@ +import {VoidButton} from "../VoidButton"; +import {useState} from "react"; +import {useSelector} from "react-redux"; +import {useApi} from "../Api"; + +export default function EditUser(props) { + const user = props.user; + const onClose = props.onClose; + + const adminApi = useApi().AdminApi; + const fileStores = useSelector((state) => state.info?.stats?.fileStores ?? ["local-disk"]) + const [storage, setStorage] = useState(user.storage); + const [email, setEmail] = useState(user.email); + + async function updateUser() { + await adminApi.updateUser({ + id: user.id, + email, + storage + }); + onClose(); + } + + return ( + <> + Editing user '{user.displayName}' ({user.id}) +
+
Email:
+
setEmail(e.target.value)}/>
+ +
File storage:
+
+ +
+ +
Roles:
+
{user.roles.map(e => {e})}
+
+ updateUser()}>Save + onClose()}>Cancel + + ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/Admin/UserList.js b/VoidCat/spa/src/Admin/UserList.js index 099b5d5..d6d19e1 100644 --- a/VoidCat/spa/src/Admin/UserList.js +++ b/VoidCat/spa/src/Admin/UserList.js @@ -5,15 +5,15 @@ import {useApi} from "../Api"; import {logout} from "../LoginState"; import {PageSelector} from "../PageSelector"; import moment from "moment"; -import {VoidButton} from "../VoidButton"; -export function UserList() { +export function UserList(props) { const {AdminApi} = useApi(); const dispatch = useDispatch(); const [users, setUsers] = useState(); const [page, setPage] = useState(0); const pageSize = 10; const [accessDenied, setAccessDenied] = useState(); + const actions = props.actions; async function loadUserList() { let pageReq = { @@ -40,10 +40,7 @@ export function UserList() { {moment(user.created).fromNow()} {moment(user.lastLogin).fromNow()} {obj.uploads} - - Delete - SetRoles - + {actions(user)} ); } diff --git a/VoidCat/spa/src/Api.js b/VoidCat/spa/src/Api.js index b33b33e..9a227f2 100644 --- a/VoidCat/spa/src/Api.js +++ b/VoidCat/spa/src/Api.js @@ -27,7 +27,8 @@ export function useApi() { AdminApi: { fileList: (pageReq) => getJson("POST", "/admin/file", pageReq, auth), deleteFile: (id) => getJson("DELETE", `/admin/file/${id}`, undefined, auth), - userList: (pageReq) => getJson("POST", `/admin/user`, pageReq, auth) + userList: (pageReq) => getJson("POST", `/admin/user`, pageReq, auth), + updateUser: (user) => getJson("POST", `/admin/user/${user.id}`, user, auth) }, Api: { info: () => getJson("GET", "/info"), @@ -42,7 +43,9 @@ export function useApi() { listUserFiles: (uid, pageReq) => getJson("POST", `/user/${uid}/files`, pageReq, auth), submitVerifyCode: (uid, code) => getJson("POST", `/user/${uid}/verify`, code, auth), sendNewCode: (uid) => getJson("GET", `/user/${uid}/verify`, undefined, auth), - updateMetadata: (id, meta) => getJson("POST", `/upload/${id}/meta`, meta, auth) + updateMetadata: (id, meta) => getJson("POST", `/upload/${id}/meta`, meta, auth), + listApiKeys: () => getJson("GET", `/auth/api-key`, undefined, auth), + createApiKey: (req) => getJson("POST", `/auth/api-key`, req, auth) } }; } \ No newline at end of file diff --git a/VoidCat/spa/src/ApiKeyList.js b/VoidCat/spa/src/ApiKeyList.js new file mode 100644 index 0000000..b0cdead --- /dev/null +++ b/VoidCat/spa/src/ApiKeyList.js @@ -0,0 +1,69 @@ +import {useApi} from "./Api"; +import {useEffect, useState} from "react"; +import {VoidButton} from "./VoidButton"; +import moment from "moment"; +import VoidModal from "./VoidModal"; + +export default function ApiKeyList() { + const {Api} = useApi(); + const [apiKeys, setApiKeys] = useState([]); + const [newApiKey, setNewApiKey] = useState(); + const DefaultExpiry = 1000 * 60 * 60 * 24 * 90; + + async function loadApiKeys() { + let keys = await Api.listApiKeys(); + setApiKeys(await keys.json()); + } + + async function createApiKey() { + let rsp = await Api.createApiKey({ + expiry: new Date(new Date().getTime() + DefaultExpiry) + }); + setNewApiKey(await rsp.json()); + } + + useEffect(() => { + if (Api) { + loadApiKeys(); + } + }, []); + + return ( + <> +
+
+

API Keys

+
+
+ createApiKey()}>+New +
+
+ + + + + + + + + + + {apiKeys.map(e => + + + + + )} + +
IdCreatedExpiryActions
{e.id}{moment(e.created).fromNow()}{moment(e.expiry).fromNow()} + Delete +
+ {newApiKey ? + + Please save this now as it will not be shown again: +
{newApiKey.token}
+ setNewApiKey(undefined)}>Close +
: null} + + ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/FileList.css b/VoidCat/spa/src/FileList.css deleted file mode 100644 index 2b153b0..0000000 --- a/VoidCat/spa/src/FileList.css +++ /dev/null @@ -1,16 +0,0 @@ -table.file-list { - width: 100%; - word-break: keep-all; - text-overflow: ellipsis; - white-space: nowrap; - border-collapse: collapse; -} - -table.file-list tr:nth-child(2n) { - background-color: #111; -} - -table.file-list th { - background-color: #222; - text-align: start; -} \ No newline at end of file diff --git a/VoidCat/spa/src/FileList.js b/VoidCat/spa/src/FileList.js index 85cfe92..ac6be1c 100644 --- a/VoidCat/spa/src/FileList.js +++ b/VoidCat/spa/src/FileList.js @@ -1,4 +1,3 @@ -import "./FileList.css"; import moment from "moment"; import {Link} from "react-router-dom"; import {useDispatch} from "react-redux"; @@ -59,7 +58,7 @@ export function FileList(props) { } return ( - +
diff --git a/VoidCat/spa/src/Profile.js b/VoidCat/spa/src/Profile.js index 04c47a0..d404aa9 100644 --- a/VoidCat/spa/src/Profile.js +++ b/VoidCat/spa/src/Profile.js @@ -10,6 +10,7 @@ import {buf2hex, hasFlag} from "./Util"; import moment from "moment"; import {FileList} from "./FileList"; import {VoidButton} from "./VoidButton"; +import ApiKeyList from "./ApiKeyList"; export function Profile() { const [profile, setProfile] = useState(); @@ -210,6 +211,7 @@ export function Profile() { {needsEmailVerify ? renderEmailVerify() : null}

Uploads

Api.listUserFiles(profile.id, req)}/> + {cantEditProfile ? : null} ); diff --git a/VoidCat/spa/src/VoidModal.css b/VoidCat/spa/src/VoidModal.css new file mode 100644 index 0000000..bb09e4f --- /dev/null +++ b/VoidCat/spa/src/VoidModal.css @@ -0,0 +1,35 @@ +.modal-bg { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; +} + +.modal-bg .modal { + min-height: 100px; + min-width: 300px; + background-color: #bbb; + color: #000; + border-radius: 10px; + overflow: hidden; +} + +.modal-bg .modal .modal-header { + text-align: center; + border-bottom: 1px solid; + margin: 0; + line-height: 2em; + background-color: #222; + color: #bbb; + font-weight: bold; + text-transform: uppercase; +} + +.modal-bg .modal .modal-body { + padding: 10px; +} \ No newline at end of file diff --git a/VoidCat/spa/src/VoidModal.js b/VoidCat/spa/src/VoidModal.js new file mode 100644 index 0000000..c3b90b9 --- /dev/null +++ b/VoidCat/spa/src/VoidModal.js @@ -0,0 +1,19 @@ +import "./VoidModal.css"; + +export default function VoidModal(props) { + const title = props.title; + const style = props.style; + + return ( +
+
+
+ {title ?? "Unknown modal"} +
+
+ {props.children ?? "Missing body"} +
+
+
+ ) +} \ No newline at end of file diff --git a/VoidCat/spa/src/index.css b/VoidCat/spa/src/index.css index 22ef33b..462da48 100644 --- a/VoidCat/spa/src/index.css +++ b/VoidCat/spa/src/index.css @@ -67,4 +67,29 @@ input[type="text"], input[type="number"], input[type="password"], select { padding: 10px 20px; margin: 5px; border: 0; +} + +table { + width: 100%; + word-break: keep-all; + text-overflow: ellipsis; + white-space: nowrap; + border-collapse: collapse; +} + +table tr:nth-child(2n) { + background-color: #111; +} + +table th { + background-color: #222; + text-align: start; +} + +pre.copy { + user-select: all; + width: fit-content; + border-radius: 4px; + border: 1px solid; + padding: 5px; } \ No newline at end of file
Id