using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.StaticFiles; using Newtonsoft.Json; using VoidCat.Model; using VoidCat.Model.Paywall; using VoidCat.Services.Abstractions; namespace VoidCat.Controllers { [Route("upload")] public class UploadController : Controller { private readonly IFileStore _storage; private readonly IFileMetadataStore _metadata; private readonly IPaywallStore _paywall; private readonly IPaywallFactory _paywallFactory; private readonly IFileInfoManager _fileInfo; private readonly IUserUploadsStore _userUploads; public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall, IPaywallFactory paywallFactory, IFileInfoManager fileInfo, IUserUploadsStore userUploads) { _storage = storage; _metadata = metadata; _paywall = paywall; _paywallFactory = paywallFactory; _fileInfo = fileInfo; _userUploads = userUploads; } /// /// Primary upload endpoint /// /// /// Additional optional headers can be included to provide details about the file being uploaded: /// /// `V-Content-Type` - Sets the `mimeType` of the file and is used on the preview page to display the file. /// `V-Filename` - Sets the filename of the file. /// `V-Description` - Sets the description of the file. /// `V-Full-Digest` - Include a SHA256 hash of the entire file for verification purposes. /// `V-Digest` - A SHA256 hash of the data you are sending in this request. /// /// True if you want to return only the url of the file in the response /// Returns [HttpPost] [DisableRequestSizeLimit] [DisableFormValueModelBinding] public async Task UploadFile([FromQuery] bool cli = false) { try { var uid = HttpContext.GetUserId(); var mime = Request.Headers.GetHeader("V-Content-Type"); var filename = Request.Headers.GetHeader("V-Filename"); if (string.IsNullOrEmpty(mime) && !string.IsNullOrEmpty(filename)) { if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out var contentType)) { mime = contentType; } } var meta = new SecretVoidFileMeta { MimeType = mime, Name = filename, Description = Request.Headers.GetHeader("V-Description"), Digest = Request.Headers.GetHeader("V-Full-Digest"), Size = (ulong?) Request.ContentLength ?? 0UL, Uploader = uid }; var digest = Request.Headers.GetHeader("V-Digest"); var vf = await _storage.Ingress(new(Request.Body, meta) { Hash = digest }, HttpContext.RequestAborted); // save metadata await _metadata.Set(vf.Id, vf.Metadata!); // attach file upload to user if (uid.HasValue) { await _userUploads.AddFile(uid!.Value, vf); } if (cli) { var urlBuilder = new UriBuilder(Request.IsHttps ? "https" : "http", Request.Host.Host, Request.Host.Port ?? 80, $"/d/{vf.Id.ToBase58()}"); return Content(urlBuilder.Uri.ToString(), "text/plain"); } return Json(UploadResult.Success(vf)); } catch (Exception ex) { return Json(UploadResult.Error(ex.Message)); } } /// /// Append data onto a file /// /// /// This endpoint is mainly used to bypass file upload limits enforced by CloudFlare. /// Clients should split their uploads into segments, upload the first segment to the regular /// upload endpoint, use the `editSecret` to append data to the file. /// /// Set the edit secret in the header `V-EditSecret` otherwise you will not be able to append data. /// /// /// [HttpPost] [DisableRequestSizeLimit] [DisableFormValueModelBinding] [Route("{id}")] public async Task UploadFileAppend([FromRoute] string id) { try { var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return UploadResult.Error("File not found"); var editSecret = Request.Headers.GetHeader("V-EditSecret"); var digest = Request.Headers.GetHeader("V-Digest"); var vf = await _storage.Ingress(new(Request.Body, meta) { Hash = digest, EditSecret = editSecret?.FromBase58Guid() ?? Guid.Empty, Id = gid, IsAppend = true }, HttpContext.RequestAborted); // update file size await _metadata.Set(vf.Id, vf.Metadata!); return UploadResult.Success(vf); } catch (Exception ex) { return UploadResult.Error(ex.Message); } } /// /// Return information about a specific file /// /// /// [HttpGet] [Route("{id}")] public ValueTask GetInfo([FromRoute] string id) { return _fileInfo.Get(id.FromBase58Guid()); } /// /// Create a paywall order to pay /// /// File id /// [HttpGet] [Route("{id}/paywall")] public async ValueTask CreateOrder([FromRoute] string id) { var gid = id.FromBase58Guid(); var file = await _fileInfo.Get(gid); var config = await _paywall.Get(gid); var provider = await _paywallFactory.CreateProvider(config!.Service); return await provider.CreateOrder(file!); } /// /// Return the status of an order /// /// File id /// Order id /// [HttpGet] [Route("{id}/paywall/{order:guid}")] public async ValueTask GetOrderStatus([FromRoute] string id, [FromRoute] Guid order) { var gid = id.FromBase58Guid(); var config = await _paywall.Get(gid); var provider = await _paywallFactory.CreateProvider(config!.Service); return await provider.GetOrderStatus(order); } /// /// Update the paywall config /// /// File id /// Requested config to set on the file /// [HttpPost] [Route("{id}/paywall")] public async Task SetPaywallConfig([FromRoute] string id, [FromBody] SetPaywallConfigRequest req) { var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return NotFound(); if (!meta.CanEdit(req.EditSecret, HttpContext)) return Unauthorized(); if (req.Strike != default) { await _paywall.Set(gid, req.Strike!); return Ok(); } // if none set, set NoPaywallConfig await _paywall.Set(gid, new NoPaywallConfig()); return Ok(); } /// /// Update metadata about file /// /// Id of file to edit /// New metadata to update /// /// /// You can only change `Name`, `Description` and `MimeType` /// [HttpPost] [Route("{id}/meta")] public async Task UpdateFileMeta([FromRoute] string id, [FromBody] SecretVoidFileMeta fileMeta) { var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return NotFound(); if (!meta.CanEdit(fileMeta.EditSecret, HttpContext)) return Unauthorized(); await _metadata.Update(gid, fileMeta); return Ok(); } } [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter { public void OnResourceExecuting(ResourceExecutingContext context) { var factories = context.ValueProviderFactories; factories.RemoveType(); factories.RemoveType(); factories.RemoveType(); } public void OnResourceExecuted(ResourceExecutedContext context) { } } public record UploadResult(bool Ok, PrivateVoidFile? File, string? ErrorMessage) { public static UploadResult Success(PrivateVoidFile vf) => new(true, vf, null); public static UploadResult Error(string message) => new(false, null, message); } public record SetPaywallConfigRequest { [JsonConverter(typeof(Base58GuidConverter))] public Guid EditSecret { get; init; } public StrikePaywallConfig? Strike { get; init; } } }