This commit is contained in:
Kieran 2022-01-25 23:39:51 +00:00
parent afee82fed9
commit 60229a0c06
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
24 changed files with 511 additions and 90 deletions

View File

@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using VoidCat.Model;
using VoidCat.Services;
namespace VoidCat.Controllers;
[Route("d")]
public class DownloadController : Controller
{
private readonly IFileStorage _storage;
public DownloadController(IFileStorage storage)
{
_storage = storage;
}
[HttpGet]
[Route("{id}")]
public async Task DownloadFile([FromRoute] string id)
{
var gid = id.FromBase58Guid();
var meta = await _storage.Get(gid);
if (meta == null)
{
Response.StatusCode = 404;
return;
}
Response.ContentType = meta?.Metadata?.MimeType ?? "application/octet-stream";
await _storage.Egress(gid, Response.Body, HttpContext.RequestAborted);
}
}

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using VoidCat.Model;
using VoidCat.Services;
@ -7,25 +8,44 @@ namespace VoidCat.Controllers
[Route("upload")]
public class UploadController : Controller
{
private readonly IFileIngressFactory _fileIngress;
private readonly IStatsCollector _stats;
private readonly IFileStorage _storage;
public UploadController(IStatsCollector stats, IFileIngressFactory fileIngress)
public UploadController(IFileStorage storage)
{
_stats = stats;
_fileIngress = fileIngress;
_storage = storage;
}
[HttpPost]
public Task<VoidFile> UploadFile()
[DisableRequestSizeLimit]
public Task<InternalVoidFile> UploadFile()
{
return Request.HasFormContentType ?
saveFromForm() : _fileIngress.Ingress(Request.Body);
saveFromForm() : _storage.Ingress(Request.Body, HttpContext.RequestAborted);
}
private Task<VoidFile> saveFromForm()
[HttpGet]
[Route("{id}")]
public Task<VoidFile?> GetInfo([FromRoute] string id)
{
return Task.FromResult<VoidFile>(null);
return _storage.Get(id.FromBase58Guid());
}
[HttpPatch]
[Route("{id}")]
public Task UpdateFileInfo([FromRoute]string id, [FromBody]UpdateFileInfoRequest request)
{
return _storage.UpdateInfo(new VoidFile()
{
Id = id.FromBase58Guid(),
Metadata = request.Metadata
}, request.EditSecret);
}
private Task<InternalVoidFile> saveFromForm()
{
return Task.FromResult<InternalVoidFile>(null);
}
public record UpdateFileInfoRequest([JsonConverter(typeof(Base58GuidConverter))] Guid EditSecret, VoidFileMeta Metadata);
}
}

View File

@ -0,0 +1,21 @@
using Newtonsoft.Json;
namespace VoidCat.Model;
public class Base58GuidConverter : JsonConverter<Guid>
{
public override void WriteJson(JsonWriter writer, Guid value, JsonSerializer serializer)
{
writer.WriteValue(value.ToBase58());
}
public override Guid ReadJson(JsonReader reader, Type objectType, Guid existingValue, bool hasExistingValue, JsonSerializer serializer)
{
if (reader.TokenType == JsonToken.String && existingValue == Guid.Empty)
{
return (reader.Value as string)?.FromBase58Guid() ?? existingValue;
}
return existingValue;
}
}

View File

@ -0,0 +1,10 @@
namespace VoidCat.Model.Exceptions;
public class VoidFileNotFoundException : Exception
{
public VoidFileNotFoundException(Guid id)
{
Id = id;
}
public Guid Id { get; }
}

View File

@ -0,0 +1,8 @@
namespace VoidCat.Model.Exceptions;
public class VoidNotAllowedException : Exception
{
public VoidNotAllowedException(string message) : base(message)
{
}
}

View File

@ -0,0 +1,16 @@
namespace VoidCat.Model;
public static class Extensions
{
public static Guid FromBase58Guid(this string base58)
{
var enc = new NBitcoin.DataEncoders.Base58Encoder();
return new Guid(enc.DecodeData(base58));
}
public static string ToBase58(this Guid id)
{
var enc = new NBitcoin.DataEncoders.Base58Encoder();
return enc.EncodeData(id.ToByteArray());
}
}

View File

@ -1,15 +1,32 @@
namespace VoidCat.Model

