diff --git a/VoidCat/Controllers/Admin/AdminController.cs b/VoidCat/Controllers/Admin/AdminController.cs index ab4da1c..f695b9b 100644 --- a/VoidCat/Controllers/Admin/AdminController.cs +++ b/VoidCat/Controllers/Admin/AdminController.cs @@ -16,11 +16,11 @@ public class AdminController : Controller _fileStore = fileStore; } - [HttpGet] + [HttpPost] [Route("file")] - public IAsyncEnumerable ListFiles() + public Task> ListFiles([FromBody] PagedRequest request) { - return _fileStore.ListFiles(); + return _fileStore.ListFiles(request).GetResults(); } [HttpDelete] diff --git a/VoidCat/Controllers/AuthController.cs b/VoidCat/Controllers/AuthController.cs index 9a61180..c65d7f5 100644 --- a/VoidCat/Controllers/AuthController.cs +++ b/VoidCat/Controllers/AuthController.cs @@ -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); } diff --git a/VoidCat/Controllers/StatsController.cs b/VoidCat/Controllers/StatsController.cs index 8d2fb89..0affa63 100644 --- a/VoidCat/Controllers/StatsController.cs +++ b/VoidCat/Controllers/StatsController.cs @@ -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++; diff --git a/VoidCat/Model/PagedResult.cs b/VoidCat/Model/PagedResult.cs new file mode 100644 index 0000000..4ed3bc4 --- /dev/null +++ b/VoidCat/Model/PagedResult.cs @@ -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 : PagedResult +{ + public IAsyncEnumerable Results { get; init; } + + public async Task> GetResults() + { + return new() + { + Page = Page, + PageSize = PageSize, + TotalResults = TotalResults, + Results = await Results.ToListAsync() + }; + } +} + +public sealed class RenderedResults : PagedResult +{ + public IList 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 +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/RangeRequest.cs b/VoidCat/Model/RangeRequest.cs similarity index 93% rename from VoidCat/Services/Abstractions/RangeRequest.cs rename to VoidCat/Model/RangeRequest.cs index 1938130..5134090 100644 --- a/VoidCat/Services/Abstractions/RangeRequest.cs +++ b/VoidCat/Model/RangeRequest.cs @@ -1,4 +1,4 @@ -namespace VoidCat.Services.Abstractions; +namespace VoidCat.Model; public sealed record RangeRequest(long? TotalSize, long? Start, long? End) { diff --git a/VoidCat/Services/Abstractions/IFileStore.cs b/VoidCat/Services/Abstractions/IFileStore.cs index 20f0987..bf8e560 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); - IAsyncEnumerable ListFiles(); + PagedResult ListFiles(PagedRequest request); ValueTask DeleteFile(Guid id); } \ No newline at end of file diff --git a/VoidCat/Services/Files/LocalDiskFileStorage.cs b/VoidCat/Services/Files/LocalDiskFileStorage.cs index 3acb696..058619d 100644 --- a/VoidCat/Services/Files/LocalDiskFileStorage.cs +++ b/VoidCat/Services/Files/LocalDiskFileStorage.cs @@ -34,12 +34,17 @@ public class LocalDiskFileStore : IFileStore public async ValueTask 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 ListFiles() + public PagedResult 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 EnumeratePage(IEnumerable 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) diff --git a/VoidCat/VoidCat.csproj b/VoidCat/VoidCat.csproj index 17b4a00..d256e28 100644 --- a/VoidCat/VoidCat.csproj +++ b/VoidCat/VoidCat.csproj @@ -18,6 +18,7 @@ + diff --git a/VoidCat/spa/src/Admin/FileList.js b/VoidCat/spa/src/Admin/FileList.js index 789c4a0..ae69ab0 100644 --- a/VoidCat/spa/src/Admin/FileList.js +++ b/VoidCat/spa/src/Admin/FileList.js @@ -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; } diff --git a/VoidCat/spa/src/Api.js b/VoidCat/spa/src/Api.js new file mode 100644 index 0000000..43f56a9 --- /dev/null +++ b/VoidCat/spa/src/Api.js @@ -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}) +} \ No newline at end of file diff --git a/VoidCat/spa/src/App.test.js b/VoidCat/spa/src/App.test.js deleted file mode 100644 index 1f03afe..0000000 --- a/VoidCat/spa/src/App.test.js +++ /dev/null @@ -1,8 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); -}); diff --git a/VoidCat/spa/src/Const.js b/VoidCat/spa/src/Const.js index 8d9b7ac..d53248b 100644 --- a/VoidCat/spa/src/Const.js +++ b/VoidCat/spa/src/Const.js @@ -47,4 +47,16 @@ export const PaywallOrderState = { Unpaid: 0, Paid: 1, Expired: 2 +} + +export const PagedSortBy = { + Name: 0, + Date: 1, + Size: 2, + Id: 3 +} + +export const PageSortOrder = { + Asc: 0, + Dsc: 1 } \ No newline at end of file diff --git a/VoidCat/spa/src/Countdown.js b/VoidCat/spa/src/Countdown.js index 2b57ca2..4aa89fc 100644 --- a/VoidCat/spa/src/Countdown.js +++ b/VoidCat/spa/src/Countdown.js @@ -3,14 +3,14 @@ export function Countdown(props) { const [time, setTime] = useState(0); const onEnded = props.onEnded; - + useEffect(() => { let t = setInterval(() => { let to = new Date(props.to).getTime(); 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); diff --git a/VoidCat/spa/src/Dropzone.js b/VoidCat/spa/src/Dropzone.js index debf8ac..e2abe64 100644 --- a/VoidCat/spa/src/Dropzone.js +++ b/VoidCat/spa/src/Dropzone.js @@ -18,7 +18,7 @@ export function Dropzone(props) { function renderUploads() { let fElm = []; - for(let f of files) { + for (let f of files) { fElm.push(); } return ( diff --git a/VoidCat/spa/src/FileEdit.js b/VoidCat/spa/src/FileEdit.js index d55766f..69cdc9e 100644 --- a/VoidCat/spa/src/FileEdit.js +++ b/VoidCat/spa/src/FileEdit.js @@ -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,16 +15,10 @@ 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; } - + function renderPaywallConfig() { switch (paywall) { case 0: { @@ -47,7 +42,7 @@ export function FileEdit(props) {
Description:
- +

Paywall Config

diff --git a/VoidCat/spa/src/FilePaywall.js b/VoidCat/spa/src/FilePaywall.js index a989eb0..95b3806 100644 --- a/VoidCat/spa/src/FilePaywall.js +++ b/VoidCat/spa/src/FilePaywall.js @@ -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()); } diff --git a/VoidCat/spa/src/FilePreview.js b/VoidCat/spa/src/FilePreview.js index fc0bf22..63ba016 100644 --- a/VoidCat/spa/src/FilePreview.js +++ b/VoidCat/spa/src/FilePreview.js @@ -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); diff --git a/VoidCat/spa/src/FileUpload.js b/VoidCat/spa/src/FileUpload.js index e1f68df..722caca 100644 --- a/VoidCat/spa/src/FileUpload.js +++ b/VoidCat/spa/src/FileUpload.js @@ -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, @@ -167,11 +167,11 @@ export function FileUpload(props) { ); } } - + function getChallengeElement() { let elm = document.createElement("iframe"); elm.contentWindow.document.write(challenge); - return
; + return
; } useEffect(() => { diff --git a/VoidCat/spa/src/GlobalStats.js b/VoidCat/spa/src/GlobalStats.js index f39c464..19e0229 100644 --- a/VoidCat/spa/src/GlobalStats.js +++ b/VoidCat/spa/src/GlobalStats.js @@ -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()); } diff --git a/VoidCat/spa/src/LightningPaywall.js b/VoidCat/spa/src/LightningPaywall.js index 173ec09..c8d14e5 100644 --- a/VoidCat/spa/src/LightningPaywall.js +++ b/VoidCat/spa/src/LightningPaywall.js @@ -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(); diff --git a/VoidCat/spa/src/Login.js b/VoidCat/spa/src/Login.js index d032c23..e1a2822 100644 --- a/VoidCat/spa/src/Login.js +++ b/VoidCat/spa/src/Login.js @@ -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) {

Login

Username:
-
setUsername(e.target.value)}/>
+
setUsername(e.target.value)} placeholder="user@example.com"/>
Password:
setPassword(e.target.value)}/>
- - + + {error ?
{error}
: null}
); diff --git a/VoidCat/spa/src/LoginState.js b/VoidCat/spa/src/LoginState.js index 23b9bb4..4f4ccd0 100644 --- a/VoidCat/spa/src/LoginState.js +++ b/VoidCat/spa/src/LoginState.js @@ -19,5 +19,5 @@ export const LoginState = createSlice({ } }); -export const { setAuth, logout } = LoginState.actions; +export const {setAuth, logout} = LoginState.actions; export default LoginState.reducer; \ No newline at end of file diff --git a/VoidCat/spa/src/RateCalculator.js b/VoidCat/spa/src/RateCalculator.js index 4561bc5..69effb0 100644 --- a/VoidCat/spa/src/RateCalculator.js +++ b/VoidCat/spa/src/RateCalculator.js @@ -3,7 +3,7 @@ export class RateCalculator { this.reports = []; this.lastLoaded = 0; } - + ReportProgress(amount) { this.reports.push({ time: new Date().getTime(), @@ -18,17 +18,17 @@ export class RateCalculator { }); this.lastLoaded = loaded; } - + RateWindow(s) { 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; } } - + return total / s; } } \ No newline at end of file diff --git a/VoidCat/spa/src/TextPreview.js b/VoidCat/spa/src/TextPreview.js index 5885825..9c5b60e 100644 --- a/VoidCat/spa/src/TextPreview.js +++ b/VoidCat/spa/src/TextPreview.js @@ -6,15 +6,15 @@ export function TextPreview(props) { async function getContent(link) { let req = await fetch(link); - if(req.ok) { + if (req.ok) { setContent(await req.text()); } } - + useEffect(() => { getContent(props.link); }, []); - + return (
{content}
) diff --git a/VoidCat/spa/src/Util.js b/VoidCat/spa/src/Util.js index e9c662f..df61553 100644 --- a/VoidCat/spa/src/Util.js +++ b/VoidCat/spa/src/Util.js @@ -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"