From 7dfffc3779157348312f3dca0deaa758c2cad2a2 Mon Sep 17 00:00:00 2001 From: Kieran Date: Thu, 24 Feb 2022 12:00:28 +0000 Subject: [PATCH] Add user list --- VoidCat/Controllers/Admin/AdminController.cs | 18 ++++- VoidCat/Controllers/StatsController.cs | 2 +- VoidCat/Model/Extensions.cs | 2 +- VoidCat/Model/VoidUser.cs | 38 ++++++++- VoidCat/Services/Abstractions/IFileStore.cs | 2 +- VoidCat/Services/Abstractions/IUserStore.cs | 6 +- .../Services/Files/LocalDiskFileStorage.cs | 20 ++--- VoidCat/Services/Users/UserManager.cs | 28 ++++++- VoidCat/Services/Users/UserStore.cs | 37 ++++++--- VoidCat/spa/src/Admin/Admin.css | 7 ++ VoidCat/spa/src/Admin/Admin.js | 9 ++- VoidCat/spa/src/Admin/FileList.css | 6 -- VoidCat/spa/src/Admin/FileList.js | 12 ++- VoidCat/spa/src/Admin/UserList.js | 80 ++++++++++++++++++- VoidCat/spa/src/Api.js | 3 +- 15 files changed, 218 insertions(+), 52 deletions(-) delete mode 100644 VoidCat/spa/src/Admin/FileList.css diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index f695b9b..ef22b81 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -10,17 +10,19 @@ namespace VoidCat.Controllers.Admin; public class AdminController : Controller { private readonly IFileStore _fileStore; + private readonly IUserStore _userStore; - public AdminController(IFileStore fileStore) + public AdminController(IFileStore fileStore, IUserStore userStore) { _fileStore = fileStore; + _userStore = userStore; } [HttpPost] [Route("file")] - public Task> ListFiles([FromBody] PagedRequest request) + public async Task> ListFiles([FromBody] PagedRequest request) { - return _fileStore.ListFiles(request).GetResults(); + return await (await _fileStore.ListFiles(request)).GetResults(); } [HttpDelete] @@ -29,4 +31,12 @@ public class AdminController : Controller { return _fileStore.DeleteFile(id.FromBase58Guid()); } -} \ No newline at end of file + + [HttpPost] + [Route("user")] + public async Task> ListUsers([FromBody] PagedRequest request) + { + var result = await _userStore.ListUsers(request); + return await result.GetResults(); + } +} diff --git a/VoidCat/Controllers/StatsController.cs b/VoidCat/Controllers/StatsController.cs index 0affa63..4dd32f3 100644 --- a/VoidCat/Controllers/StatsController.cs +++ b/VoidCat/Controllers/StatsController.cs @@ -24,7 +24,7 @@ namespace VoidCat.Controllers var bw = await _statsReporter.GetBandwidth(); var bytes = 0UL; var count = 0; - var files = _fileStore.ListFiles(new(0, Int32.MaxValue)); + var files = await _fileStore.ListFiles(new(0, Int32.MaxValue)); await foreach (var vf in files.Results) { bytes += vf.Metadata?.Size ?? 0; diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index a3d6789..bcb2453 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -147,7 +147,7 @@ public static class Extensions throw new ArgumentException("Unknown algo", nameof(algo)); } - public static bool CheckPassword(this VoidUser vu, string password) + public static bool CheckPassword(this PrivateVoidUser vu, string password) { var hashParts = vu.PasswordHash.Split(":"); return vu.PasswordHash == password.HashPassword(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null); diff --git a/VoidCat/Model/VoidUser.cs b/VoidCat/Model/VoidUser.cs index e549612..179a67c 100644 --- a/VoidCat/Model/VoidUser.cs +++ b/VoidCat/Model/VoidUser.cs @@ -1,6 +1,40 @@ +using Newtonsoft.Json; + namespace VoidCat.Model; -public sealed record VoidUser(Guid Id, string Email, string PasswordHash) +public abstract class VoidUser { - public IEnumerable Roles { get; init; } = Enumerable.Empty(); + protected VoidUser(Guid id, string email) + { + Id = id; + Email = email; + } + + [JsonConverter(typeof(Base58GuidConverter))] + public Guid Id { get; } + + public string Email { get; } + + public HashSet Roles { get; init; } = new() { Model.Roles.User }; + + public DateTimeOffset Created { get; init; } + + public DateTimeOffset LastLogin { get; set; } } + +public sealed class PrivateVoidUser : VoidUser +{ + public PrivateVoidUser(Guid id, string email, string passwordHash) : base(id, email) + { + PasswordHash = passwordHash; + } + + public string PasswordHash { get; } +} + +public sealed class PublicVoidUser : VoidUser +{ + public PublicVoidUser(Guid id, string email) : base(id, email) + { + } +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IFileStore.cs b/VoidCat/Services/Abstractions/IFileStore.cs index bf8e560..0bdcf5b 100644 --- a/VoidCat/Services/Abstractions/IFileStore.cs +++ b/VoidCat/Services/Abstractions/IFileStore.cs @@ -10,7 +10,7 @@ public interface IFileStore ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts); - PagedResult ListFiles(PagedRequest request); + ValueTask> ListFiles(PagedRequest request); ValueTask DeleteFile(Guid id); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IUserStore.cs b/VoidCat/Services/Abstractions/IUserStore.cs index dfe91de..e59fef6 100644 --- a/VoidCat/Services/Abstractions/IUserStore.cs +++ b/VoidCat/Services/Abstractions/IUserStore.cs @@ -5,7 +5,7 @@ namespace VoidCat.Services.Abstractions; public interface IUserStore { ValueTask LookupUser(string email); - ValueTask Get(Guid id); - ValueTask Set(VoidUser user); - IAsyncEnumerable ListUsers(CancellationToken cts); + ValueTask Get(Guid id) where T : VoidUser; + ValueTask Set(PrivateVoidUser user); + ValueTask> ListUsers(PagedRequest request); } \ No newline at end of file diff --git a/VoidCat/Services/Files/LocalDiskFileStorage.cs b/VoidCat/Services/Files/LocalDiskFileStorage.cs index 058619d..206d263 100644 --- a/VoidCat/Services/Files/LocalDiskFileStorage.cs +++ b/VoidCat/Services/Files/LocalDiskFileStorage.cs @@ -119,10 +119,11 @@ public class LocalDiskFileStore : IFileStore }; } - public PagedResult ListFiles(PagedRequest request) + public ValueTask> ListFiles(PagedRequest request) { var files = Directory.EnumerateFiles(_settings.DataDirectory) .Where(a => !Path.HasExtension(a)); + files = (request.SortBy, request.SortOrder) switch { (PagedSortBy.Id, PageSortOrder.Asc) => files.OrderBy(a => @@ -143,6 +144,7 @@ public class LocalDiskFileStore : IFileStore foreach (var file in page) { if (!Guid.TryParse(Path.GetFileNameWithoutExtension(file), out var gid)) continue; + var loaded = await Get(gid); if (loaded != default) { @@ -151,13 +153,13 @@ public class LocalDiskFileStore : IFileStore } } - return new() + 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 async ValueTask DeleteFile(Guid id) @@ -190,9 +192,9 @@ public class LocalDiskFileStore : IFileStore var totalRead = readLength + offset; var buf = buffer.Memory[..totalRead]; await fs.WriteAsync(buf, cts); - await _stats.TrackIngress(id, (ulong) buf.Length); + await _stats.TrackIngress(id, (ulong)buf.Length); sha.TransformBlock(buf.ToArray(), 0, buf.Length, null, 0); - total += (ulong) buf.Length; + total += (ulong)buf.Length; offset = 0; } @@ -216,7 +218,7 @@ public class LocalDiskFileStore : IFileStore var fullSize = readLength + offset; await outStream.WriteAsync(buffer.Memory[..fullSize], cts); - await _stats.TrackEgress(id, (ulong) fullSize); + await _stats.TrackEgress(id, (ulong)fullSize); await outStream.FlushAsync(cts); offset = 0; } @@ -244,8 +246,8 @@ public class LocalDiskFileStore : IFileStore var fullSize = readLength + offset; var toWrite = Math.Min(fullSize, dataRemaining); - await outStream.WriteAsync(buffer.Memory[..(int) toWrite], cts); - await _stats.TrackEgress(id, (ulong) toWrite); + await outStream.WriteAsync(buffer.Memory[..(int)toWrite], cts); + await _stats.TrackEgress(id, (ulong)toWrite); await outStream.FlushAsync(cts); dataRemaining -= toWrite; offset = 0; @@ -260,4 +262,4 @@ public class LocalDiskFileStore : IFileStore private string MapPath(Guid id) => Path.Join(_settings.DataDirectory, id.ToString()); -} \ No newline at end of file +} diff --git a/VoidCat/Services/Users/UserManager.cs b/VoidCat/Services/Users/UserManager.cs index a68ef5c..31930eb 100644 --- a/VoidCat/Services/Users/UserManager.cs +++ b/VoidCat/Services/Users/UserManager.cs @@ -6,29 +6,49 @@ namespace VoidCat.Services.Users; public class UserManager : IUserManager { private readonly IUserStore _store; + private static bool _checkFirstRegister; public UserManager(IUserStore store) { _store = store; } - + public async ValueTask Login(string email, string password) { var userId = await _store.LookupUser(email); if (!userId.HasValue) throw new InvalidOperationException("User does not exist"); - var user = await _store.Get(userId.Value); + var user = await _store.Get(userId.Value); if (!(user?.CheckPassword(password) ?? false)) throw new InvalidOperationException("User does not exist"); + user.LastLogin = DateTimeOffset.UtcNow; + await _store.Set(user); + return user; } - + public async ValueTask Register(string email, string password) { var existingUser = await _store.LookupUser(email); if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists"); - var newUser = new VoidUser(Guid.NewGuid(), email, password.HashPassword()); + var newUser = new PrivateVoidUser(Guid.NewGuid(), email, password.HashPassword()) + { + Created = DateTimeOffset.UtcNow, + LastLogin = DateTimeOffset.UtcNow + }; + + // automatically set first user to admin + if (!_checkFirstRegister) + { + _checkFirstRegister = true; + var users = await _store.ListUsers(new(0, 1)); + if (users.TotalResults == 0) + { + newUser.Roles.Add(Roles.Admin); + } + } + await _store.Set(newUser); return newUser; } diff --git a/VoidCat/Services/Users/UserStore.cs b/VoidCat/Services/Users/UserStore.cs index 3054569..9fa32a0 100644 --- a/VoidCat/Services/Users/UserStore.cs +++ b/VoidCat/Services/Users/UserStore.cs @@ -1,4 +1,3 @@ -using System.Runtime.CompilerServices; using VoidCat.Model; using VoidCat.Services.Abstractions; @@ -19,35 +18,47 @@ public class UserStore : IUserStore return await _cache.Get(MapKey(email)); } - public async ValueTask Get(Guid id) + public async ValueTask Get(Guid id) where T : VoidUser { - return await _cache.Get(MapKey(id)); + return await _cache.Get(MapKey(id)); } - public async ValueTask Set(VoidUser user) + public async ValueTask Set(PrivateVoidUser user) { await _cache.Set(MapKey(user.Id), user); await _cache.AddToList(UserList, user.Id.ToString()); await _cache.Set(MapKey(user.Email), user.Id.ToString()); } - public async IAsyncEnumerable ListUsers([EnumeratorCancellation] CancellationToken cts = default) + public async ValueTask> ListUsers(PagedRequest request) { var users = (await _cache.GetList(UserList))?.Select(Guid.Parse); - if (users != default) + users = (request.SortBy, request.SortOrder) switch { - while (!cts.IsCancellationRequested) + (PagedSortBy.Id, PageSortOrder.Asc) => users?.OrderBy(a => a), + (PagedSortBy.Id, PageSortOrder.Dsc) => users?.OrderByDescending(a => a), + _ => users + }; + + async IAsyncEnumerable EnumerateUsers(IEnumerable ids) + { + var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get(a))); + foreach (var user in usersLoaded) { - var loadUsers = await Task.WhenAll(users.Select(async a => await Get(a))); - foreach (var user in loadUsers) + if (user != default) { - if (user != default) - { - yield return user; - } + yield return user; } } } + + return new() + { + Page = request.Page, + PageSize = request.PageSize, + TotalResults = users?.Count() ?? 0, + Results = EnumerateUsers(users?.Skip(request.PageSize * request.Page).Take(request.PageSize)) + }; } private static string MapKey(Guid id) => $"user:{id}"; diff --git a/VoidCat/spa/src/Admin/Admin.css b/VoidCat/spa/src/Admin/Admin.css index 0c91164..c6c2cbd 100644 --- a/VoidCat/spa/src/Admin/Admin.css +++ b/VoidCat/spa/src/Admin/Admin.css @@ -2,4 +2,11 @@ width: 1024px; margin-left: auto; margin-right: auto; +} + +.admin table { + width: 100%; + word-break: keep-all; + text-overflow: ellipsis; + white-space: nowrap; } \ No newline at end of file diff --git a/VoidCat/spa/src/Admin/Admin.js b/VoidCat/spa/src/Admin/Admin.js index 836e506..b4cfa56 100644 --- a/VoidCat/spa/src/Admin/Admin.js +++ b/VoidCat/spa/src/Admin/Admin.js @@ -1,20 +1,23 @@ -import {useSelector} from "react-redux"; +import {useDispatch, useSelector} from "react-redux"; import {Login} from "../Login"; import {FileList} from "./FileList"; import {UserList} from "./UserList"; import "./Admin.css"; +import {logout} from "../LoginState"; export function Admin() { const auth = useSelector((state) => state.login.jwt); - + const dispatch = useDispatch(); + if (!auth) { return ; } else { return (

Admin

- + +

Users

diff --git a/VoidCat/spa/src/Admin/FileList.css b/VoidCat/spa/src/Admin/FileList.css deleted file mode 100644 index 2901fda..0000000 --- a/VoidCat/spa/src/Admin/FileList.css +++ /dev/null @@ -1,6 +0,0 @@ -table.file-list { - width: 100%; - word-break: keep-all; - text-overflow: ellipsis; - white-space: nowrap; -} \ No newline at end of file diff --git a/VoidCat/spa/src/Admin/FileList.js b/VoidCat/spa/src/Admin/FileList.js index 12c3722..1b79af3 100644 --- a/VoidCat/spa/src/Admin/FileList.js +++ b/VoidCat/spa/src/Admin/FileList.js @@ -3,8 +3,6 @@ import {Link} from "react-router-dom"; import {useDispatch, useSelector} from "react-redux"; import {useEffect, useState} from "react"; import {FormatBytes} from "../Util"; - -import "./FileList.css"; import {AdminApi} from "../Api"; import {logout} from "../LoginState"; import {PagedSortBy, PageSortOrder} from "../Const"; @@ -16,6 +14,7 @@ export function FileList(props) { const [files, setFiles] = useState(); const [page, setPage] = useState(0); const pageSize = 10; + const [accessDenied, setAccessDenied] = useState(); async function loadFileList() { let pageReq = { @@ -29,6 +28,8 @@ export function FileList(props) { setFiles(await req.json()); } else if (req.status === 401) { dispatch(logout()); + } else if (req.status === 403) { + setAccessDenied(true); } } @@ -70,6 +71,10 @@ export function FileList(props) { loadFileList() }, [page]); + if (accessDenied === true) { + return

Access Denied

; + } + return ( @@ -88,7 +93,8 @@ export function FileList(props) { + setPage(x)} page={page} total={files.totalResults} + pageSize={pageSize}/> : null}
{files ? - setPage(x)} page={page} total={files.totalResults} pageSize={pageSize}/> : null}
diff --git a/VoidCat/spa/src/Admin/UserList.js b/VoidCat/spa/src/Admin/UserList.js index e801c3d..a205ea5 100644 --- a/VoidCat/spa/src/Admin/UserList.js +++ b/VoidCat/spa/src/Admin/UserList.js @@ -1,5 +1,83 @@ +import {useDispatch, useSelector} from "react-redux"; +import {useEffect, useState} from "react"; +import {PagedSortBy, PageSortOrder} from "../Const"; +import {AdminApi} from "../Api"; +import {logout} from "../LoginState"; +import {PageSelector} from "../PageSelector"; +import moment from "moment"; + export function UserList() { + const auth = useSelector((state) => state.login.jwt); + const dispatch = useDispatch(); + const [users, setUsers] = useState(); + const [page, setPage] = useState(0); + const pageSize = 10; + const [accessDenied, setAccessDenied] = useState(); + + async function loadUserList() { + let pageReq = { + page: page, + pageSize, + sortBy: PagedSortBy.Id, + sortOrder: PageSortOrder.Asc + }; + let req = await AdminApi.userList(auth, pageReq); + if (req.ok) { + setUsers(await req.json()); + } else if (req.status === 401) { + dispatch(logout()); + } else if(req.status === 403) { + setAccessDenied(true); + } + } + + function renderUser(u) { + return ( + + {u.id.substring(0, 4)}.. + {moment(u.created).fromNow()} + {moment(u.lastLogin).fromNow()} + 0 + {u.roles.join(", ")} + + + + + + ); + } + + useEffect(() => { + loadUserList(); + }, [page]); + + if (accessDenied === true) { + return

Access Denied

; + } + return ( -
+ + + + + + + + + + + + + {users ? users.results.map(renderUser) : null} + + + + + + +
IdCreatedLast LoginFilesRolesActions
+ {users ? setPage(x)} page={page} total={users.totalResults} + pageSize={pageSize}/> : null} +
); } \ No newline at end of file diff --git a/VoidCat/spa/src/Api.js b/VoidCat/spa/src/Api.js index 43f56a9..0f39993 100644 --- a/VoidCat/spa/src/Api.js +++ b/VoidCat/spa/src/Api.js @@ -18,7 +18,8 @@ export const AdminApi = { fileList: (auth, pageReq) => getJson("POST", "/admin/file", auth, pageReq), - deleteFile: (auth, id) => getJson("DELETE", `/admin/file/${id}`, auth) + deleteFile: (auth, id) => getJson("DELETE", `/admin/file/${id}`, auth), + userList: (auth, pageReq) => getJson("POST", `/admin/user`, auth, pageReq) } export const Api = {