Page file list response

Standardize api calls
This commit is contained in:
Kieran 2022-02-23 17:06:44 +00:00
parent 49c6aaa249
commit f5e3b47311
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
25 changed files with 238 additions and 97 deletions

View File

@ -16,11 +16,11 @@ public class AdminController : Controller
_fileStore = fileStore;
}
[HttpGet]
[HttpPost]
[Route("file")]
public IAsyncEnumerable<PublicVoidFile> ListFiles()
public Task<RenderedResults<PublicVoidFile>> ListFiles([FromBody] PagedRequest request)
{
return _fileStore.ListFiles();
return _fileStore.ListFiles(request).GetResults();
}
[HttpDelete]

View File

@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
@ -26,6 +27,12 @@ public class AuthController : Controller
{
try
{
if (!TryValidateModel(req))
{
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
return new(null, error);
}
var user = await _manager.Login(req.Username, req.Password);
var token = CreateToken(user);
var tokenWriter = new JwtSecurityTokenHandler();
@ -43,6 +50,12 @@ public class AuthController : Controller
{
try
{
if (!TryValidateModel(req))
{
var error = ControllerContext.ModelState.FirstOrDefault().Value?.Errors.FirstOrDefault()?.ErrorMessage;
return new(null, error);
}
var newUser = await _manager.Register(req.Username, req.Password);
var token = CreateToken(newUser);
var tokenWriter = new JwtSecurityTokenHandler();
@ -72,7 +85,22 @@ public class AuthController : Controller
}
public record LoginRequest(string Username, string Password);
public class LoginRequest
{
public LoginRequest(string username, string password)
{
Username = username;
Password = password;
}
[Required]
[EmailAddress]
public string Username { get; init; }
[Required]
[MinLength(6)]
public string Password { get; init; }
}
public record LoginResponse(string? Jwt, string? Error = null);
}

View File

@ -24,7 +24,8 @@ namespace VoidCat.Controllers
var bw = await _statsReporter.GetBandwidth();
var bytes = 0UL;
var count = 0;
await foreach (var vf in _fileStore.ListFiles())
var files = _fileStore.ListFiles(new(0, Int32.MaxValue));
await foreach (var vf in files.Results)
{
bytes += vf.Metadata?.Size ?? 0;
count++;

View File

@ -0,0 +1,46 @@
namespace VoidCat.Model;
public abstract class PagedResult
{
public int Page { get; init; }
public int PageSize { get; init; }
public int Pages => TotalResults / PageSize;
public int TotalResults { get; init; }
}
public sealed class PagedResult<T> : PagedResult
{
public IAsyncEnumerable<T> Results { get; init; }
public async Task<RenderedResults<T>> GetResults()
{
return new()
{
Page = Page,
PageSize = PageSize,
TotalResults = TotalResults,
Results = await Results.ToListAsync()
};
}
}
public sealed class RenderedResults<T> : PagedResult
{
public IList<T> Results { get; init; }
}
public sealed record PagedRequest(int Page, int PageSize, PagedSortBy SortBy = PagedSortBy.Name, PageSortOrder SortOrder = PageSortOrder.Asc);
public enum PagedSortBy : byte
{
Name,
Date,
Size,
Id
}
public enum PageSortOrder : byte
{
Asc,
Dsc
}

View File

@ -1,4 +1,4 @@
namespace VoidCat.Services.Abstractions;
namespace VoidCat.Model;
public sealed record RangeRequest(long? TotalSize, long? Start, long? End)
{

View File

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

View File

@ -34,12 +34,17 @@ public class LocalDiskFileStore : IFileStore
public async ValueTask<PublicVoidFile?> Get(Guid id)
{
var meta = _metadataStore.GetPublic(id);
var paywall = _paywallStore.GetConfig(id);
var bandwidth = _statsReporter.GetBandwidth(id);
await Task.WhenAll(meta.AsTask(), paywall.AsTask(), bandwidth.AsTask());
return new()
{
Id = id,
Metadata = await _metadataStore.GetPublic(id),
Paywall = await _paywallStore.GetConfig(id),
Bandwidth = await _statsReporter.GetBandwidth(id)
Metadata = meta.Result,
Paywall = paywall.Result,
Bandwidth = bandwidth.Result
};
}
@ -114,20 +119,45 @@ public class LocalDiskFileStore : IFileStore
};
}
public async IAsyncEnumerable<PublicVoidFile> ListFiles()
public PagedResult<PublicVoidFile> ListFiles(PagedRequest request)
{
foreach (var fe in Directory.EnumerateFiles(_settings.DataDirectory))
var files = Directory.EnumerateFiles(_settings.DataDirectory)
.Where(a => !Path.HasExtension(a));
files = (request.SortBy, request.SortOrder) switch
{
var filename = Path.GetFileNameWithoutExtension(fe);
if (Path.HasExtension(fe)) continue; // real file does not have extension
if (!Guid.TryParse(filename, out var id)) continue;
(PagedSortBy.Id, PageSortOrder.Asc) => files.OrderBy(a =>
Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty),
(PagedSortBy.Id, PageSortOrder.Dsc) => files.OrderByDescending(a =>
Guid.TryParse(Path.GetFileNameWithoutExtension(a), out var g) ? g : Guid.Empty),
(PagedSortBy.Name, PageSortOrder.Asc) => files.OrderBy(Path.GetFileNameWithoutExtension),
(PagedSortBy.Name, PageSortOrder.Dsc) => files.OrderByDescending(Path.GetFileNameWithoutExtension),
(PagedSortBy.Size, PageSortOrder.Asc) => files.OrderBy(a => new FileInfo(a).Length),
(PagedSortBy.Size, PageSortOrder.Dsc) => files.OrderByDescending(a => new FileInfo(a).Length),
(PagedSortBy.Date, PageSortOrder.Asc) => files.OrderBy(File.GetCreationTimeUtc),
(PagedSortBy.Date, PageSortOrder.Dsc) => files.OrderByDescending(File.GetCreationTimeUtc),
_ => files
};
var vf = await Get(id);
if (vf != default)
async IAsyncEnumerable<PublicVoidFile> EnumeratePage(IEnumerable<string> page)
{
foreach (var file in page)
{
yield return vf;
if (!Guid.TryParse(Path.GetFileNameWithoutExtension(file), out var gid)) continue;
var loaded = await Get(gid);
if (loaded != default)
{
yield return loaded;
}
}
}
return new()
{
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)

View File

@ -18,6 +18,7 @@
<PackageReference Include="prometheus-net.AspNetCore" Version="5.0.2" />
<PackageReference Include="Seq.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.5.27-prerelease" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>
<ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list -->

View File

@ -1,41 +1,45 @@
import moment from "moment";
import {Link} from "react-router-dom";
import {useSelector} from "react-redux";
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";
export function FileList(props) {
const auth = useSelector((state) => state.login.jwt);
const dispatch = useDispatch();
const [files, setFiles] = useState([]);
async function loadFileList() {
let req = await fetch("/admin/file", {
headers: {
"authorization": `Bearer ${auth}`
}
});
let pageReq = {
page: 0,
pageSize: 20,
sortBy: PagedSortBy.Date,
sortOrder: PageSortOrder.Dsc
};
let req = await AdminApi.fileList(auth, pageReq);
if (req.ok) {
setFiles(await req.json());
} else if (req.status === 401) {
dispatch(logout());
}
}
async function deleteFile(e, id) {
e.target.disabled = true;
let req = await fetch(`/admin/file/${id}`, {
method: "DELETE",
headers: {
"authorization": `Bearer ${auth}`
if (window.confirm(`Are you sure you want to delete: ${id}?`)) {
let req = await AdminApi.deleteFile(auth, id);
if (req.ok) {
setFiles([
...files.filter(a => a.id !== id)
]);
} else {
alert("Failed to delete file!");
}
});
if (req.ok) {
setFiles([
...files.filter(a => a.id !== id)
]);
} else {
alert("Failed to delete file!");
}
e.target.disabled = false;
}

32
VoidCat/spa/src/Api.js Normal file
View File

@ -0,0 +1,32 @@
async function getJson(method, url, auth, body) {
let headers = {
"Accept": "application/json"
};
if (auth) {
headers["Authorization"] = `Bearer ${auth}`;
}
if (body) {
headers["Content-Type"] = "application/json";
}
return await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined
});
}
export const AdminApi = {
fileList: (auth, pageReq) => getJson("POST", "/admin/file", auth, pageReq),
deleteFile: (auth, id) => getJson("DELETE", `/admin/file/${id}`, auth)
}
export const Api = {
stats: () => getJson("GET", "/stats"),
fileInfo: (id) => getJson("GET", `/upload/${id}`),
setPaywallConfig: (id, cfg) => getJson("POST", `/upload/${id}/paywall`, undefined, cfg),
createOrder: (id) => getJson("GET", `/upload/${id}/paywall`),
getOrder: (file, order) => getJson("GET", `/upload/${file}/paywall/${order}`),
login: (username, password) => getJson("POST", `/auth/login`, undefined, {username, password}),
register: (username, password) => getJson("POST", `/auth/register`, undefined, {username, password})
}

View File

@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -48,3 +48,15 @@ export const PaywallOrderState = {
Paid: 1,
Expired: 2
}
export const PagedSortBy = {
Name: 0,
Date: 1,
Size: 2,
Id: 3
}
export const PageSortOrder = {
Asc: 0,
Dsc: 1
}

View File

@ -10,7 +10,7 @@ export function Countdown(props) {
let now = new Date().getTime();
let seconds = (to - now) / 1000.0;
setTime(Math.max(0, seconds));
if(seconds <= 0 && typeof onEnded === "function") {
if (seconds <= 0 && typeof onEnded === "function") {
onEnded();
}
}, 100);

View File

@ -18,7 +18,7 @@ export function Dropzone(props) {
function renderUploads() {
let fElm = [];
for(let f of files) {
for (let f of files) {
fElm.push(<FileUpload file={f} key={f.name}/>);
}
return (

View File

@ -1,8 +1,9 @@
import {useState} from "react";
import "./FileEdit.css";
import {StrikePaywallConfig} from "./StrikePaywallConfig";
import {NoPaywallConfig} from "./NoPaywallConfig";
import {Api} from "./Api";
import "./FileEdit.css";
export function FileEdit(props) {
const file = props.file;
@ -14,13 +15,7 @@ export function FileEdit(props) {
}
async function saveConfig(cfg) {
let req = await fetch(`/upload/${file.id}/paywall`, {
method: "POST",
body: JSON.stringify(cfg),
headers: {
"Content-Type": "application/json"
}
});
let req = await Api.setPaywallConfig(file.id, cfg);
return req.ok;
}

View File

@ -1,7 +1,8 @@
import {ConstName, FormatCurrency} from "./Util";
import {PaywallCurrencies, PaywallServices} from "./Const";
import {FormatCurrency} from "./Util";
import {PaywallServices} from "./Const";
import {useState} from "react";
import {LightningPaywall} from "./LightningPaywall";
import {Api} from "./Api";
export function FilePaywall(props) {
const file = props.file;
@ -13,7 +14,7 @@ export function FilePaywall(props) {
async function fetchOrder(e) {
e.target.disabled = true;
let req = await fetch(`/upload/${file.id}/paywall`);
let req = await Api.createOrder(file.id);
if (req.ok) {
setOrder(await req.json());
}

View File

@ -5,6 +5,7 @@ import {TextPreview} from "./TextPreview";
import "./FilePreview.css";
import {FileEdit} from "./FileEdit";
import {FilePaywall} from "./FilePaywall";
import {Api} from "./Api";
export function FilePreview() {
const params = useParams();
@ -13,7 +14,7 @@ export function FilePreview() {
const [link, setLink] = useState("#");
async function loadInfo() {
let req = await fetch(`/upload/${params.id}`);
let req = await Api.fileInfo(params.id);
if (req.ok) {
let info = await req.json();
setInfo(info);

View File

@ -1,9 +1,9 @@
import {useEffect, useState} from "react";
import "./FileUpload.css";
import {buf2hex, ConstName, FormatBytes} from "./Util";
import {RateCalculator} from "./RateCalculator";
import "./FileUpload.css";
const UploadState = {
NotStarted: 0,
Starting: 1,
@ -171,7 +171,7 @@ export function FileUpload(props) {
function getChallengeElement() {
let elm = document.createElement("iframe");
elm.contentWindow.document.write(challenge);
return <div dangerouslySetInnerHTML={{ __html: elm.outerHTML }}/>;
return <div dangerouslySetInnerHTML={{__html: elm.outerHTML}}/>;
}
useEffect(() => {

View File

@ -3,12 +3,13 @@ import FeatherIcon from "feather-icons-react";
import {FormatBytes} from "./Util";
import "./GlobalStats.css";
import {Api} from "./Api";
export function GlobalStats(props) {
let [stats, setStats] = useState();
async function loadStats() {
let req = await fetch("/stats");
let req = await Api.stats();
if (req.ok) {
setStats(await req.json());
}

View File

@ -1,7 +1,9 @@
import QRCode from "qrcode.react";
import {Countdown} from "./Countdown";
import {useEffect} from "react";
import {Countdown} from "./Countdown";
import {PaywallOrderState} from "./Const";
import {Api} from "./Api";
export function LightningPaywall(props) {
const file = props.file;
@ -16,7 +18,7 @@ export function LightningPaywall(props) {
}
async function checkStatus() {
let req = await fetch(`/upload/${file.id}/paywall/${order.id}`);
let req = await Api.getOrder(file.id, order.id);
if (req.ok) {
let order = await req.json();

View File

@ -3,34 +3,28 @@ import {useDispatch} from "react-redux";
import {setAuth} from "./LoginState";
import "./Login.css";
import {Api} from "./Api";
export function Login(props) {
export function Login() {
const [username, setUsername] = useState();
const [password, setPassword] = useState();
const [error, setError] = useState();
const dispatch = useDispatch();
async function login(e, url) {
async function login(e, fnLogin) {
e.target.disabled = true;
setError(null);
let req = await fetch(`/auth/${url}`, {
method: "POST",
body: JSON.stringify({
username, password
}),
headers: {
"content-type": "application/json"
}
});
let req = await fnLogin(username, password);
if (req.ok) {
let rsp = await req.json();
if(rsp.jwt) {
if (rsp.jwt) {
dispatch(setAuth(rsp.jwt));
} else {
setError(rsp.error);
}
}
e.target.disabled = false;
}
@ -39,12 +33,12 @@ export function Login(props) {
<h2>Login</h2>
<dl>
<dt>Username:</dt>
<dd><input onChange={(e) => setUsername(e.target.value)}/></dd>
<dd><input onChange={(e) => setUsername(e.target.value)} placeholder="user@example.com"/></dd>
<dt>Password:</dt>
<dd><input type="password" onChange={(e) => setPassword(e.target.value)}/></dd>
</dl>
<button onClick={(e) => login(e, "login")}>Login</button>
<button onClick={(e) => login(e, "register")}>Register</button>
<button onClick={(e) => login(e, Api.login)}>Login</button>
<button onClick={(e) => login(e, Api.register)}>Register</button>
{error ? <div className="error-msg">{error}</div> : null}
</div>
);

View File

@ -19,5 +19,5 @@ export const LoginState = createSlice({
}
});
export const { setAuth, logout } = LoginState.actions;
export const {setAuth, logout} = LoginState.actions;
export default LoginState.reducer;

View File

@ -23,8 +23,8 @@ export class RateCalculator {
let total = 0.0;
let windowStart = new Date().getTime() - (s * 1000);
for(let r of this.reports) {
if(r.time >= windowStart) {
for (let r of this.reports) {
if (r.time >= windowStart) {
total += r.amount;
}
}

View File

@ -6,7 +6,7 @@ export function TextPreview(props) {
async function getContent(link) {
let req = await fetch(link);
if(req.ok) {
if (req.ok) {
setContent(await req.text());
}
}

View File

@ -1,4 +1,5 @@
import * as Const from "./Const";
/**
* Formats bytes into binary notation
* @param {number} b - The value in bytes
@ -31,8 +32,8 @@ export function buf2hex(buffer) {
}
export function ConstName(type, val) {
for(let [k, v] of Object.entries(type)) {
if(v === val) {
for (let [k, v] of Object.entries(type)) {
if (v === val) {
return k;
}
}
@ -52,7 +53,7 @@ export function FormatCurrency(value, currency) {
})}`;
}
case 1:
case "USD":{
case "USD": {
return value.toLocaleString(undefined, {
style: "currency",
currency: "USD"