diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index f8da4bc..e02346e 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -20,16 +20,54 @@ namespace VoidCat.Controllers [HttpPost] [DisableRequestSizeLimit] [DisableFormValueModelBinding] - public Task UploadFile() + public async Task UploadFile() { - var meta = new VoidFileMeta() + try { - MimeType = Request.ContentType, - Name = Request.Headers - .FirstOrDefault(a => a.Key.Equals("X-Filename", StringComparison.InvariantCultureIgnoreCase)).Value.ToString() - }; - return Request.HasFormContentType ? - saveFromForm() : _storage.Ingress(Request.Body, meta, HttpContext.RequestAborted); + var meta = new VoidFileMeta() + { + MimeType = Request.ContentType, + Name = Request.Headers.GetHeader("X-Filename") + }; + + var digest = Request.Headers.GetHeader("X-Digest"); + var vf = await (Request.HasFormContentType ? + saveFromForm() : _storage.Ingress(new(Request.Body, meta, digest!), HttpContext.RequestAborted)); + + return UploadResult.Success(vf); + } + catch (Exception ex) + { + return UploadResult.Error(ex.Message); + } + } + + [HttpPost] + [DisableRequestSizeLimit] + [DisableFormValueModelBinding] + [Route("{id}")] + public async Task UploadFileAppend([FromRoute] string id) + { + try + { + var gid = id.FromBase58Guid(); + var fileInfo = await _storage.Get(gid); + if (fileInfo == default) return null; + + var editSecret = Request.Headers.GetHeader("X-EditSecret"); + var digest = Request.Headers.GetHeader("X-Digest"); + var vf = await _storage.Ingress(new(Request.Body, fileInfo.Metadata, digest!) + { + EditSecret = editSecret?.FromBase58Guid(), + Id = gid + }, HttpContext.RequestAborted); + + return UploadResult.Success(vf); + } + catch (Exception ex) + { + return UploadResult.Error(ex.Message); + } } [HttpGet] @@ -38,10 +76,10 @@ namespace VoidCat.Controllers { return _storage.Get(id.FromBase58Guid()); } - + [HttpPatch] [Route("{id}")] - public Task UpdateFileInfo([FromRoute]string id, [FromBody]UpdateFileInfoRequest request) + public Task UpdateFileInfo([FromRoute] string id, [FromBody] UpdateFileInfoRequest request) { return _storage.UpdateInfo(new VoidFile() { @@ -73,4 +111,13 @@ namespace VoidCat.Controllers { } } + + public record UploadResult(bool Ok, InternalVoidFile? File, string? ErrorMessage) + { + public static UploadResult Success(InternalVoidFile vf) + => new(true, vf, null); + + public static UploadResult Error(string message) + => new(false, null, message); + } } diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index dd45b6a..341a2d8 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -13,4 +13,10 @@ public static class Extensions var enc = new NBitcoin.DataEncoders.Base58Encoder(); return enc.EncodeData(id.ToByteArray()); } + + public static string? GetHeader(this IHeaderDictionary headers, string key) + { + return headers + .FirstOrDefault(a => a.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)).Value.ToString(); + } } \ No newline at end of file diff --git a/VoidCat/Model/VoidFile.cs b/VoidCat/Model/VoidFile.cs index 266aa28..827925c 100644 --- a/VoidCat/Model/VoidFile.cs +++ b/VoidCat/Model/VoidFile.cs @@ -3,7 +3,7 @@ using Newtonsoft.Json; namespace VoidCat.Model { - public class VoidFile + public record class VoidFile { [JsonConverter(typeof(Base58GuidConverter))] public Guid Id { get; init; } @@ -15,13 +15,13 @@ namespace VoidCat.Model public DateTimeOffset Uploaded { get; init; } } - public class InternalVoidFile : VoidFile + public record class InternalVoidFile : VoidFile { [JsonConverter(typeof(Base58GuidConverter))] public Guid EditSecret { get; init; } } - public class VoidFileMeta + public record class VoidFileMeta { public string? Name { get; init; } diff --git a/VoidCat/Services/IFileStore.cs b/VoidCat/Services/IFileStore.cs index 9f48ab8..3433f3b 100644 --- a/VoidCat/Services/IFileStore.cs +++ b/VoidCat/Services/IFileStore.cs @@ -6,7 +6,7 @@ namespace VoidCat.Services { Task Get(Guid id); - Task Ingress(Stream inStream, VoidFileMeta meta, CancellationToken cts); + Task Ingress(IngressPayload payload, CancellationToken cts); Task Egress(EgressRequest request, Stream outStream, CancellationToken cts); @@ -15,6 +15,14 @@ namespace VoidCat.Services IAsyncEnumerable ListFiles(); } + public record IngressPayload(Stream InStream, VoidFileMeta Meta, string Hash) + { + public Guid? Id { get; init; } + public Guid? EditSecret { get; init; } + + public bool IsAppend => Id.HasValue && EditSecret.HasValue; + } + public record EgressRequest(Guid Id, IEnumerable Ranges) { } diff --git a/VoidCat/Services/LocalDiskFileStorage.cs b/VoidCat/Services/LocalDiskFileStorage.cs index 542e093..3594633 100644 --- a/VoidCat/Services/LocalDiskFileStorage.cs +++ b/VoidCat/Services/LocalDiskFileStorage.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Security.Cryptography; using VoidCat.Model; using VoidCat.Model.Exceptions; @@ -44,33 +45,52 @@ public class LocalDiskFileIngressFactory : IFileStore } } - public async Task Ingress(Stream inStream, VoidFileMeta meta, CancellationToken cts) + public async Task Ingress(IngressPayload payload, CancellationToken cts) { - var id = Guid.NewGuid(); + var id = payload.Id ?? Guid.NewGuid(); var fPath = MapPath(id); - await using var fsTemp = new FileStream(fPath, FileMode.Create, FileAccess.ReadWrite); - - using var buffer = MemoryPool.Shared.Rent(); - var total = 0UL; - var readLength = 0; - while ((readLength = await inStream.ReadAsync(buffer.Memory, cts)) > 0) + InternalVoidFile? vf = null; + if (payload.IsAppend) { - await fsTemp.WriteAsync(buffer.Memory[..readLength], cts); - await _stats.TrackIngress(id, (ulong)readLength); - total += (ulong)readLength; + vf = await _metadataStore.Get(payload.Id!.Value); + if (vf?.EditSecret != null && vf.EditSecret != payload.EditSecret) + { + throw new VoidNotAllowedException("Edit secret incorrect!"); + } } - var fm = new InternalVoidFile() - { - Id = id, - Size = total, - Metadata = meta, - Uploaded = DateTimeOffset.UtcNow, - EditSecret = Guid.NewGuid() - }; + // open file + await using var fsTemp = new FileStream(fPath, + payload.IsAppend ? FileMode.Append : FileMode.Create, FileAccess.Write); - await _metadataStore.Set(fm); - return fm; + var (total, hash) = await IngressInternal(id, payload.InStream, fsTemp, cts); + + if (!hash.Equals(payload.Hash, StringComparison.InvariantCultureIgnoreCase)) + { + throw new CryptographicException("Invalid file hash"); + } + if (payload.IsAppend) + { + vf = vf! with + { + Size = vf.Size + total + }; + } + else + { + vf = new InternalVoidFile() + { + Id = id, + Metadata = payload.Meta, + Uploaded = DateTimeOffset.UtcNow, + EditSecret = Guid.NewGuid(), + Size = total + }; + } + + + await _metadataStore.Set(vf); + return vf; } public Task UpdateInfo(VoidFile patch, Guid editSecret) @@ -93,6 +113,25 @@ public class LocalDiskFileIngressFactory : IFileStore } } + private async Task<(ulong, string)> IngressInternal(Guid id, Stream ingress, Stream fs, CancellationToken cts) + { + using var buffer = MemoryPool.Shared.Rent(); + var total = 0UL; + var readLength = 0; + var sha = SHA256.Create(); + while ((readLength = await ingress.ReadAsync(buffer.Memory, cts)) > 0) + { + var buf = buffer.Memory[..readLength]; + await fs.WriteAsync(buf, cts); + await _stats.TrackIngress(id, (ulong)readLength); + sha.TransformBlock(buf.ToArray(), 0, buf.Length, null, 0); + total += (ulong)readLength; + } + + sha.TransformFinalBlock(Array.Empty(), 0, 0); + return (total, BitConverter.ToString(sha.Hash!).Replace("-", string.Empty)); + } + private async Task EgressFull(Guid id, FileStream fileStream, Stream outStream, CancellationToken cts) { diff --git a/VoidCat/spa/src/FilePreview.js b/VoidCat/spa/src/FilePreview.js index a43d8d2..35ca3f5 100644 --- a/VoidCat/spa/src/FilePreview.js +++ b/VoidCat/spa/src/FilePreview.js @@ -26,7 +26,8 @@ export function FilePreview(props) { case "video/mp4": case "video/matroksa": case "video/x-matroska": - case "video/webm": { + case "video/webm": + case "video/quicktime": { return