using Newtonsoft.Json;
namespace VoidCat.Model
{
public class VoidFile
{
public Guid Id { get; init; } = Guid.NewGuid();
public string? Name { get; init; }
public string? Description { get; init; }
[JsonConverter(typeof(Base58GuidConverter))]
public Guid Id { get; init; }
public VoidFileMeta Metadata { get; set; }
public ulong Size { get; init; }
public DateTimeOffset Uploaded { get; init; }
}
public class InternalVoidFile : VoidFile
{
[JsonConverter(typeof(Base58GuidConverter))]
public Guid EditSecret { get; init; }
}
public class VoidFileMeta
{
public string? Name { get; init; }
public string? Description { get; init; }
public string? MimeType { get; init; }
}
}

View File

@ -2,6 +2,6 @@
{
public class VoidSettings
{
public string FilePath { get; init; } = "./data";
public string DataDirectory { get; init; } = "./data";
}
}

View File

@ -5,8 +5,8 @@ var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;
services.AddRouting();
services.AddControllers();
services.AddScoped<IFileIngressFactory, LocalDiskFileIngressFactory>();
services.AddControllers().AddNewtonsoftJson();
services.AddScoped<IFileStorage, LocalDiskFileIngressFactory>();
services.AddScoped<IStatsCollector, InMemoryStatsCollector>();
var configuration = builder.Configuration;

View File

@ -1,9 +0,0 @@
using VoidCat.Model;
namespace VoidCat.Services
{
public interface IFileIngressFactory
{
Task<VoidFile> Ingress(Stream inStream);
}
}

View File

@ -0,0 +1,15 @@
using VoidCat.Model;
namespace VoidCat.Services
{
public interface IFileStorage
{
Task<VoidFile?> Get(Guid id);
Task<InternalVoidFile> Ingress(Stream inStream, CancellationToken cts);
Task Egress(Guid id, Stream outStream, CancellationToken cts);
Task UpdateInfo(VoidFile patch, Guid editSecret);
}
}

View File

@ -1,15 +1,36 @@
namespace VoidCat.Services
using System.Collections.Concurrent;
namespace VoidCat.Services
{
public class InMemoryStatsCollector : IStatsCollector
{
private readonly ConcurrentDictionary<Guid, ulong> _ingress = new();
private readonly ConcurrentDictionary<Guid, ulong> _egress = new();
public ValueTask TrackIngress(Guid id, ulong amount)
{
throw new NotImplementedException();
if (_ingress.ContainsKey(id) && _ingress.TryGetValue(id, out var v))
{
_ingress.TryUpdate(id, v + amount, v);
}
else
{
_ingress.TryAdd(id, amount);
}
return ValueTask.CompletedTask;
}
public ValueTask TrackEgress(Guid id, ulong amount)
{
throw new NotImplementedException();
if (_egress.ContainsKey(id) && _egress.TryGetValue(id, out var v))
{
_egress.TryUpdate(id, v + amount, v);
}
else
{
_egress.TryAdd(id, amount);
}
return ValueTask.CompletedTask;
}
}
}

View File

@ -1,42 +0,0 @@
using System.Buffers;
using VoidCat.Model;
namespace VoidCat.Services;
public class LocalDiskFileIngressFactory : IFileIngressFactory
{
private readonly VoidSettings _settings;
private readonly IStatsCollector _stats;
public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats)
{
_settings = settings;
_stats = stats;
}
public async Task<VoidFile> Ingress(Stream inStream)
{
var id = Guid.NewGuid();
var fPath = mapPath(id);
using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite);
var buffer = MemoryPool<byte>.Shared.Rent();
var total = 0UL;
var readLength = 0;
while ((readLength = await inStream.ReadAsync(buffer.Memory)) > 0)
{
await fsTemp.WriteAsync(buffer.Memory[..readLength]);
await _stats.TrackIngress(id, (ulong)readLength);
total += (ulong)readLength;
}
return new()
{
Id = id,
Size = total
};
}
private string mapPath(Guid id) =>
Path.Join(_settings.FilePath, id.ToString());
}

View File

