Add user list

This commit is contained in:
Kieran 2022-02-24 12:00:28 +00:00
parent 783b69dda3
commit 7dfffc3779
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
15 changed files with 218 additions and 52 deletions

View File

@ -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<RenderedResults<PublicVoidFile>> ListFiles([FromBody] PagedRequest request)
public async Task<RenderedResults<PublicVoidFile>> 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());
}
}
[HttpPost]
[Route("user")]
public async Task<RenderedResults<PublicVoidUser>> ListUsers([FromBody] PagedRequest request)
{
var result = await _userStore.ListUsers(request);
return await result.GetResults();
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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<string> Roles { get; init; } = Enumerable.Empty<string>();
protected VoidUser(Guid id, string email)
{
Id = id;
Email = email;
}
[JsonConverter(typeof(Base58GuidConverter))]
public Guid Id { get; }
public string Email { get; }
public HashSet<string> 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)
{
}
}

View File

@ -10,7 +10,7 @@ public interface IFileStore
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
PagedResult<PublicVoidFile> ListFiles(PagedRequest request);
ValueTask<PagedResult<PublicVoidFile>> ListFiles(PagedRequest request);
ValueTask DeleteFile(Guid id);
}

View File

@ -5,7 +5,7 @@ namespace VoidCat.Services.Abstractions;
public interface IUserStore
{
ValueTask<Guid?> LookupUser(string email);
ValueTask<VoidUser?> Get(Guid id);
ValueTask Set(VoidUser user);
IAsyncEnumerable<VoidUser> ListUsers(CancellationToken cts);
ValueTask<T?> Get<T>(Guid id) where T : VoidUser;
ValueTask Set(PrivateVoidUser user);
ValueTask<PagedResult<PublicVoidUser>> ListUsers(PagedRequest request);
}

View File

@ -119,10 +119,11 @@ public class LocalDiskFileStore : IFileStore
};
}
public PagedResult<PublicVoidFile> ListFiles(PagedRequest request)
public ValueTask<PagedResult<PublicVoidFile>> 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<PublicVoidFile>()
{
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());
}
}

View File

@ -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<VoidUser> 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<PrivateVoidUser>(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<VoidUser> 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;
}

View File

@ -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<Guid>(MapKey(email));
}
public async ValueTask<VoidUser?> Get(Guid id)
public async ValueTask<T?> Get<T>(Guid id) where T : VoidUser
{
return await _cache.Get<VoidUser>(MapKey(id));
return await _cache.Get<T>(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<VoidUser> ListUsers([EnumeratorCancellation] CancellationToken cts = default)
public async ValueTask<PagedResult<PublicVoidUser>> 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<PublicVoidUser> EnumerateUsers(IEnumerable<Guid> ids)
{
var usersLoaded = await Task.WhenAll(ids.Select(async a => await Get<PublicVoidUser>(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}";

View File

@ -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;
}

View File

@ -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 <Login/>;
} else {
return (
<div className="admin">
<h2>Admin</h2>
<button onClick={() => dispatch(logout())}>Logout</button>
<h4>Users</h4>
<UserList/>

View File

@ -1,6 +0,0 @@
table.file-list {
width: 100%;
word-break: keep-all;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@ -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 <h3>Access Denied</h3>;
}
return (
<table className="file-list">
<thead>
@ -88,7 +93,8 @@ export function FileList(props) {
<tbody>
<tr>
<td colSpan={999}>{files ?
<PageSelector onSelectPage={(x) => setPage(x)} page={page} total={files.totalResults} pageSize={pageSize}/> : null}</td>
<PageSelector onSelectPage={(x) => setPage(x)} page={page} total={files.totalResults}
pageSize={pageSize}/> : null}</td>
</tr>
</tbody>
</table>

View File

@ -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 (
<tr>
<td><a href={`/u/${u.id}`}>{u.id.substring(0, 4)}..</a></td>
<td>{moment(u.created).fromNow()}</td>
<td>{moment(u.lastLogin).fromNow()}</td>
<td>0</td>
<td>{u.roles.join(", ")}</td>
<td>
<button>Delete</button>
<button>SetRoles</button>
</td>
</tr>
);
}
useEffect(() => {
loadUserList();
}, [page]);
if (accessDenied === true) {
return <h3>Access Denied</h3>;
}
return (
<table></table>
<table>
<thead>
<tr>
<td>Id</td>
<td>Created</td>
<td>Last Login</td>
<td>Files</td>
<td>Roles</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
{users ? users.results.map(renderUser) : null}
</tbody>
<tbody>
<tr>
<td>
{users ? <PageSelector onSelectPage={(x) => setPage(x)} page={page} total={users.totalResults}
pageSize={pageSize}/> : null}
</td>
</tr>
</tbody>
</table>
);
}

View File

@ -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 = {