mirror of
https://git.v0l.io/Kieran/void.cat.git
synced 2025-04-09 08:48:00 +02:00
Add user list
This commit is contained in:
parent
783b69dda3
commit
7dfffc3779
@ -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();
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -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)
|
||||
{
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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}";
|
||||
|
@ -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;
|
||||
}
|
@ -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/>
|
||||
|
||||
|
@ -1,6 +0,0 @@
|
||||
table.file-list {
|
||||
width: 100%;
|
||||
word-break: keep-all;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 = {
|
||||
|
Loading…
x
Reference in New Issue
Block a user