@ -0,0 +1,103 @@
using System.Buffers;
using Newtonsoft.Json;
using VoidCat.Model;
using VoidCat.Model.Exceptions;
namespace VoidCat.Services;
public class LocalDiskFileIngressFactory : IFileStorage
{
private readonly VoidSettings _settings;
private readonly IStatsCollector _stats;
public LocalDiskFileIngressFactory(VoidSettings settings, IStatsCollector stats)
{
_settings = settings;
_stats = stats;
if (!Directory.Exists(_settings.DataDirectory))
{
Directory.CreateDirectory(_settings.DataDirectory);
}
}
public async Task<VoidFile?> Get(Guid id)
{
var path = MapMeta(id);
if (!File.Exists(path)) throw new VoidFileNotFoundException(id);
var json = await File.ReadAllTextAsync(path);
return JsonConvert.DeserializeObject<VoidFile>(json);
}
public async Task Egress(Guid id, Stream outStream, CancellationToken cts)
{
var path = MapPath(id);
if (!File.Exists(path)) throw new VoidFileNotFoundException(id);
await using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
using var buffer = MemoryPool<byte>.Shared.Rent();
var readLength = 0;
while ((readLength = await fs.ReadAsync(buffer.Memory, cts)) > 0)
{
await outStream.WriteAsync(buffer.Memory[..readLength], cts);
await _stats.TrackEgress(id, (ulong)readLength);
}
}
public async Task<InternalVoidFile> Ingress(Stream inStream, CancellationToken cts)
{
var id = Guid.NewGuid();
var fPath = MapPath(id);
await using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite);
using var buffer = MemoryPool<byte>.Shared.Rent();
var total = 0UL;
var readLength = 0;
while ((readLength = await inStream.ReadAsync(buffer.Memory, cts)) > 0)
{
await fsTemp.WriteAsync(buffer.Memory[..readLength], cts);
await _stats.TrackIngress(id, (ulong)readLength);
total += (ulong)readLength;
}
var fm = new InternalVoidFile()
{
Id = id,
Size = total,
Uploaded = DateTimeOffset.UtcNow,
EditSecret = Guid.NewGuid()
};
var mPath = MapMeta(id);
var json = JsonConvert.SerializeObject(fm);
await File.WriteAllTextAsync(mPath, json, cts);
return fm;
}
public async Task UpdateInfo(VoidFile patch, Guid editSecret)
{
var path = MapMeta(patch.Id);
if (!File.Exists(path)) throw new VoidFileNotFoundException(patch.Id);
var oldJson = await File.ReadAllTextAsync(path);
var oldObj = JsonConvert.DeserializeObject<InternalVoidFile>(oldJson);
if (oldObj?.EditSecret != editSecret)
{
throw new VoidNotAllowedException("Edit secret incorrect");
}
// only patch metadata
oldObj.Metadata = patch.Metadata;
var json = JsonConvert.SerializeObject(oldObj);
await File.WriteAllTextAsync(path, json);
}
private string MapPath(Guid id) =>
Path.Join(_settings.DataDirectory, id.ToString());
private string MapMeta(Guid id) =>
Path.ChangeExtension(MapPath(id), ".json");
}

View File

@ -11,7 +11,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.1" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.14.0" />
<PackageReference Include="NBitcoin" Version="6.0.19" />
</ItemGroup>
<ItemGroup>
<!-- Don't publish the SPA source files, but do show them in the project files list -->

View File

@ -7,6 +7,6 @@
},
"AllowedHosts": "*",
"Settings": {
"VoidSettings": "./data"
"DataDirectory": "./data"
}
}

View File

@ -1,17 +1,10 @@
import './App.css';
import {FilePreview} from "./FilePreview";
import {Uploader} from "./Uploader";
function App() {
function selectFiles(e) {
}
return (
<div className="app">
<div className="drop" onClick={selectFiles}>
<h3>Drop files here!</h3>
</div>
</div>
);
let hasPath = window.location.pathname !== "/";
return hasPath ? <FilePreview id={window.location.pathname.substr(1)}/> : <Uploader/>;
}
export default App;

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

@ -0,0 +1,32 @@
/**
* @constant {number} - Size of 1 kiB
*/
export const kiB = Math.pow(1024, 1);
/**
* @constant {number} - Size of 1 MiB
*/
export const MiB = Math.pow(1024, 2);
/**
* @constant {number} - Size of 1 GiB
*/
export const GiB = Math.pow(1024, 3);
/**
* @constant {number} - Size of 1 TiB
*/
export const TiB = Math.pow(1024, 4);
/**
* @constant {number} - Size of 1 PiB
*/
export const PiB = Math.pow(1024, 5);
/**
* @constant {number} - Size of 1 EiB
*/
export const EiB = Math.pow(1024, 6);
/**
* @constant {number} - Size of 1 ZiB
*/
export const ZiB = Math.pow(1024, 7);
/**
* @constant {number} - Size of 1 YiB
*/
export const YiB = Math.pow(1024, 8);

View File

