Update admin page

This commit is contained in:
Kieran 2022-02-22 14:20:31 +00:00
parent 3bffcdeb13
commit e9a0b8fb1c
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
25 changed files with 242 additions and 53 deletions

View File

@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
namespace VoidCat.Controllers.Admin;
@ -8,5 +9,24 @@ namespace VoidCat.Controllers.Admin;
[Authorize(Policy = Policies.RequireAdmin)]
public class AdminController : Controller
{
}
private readonly IFileStore _fileStore;
public AdminController(IFileStore fileStore)
{
_fileStore = fileStore;
}
[HttpGet]
[Route("file")]
public IAsyncEnumerable<PublicVoidFile> ListFiles()
{
return _fileStore.ListFiles();
}
[HttpDelete]
[Route("file/{id}")]
public ValueTask DeleteFile([FromRoute] string id)
{
return _fileStore.DeleteFile(id.FromBase58Guid());
}
}

View File

@ -61,12 +61,13 @@ public class AuthController : Controller
var claims = new Claim[]
{
new(ClaimTypes.Sid, user.Id.ToString()),
new(ClaimTypes.Expiration, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new(ClaimTypes.AuthorizationDecision, string.Join(",", user.Roles))
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
new(JwtRegisteredClaimNames.Exp, DateTimeOffset.UtcNow.AddHours(6).ToUnixTimeSeconds().ToString()),
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()),
new(ClaimTypes.Role, string.Join(",", user.Roles))
};
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims, expires: DateTime.UtcNow.AddHours(6),
return new JwtSecurityToken(_settings.JwtSettings.Issuer, claims: claims,
signingCredentials: credentials);
}

View File

@ -117,12 +117,12 @@ public static class Extensions
case "sha256":
{
var hash = SHA256.Create().ComputeHash(bytes);
return $"sha256:${hash.ToHex()}";
return $"sha256:{hash.ToHex()}";
}
case "sha512":
{
var hash = SHA512.Create().ComputeHash(bytes);
return $"sha512:${hash.ToHex()}";
return $"sha512:{hash.ToHex()}";
}
case "pbkdf2":
{
@ -140,7 +140,7 @@ public static class Extensions
}
var pbkdf2 = new Rfc2898DeriveBytes(bytes, salt, iterations);
return $"pbkdf2:{salt.ToHex()}:${pbkdf2.GetBytes(salt.Length).ToHex()}";
return $"pbkdf2:{salt.ToHex()}:{pbkdf2.GetBytes(salt.Length).ToHex()}";
}
}

View File

@ -12,5 +12,5 @@ public record NoPaywallConfig() : PaywallConfig(PaywallServices.None, new Paywal
public record StrikePaywallConfig(PaywallMoney Cost) : PaywallConfig(PaywallServices.Strike, Cost)
{
public string Handle { get; init; }
public string Handle { get; init; } = null!;
}

View File

@ -24,9 +24,11 @@ namespace VoidCat.Model
public sealed record PublicVoidFile : VoidFile<VoidFileMeta>
{
public Bandwidth? Bandwidth { get; init; }
}
public sealed record PrivateVoidFile : VoidFile<SecretVoidFileMeta>
{
public Bandwidth? Bandwidth { get; init; }
}
}

View File

@ -1,5 +1,4 @@
using VoidCat.Services;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Strike;
namespace VoidCat.Model
{
@ -9,14 +8,18 @@ namespace VoidCat.Model
public TorSettings? TorSettings { get; init; }
public JwtSettings JwtSettings { get; init; } = new("void_cat_internal", "default_key");
public JwtSettings JwtSettings { get; init; } = new("void_cat_internal", "default_key_void_cat_host");
public string? Redis { get; init; }
public StrikeApiSettings? Strike { get; init; }
public SmtpSettings? Smtp { get; init; }
}
public sealed record TorSettings(Uri TorControl, string PrivateKey, string ControlPassword);
public sealed record JwtSettings(string Issuer, string Key);
public sealed record SmtpSettings(string Address, string Username, string Password);
}

View File