@ -0,0 +1,23 @@
import {useEffect, useState} from "react";
export function FilePreview(props) {
let [info, setInfo] = useState();
async function loadInfo() {
let req = await fetch(`/upload/${props.id}`);
if(req.ok) {
let info = await req.json();
setInfo(info);
}
}
useEffect(() => {
loadInfo();
}, []);
return (
<div>
{info ? <a href={`/d/${info.id}`}>{info.metadata?.name ?? info.id}</a> : "Not Found"}
</div>
);
}

View File

@ -0,0 +1,20 @@
.upload {
display: flex;
padding: 10px;
border: 1px solid grey;
border-radius: 10px;
margin-top: 10px;
}
.upload .info {
flex: 2;
}
.upload .status {
flex: 1;
}
.upload dt {
font-size: 12px;
color: grey;
}

View File

@ -0,0 +1,66 @@
import {useEffect, useState} from "react";
import "./FileUpload.css";
import {FormatBytes} from "./Util";
export function FileUpload(props) {
let [speed, setSpeed] = useState(0);
let [progress, setProgress] = useState(0);
let [result, setResult] = useState();
async function doUpload() {
let req = await fetch("/upload", {
method: "POST",
body: props.file,
headers: {
"content-type": "application/octet-stream"
}
});
if(req.ok) {
let rsp = await req.json();
console.log(rsp);
setResult(rsp);
}
}
function renderStatus() {
if(result) {
return (
<dl>
<dt>Link:</dt>
<dd><a target="_blank" href={`/${result.id}`}>{result.id}</a></dd>
</dl>
);
} else {
return (
<dl>
<dt>Speed:</dt>
<dd>{FormatBytes(speed)}/s</dd>
<dt>Progress:</dt>
<dd>{(progress * 100).toFixed(0)}%</dd>
</dl>
);
}
}
useEffect(() => {
console.log(props.file);
doUpload();
}, []);
return (
<div className="upload">
<div className="info">
<dl>
<dt>Name:</dt>
<dd>{props.file.name}</dd>
<dt>Size:</dt>
<dd>{FormatBytes(props.file.size)}</dd>
</dl>
</div>
<div className="status">
{renderStatus()}
</div>
</div>
);
}

View File

@ -0,0 +1,42 @@
import {Fragment, useState} from "react";
import {FileUpload} from "./FileUpload";
export function Uploader(props) {
let [files, setFiles] = useState([]);
function selectFiles(e) {
let i = document.createElement('input');
i.setAttribute('type', 'file');
i.setAttribute('multiple', '');
i.addEventListener('change', function (evt) {
setFiles(evt.target.files);
});
i.click();
}
function renderUploads() {
let fElm = [];
for(let f of files) {
fElm.push(<FileUpload file={f}/>);
}
return (
<Fragment>
{fElm}
</Fragment>
);
}
function renderDrop() {
return (
<div className="drop" onClick={selectFiles}>
<h3>Drop files here!</h3>
</div>
);
}
return (
<div className="app">
{files.length === 0 ? renderDrop() : renderUploads()}
</div>
);
}

27
VoidCat/spa/src/Util.js Normal file
View File

@ -0,0 +1,27 @@
import * as Const from "./Const";
/**
* Formats bytes into binary notation
* @param {number} b - The value in bytes
* @param {number} [f=2] - The number of decimal places to use
* @returns {string} Bytes formatted in binary notation
*/
export function FormatBytes(b, f) {
f = typeof f === 'number' ? 2 : f;
if (b >= Const.YiB)
return (b / Const.YiB).toFixed(f) + ' YiB';
if (b >= Const.ZiB)
return (b / Const.ZiB).toFixed(f) + ' ZiB';
if (b >= Const.EiB)
return (b / Const.EiB).toFixed(f) + ' EiB';
if (b >= Const.PiB)
return (b / Const.PiB).toFixed(f) + ' PiB';
if (b >= Const.TiB)
return (b / Const.TiB).toFixed(f) + ' TiB';
if (b >= Const.GiB)
return (b / Const.GiB).toFixed(f) + ' GiB';
if (b >= Const.MiB)
return (b / Const.MiB).toFixed(f) + ' MiB';
if (b >= Const.kiB)
return (b / Const.kiB).toFixed(f) + ' KiB';
return b.toFixed(f) + ' B'
}

View File

@ -1,15 +1,19 @@
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;700&display=swap');
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
font-family: 'Source Code Pro', monospace;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: black;
color: white;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
a {
color: white;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}