@ -5,8 +5,8 @@ using Newtonsoft.Json;
using Prometheus;
using StackExchange.Redis;
using VoidCat.Model;
using VoidCat.Services;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
using VoidCat.Services.InMemory;
using VoidCat.Services.Migrations;
using VoidCat.Services.Paywall;
@ -108,9 +108,11 @@ foreach (var migration in migrations)
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(ep =>
{
ep.MapControllers();

View File

@ -10,4 +10,6 @@ public interface IFileMetadataStore
ValueTask Set(Guid id, SecretVoidFileMeta meta);
ValueTask Update(Guid id, SecretVoidFileMeta patch);
ValueTask Delete(Guid id);
}

View File

@ -11,4 +11,6 @@ public interface IFileStore
ValueTask Egress(EgressRequest request, Stream outStream, CancellationToken cts);
IAsyncEnumerable<PublicVoidFile> ListFiles();
ValueTask DeleteFile(Guid id);
}

View File

@ -3,16 +3,18 @@ using VoidCat.Model;
using VoidCat.Model.Exceptions;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services;
namespace VoidCat.Services.Files;
public class LocalDiskFileMetadataStore : IFileMetadataStore
{
private const string MetadataDir = "metadata-v3";
private readonly ILogger<LocalDiskFileMetadataStore> _logger;
private readonly VoidSettings _settings;
public LocalDiskFileMetadataStore(VoidSettings settings)
public LocalDiskFileMetadataStore(VoidSettings settings, ILogger<LocalDiskFileMetadataStore> logger)
{
_settings = settings;
_logger = logger;
var metaPath = Path.Combine(_settings.DataDirectory, MetadataDir);
if (!Directory.Exists(metaPath))
@ -20,24 +22,24 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
Directory.CreateDirectory(metaPath);
}
}
public ValueTask<VoidFileMeta?> GetPublic(Guid id)
{
return GetMeta<VoidFileMeta>(id);
}
public ValueTask<SecretVoidFileMeta?> Get(Guid id)
{
return GetMeta<SecretVoidFileMeta>(id);
}
public async ValueTask Set(Guid id, SecretVoidFileMeta meta)
{
var path = MapMeta(id);
var json = JsonConvert.SerializeObject(meta);
await File.WriteAllTextAsync(path, json);
}
public async ValueTask Update(Guid id, SecretVoidFileMeta patch)
{
var oldMeta = await Get(id);
@ -49,6 +51,18 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
await Set(id, patch);
}
public ValueTask Delete(Guid id)
{
var path = MapMeta(id);
if (File.Exists(path))
{
_logger.LogInformation("Deleting metadata file {Path}", path);
File.Delete(path);
}
return ValueTask.CompletedTask;
}
private async ValueTask<TMeta?> GetMeta<TMeta>(Guid id)
{
var path = MapMeta(id);
@ -57,7 +71,7 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
var json = await File.ReadAllTextAsync(path);
return JsonConvert.DeserializeObject<TMeta>(json);
}
private string MapMeta(Guid id) =>
Path.ChangeExtension(Path.Join(_settings.DataDirectory, MetadataDir, id.ToString()), ".json");
}
}

View File

@ -4,23 +4,27 @@ using VoidCat.Model;
using VoidCat.Model.Exceptions;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services;
namespace VoidCat.Services.Files;
public class LocalDiskFileStore : IFileStore
{
private const int BufferSize = 1024 * 1024;
private const int BufferSize = 1_048_576;
private readonly ILogger<LocalDiskFileStore> _logger;
private readonly VoidSettings _settings;
private readonly IAggregateStatsCollector _stats;
private readonly IFileMetadataStore _metadataStore;
private readonly IPaywallStore _paywallStore;
private readonly IStatsReporter _statsReporter;
public LocalDiskFileStore(VoidSettings settings, IAggregateStatsCollector stats,
IFileMetadataStore metadataStore, IPaywallStore paywallStore)
public LocalDiskFileStore(ILogger<LocalDiskFileStore> logger, VoidSettings settings, IAggregateStatsCollector stats,
IFileMetadataStore metadataStore, IPaywallStore paywallStore, IStatsReporter statsReporter)
{
_settings = settings;
_stats = stats;
_metadataStore = metadataStore;
_paywallStore = paywallStore;
_statsReporter = statsReporter;
_logger = logger;
if (!Directory.Exists(_settings.DataDirectory))
{
@ -30,11 +34,12 @@ public class LocalDiskFileStore : IFileStore
public async ValueTask<PublicVoidFile?> Get(Guid id)
{
return new ()
return new()
{
Id = id,
Metadata = await _metadataStore.GetPublic(id),
Paywall = await _paywallStore.GetConfig(id)
Paywall = await _paywallStore.GetConfig(id),
Bandwidth = await _statsReporter.GetBandwidth(id)
};
}
@ -114,20 +119,29 @@ public class LocalDiskFileStore : IFileStore
foreach (var fe in Directory.EnumerateFiles(_settings.DataDirectory))
{
var filename = Path.GetFileNameWithoutExtension(fe);
if (Path.HasExtension(fe)) continue; // real file does not have extension
if (!Guid.TryParse(filename, out var id)) continue;
var meta = await _metadataStore.Get(id);
if (meta != default)
var vf = await Get(id);
if (vf != default)
{
yield return new()
{
Id = id,
Metadata = meta
};
yield return vf;
}
}
}
public async ValueTask DeleteFile(Guid id)
{
var fp = MapPath(id);
if (File.Exists(fp))
{
_logger.LogInformation("Deleting file: {Path}", fp);
File.Delete(fp);
}
await _metadataStore.Delete(id);
}
private async Task<(ulong, string)> IngressInternal(Guid id, Stream ingress, Stream fs, CancellationToken cts)
{
using var buffer = MemoryPool<byte>.Shared.Rent(BufferSize);

View File

@ -1,5 +1,6 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Strike;
namespace VoidCat.Services.Paywall;

View File

@ -14,10 +14,10 @@ public class PaywallStore : IPaywallStore
public async ValueTask<PaywallConfig?> GetConfig(Guid id)
{
var cfg = await _cache.Get<PaywallConfig>(ConfigKey(id));
var cfg = await _cache.Get<NoPaywallConfig>(ConfigKey(id));
return cfg?.Service switch
{
PaywallServices.None => await _cache.Get<NoPaywallConfig>(ConfigKey(id)),
PaywallServices.None => cfg,
PaywallServices.Strike => await _cache.Get<StrikePaywallConfig>(ConfigKey(id)),
_ => default
};

View File

@ -2,6 +2,7 @@
using VoidCat.Model;
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Strike;
namespace VoidCat.Services.Paywall;

View File

@ -3,7 +3,7 @@ using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace VoidCat.Services;
namespace VoidCat.Services.Strike;
public class StrikeApi
{

View File

@ -26,7 +26,7 @@ public class UserManager : IUserManager
public async ValueTask<VoidUser> Register(string email, string password)
{
var existingUser = await _store.LookupUser(email);
if (existingUser != default) throw new InvalidOperationException("User already exists");
if (existingUser != Guid.Empty) throw new InvalidOperationException("User already exists");
var newUser = new VoidUser(Guid.NewGuid(), email, password.HashPassword());
await _store.Set(newUser);

View File

@ -6,6 +6,7 @@
"dependencies": {
"@reduxjs/toolkit": "^1.7.2",
"feather-icons-react": "^0.5.0",
"moment": "^2.29.1",
"qrcode.react": "^1.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",

View File

@ -1,16 +1,25 @@
import {useSelector} from "react-redux";
import {Fragment} from "react";
import {useSelector} from "react-redux";
import {Login} from "../Login";
import {FileList} from "./FileList";
import {UserList} from "./UserList";
export function Admin(props) {
export function Admin() {
const auth = useSelector((state) => state.login.jwt);
if(!auth) {
if (!auth) {
return <Login/>;
} else {
return (
<div>
<h3>Admin</h3>
</div>
<Fragment>
<h2>Admin</h2>
<h4>Users</h4>
<UserList/>
<h4>Files</h4>
<FileList/>
</Fragment>
);
}
}

View File

@ -0,0 +1,3 @@
table.file-list {
width: 100%;
}

View File

@ -0,0 +1,82 @@
import moment from "moment";
import {Link} from "react-router-dom";
import {useSelector} from "react-redux";
import {useEffect, useState} from "react";
import {FormatBytes} from "../Util";
import "./FileList.css";
export function FileList(props) {
const auth = useSelector((state) => state.login.jwt);
const [files, setFiles] = useState([]);
async function loadFileList() {
let req = await fetch("/admin/file", {
headers: {
"authorization": `Bearer ${auth}`
}
});
if (req.ok) {
setFiles(await req.json());
}
}
async function deleteFile(e, id) {
e.target.disabled = true;
let req = await fetch(`/admin/file/${id}`, {
method: "DELETE",
headers: {
"authorization": `Bearer ${auth}`
}
});
if (req.ok) {
setFiles([
...files.filter(a => a.id !== id)
]);
} else {
alert("Failed to delete file!");
}
e.target.disabled = false;
}
function renderItem(i) {
const meta = i.metadata;
const bw = i.bandwidth;
return (
<tr key={i.id}>
<td><Link to={`/${i.id}`}>{i.id.substring(0, 4)}...</Link></td>
<td>{meta?.name}</td>
<td>{meta?.uploaded ? moment(meta?.uploaded).fromNow() : null}</td>
<td>{meta?.size ? FormatBytes(meta?.size, 2) : null}</td>
<td>{bw ? FormatBytes(bw.egress, 2) : null}</td>
<td>
<button onClick={(e) => deleteFile(e, i.id)}>Delete</button>
</td>
</tr>
);
}
useEffect(() => {
loadFileList()
}, []);
return (
<table className="file-list">
<thead>
<tr>
<td>Id</td>
<td>Name</td>
<td>Uploaded</td>
<td>Size</td>
<td>Egress</td>
<td>Actions</td>
</tr>
</thead>
<tbody>
{files?.map(a => renderItem(a))}
</tbody>
</table>
);
}

View File

@ -0,0 +1,5 @@
export function UserList() {
return (
<table></table>
);
}

View File

@ -0,0 +1,8 @@
.login .error-msg {
color: red;
padding: 10px;
border: 1px solid red;
border-radius: 10px;
margin-top: 10px;
width: fit-content;
}

View File

@ -2,15 +2,19 @@
import {useDispatch} from "react-redux";
import {setAuth} from "./LoginState";
import "./Login.css";
export function Login(props) {
const [username, setUsername] = useState();
const [password, setPassword] = useState();
const [error, setError] = useState();
const dispatch = useDispatch();
async function login(e) {
async function login(e, url) {
e.target.disabled = true;
setError(null);
let req = await fetch("/auth/login", {
let req = await fetch(`/auth/${url}`, {
method: "POST",
body: JSON.stringify({
username, password
@ -21,7 +25,11 @@ export function Login(props) {
});
if (req.ok) {
let rsp = await req.json();
dispatch(setAuth(rsp.jwt));
if(rsp.jwt) {
dispatch(setAuth(rsp.jwt));
} else {
setError(rsp.error);
}
}
e.target.disabled = false;
}
@ -35,7 +43,9 @@ export function Login(props) {
<dt>Password:</dt>
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
</dl>
<button onClick={login}>Login</button>
<button onClick={(e) => login(e, "login")}>Login</button>
<button onClick={(e) => login(e, "register")}>Register</button>
{error ? <div className="error-msg">{error}</div> : null}
</div>
);
}

View File

@ -1,16 +1,20 @@
import {createSlice} from "@reduxjs/toolkit";
const LocalStorageKey = "token";
export const LoginState = createSlice({
name: "Login",
initialState: {
jwt: null
jwt: window.localStorage.getItem(LocalStorageKey)
},
reducers: {
setAuth: (state, action) => {
state.jwt = action.payload;
window.localStorage.setItem(LocalStorageKey, state.jwt);
},
logout: (state) => {
state.jwt = null;
window.localStorage.removeItem(LocalStorageKey);
}
}
});

View File

@ -5697,6 +5697,11 @@ mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.5"
moment@^2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
ms@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"