using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.StaticFiles; using Newtonsoft.Json; using VoidCat.Database; using VoidCat.Model; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; using VoidCat.Services.Users; using File = VoidCat.Database.File; namespace VoidCat.Controllers { [Route("upload")] public class UploadController : Controller { private readonly FileStoreFactory _storage; private readonly IFileMetadataStore _metadata; private readonly IPaymentStore _paymentStore; private readonly IPaymentFactory _paymentFactory; private readonly FileInfoManager _fileInfo; private readonly IUserUploadsStore _userUploads; private readonly IUserStore _userStore; private readonly UserManager _userManager; private readonly ITimeSeriesStatsReporter _timeSeriesStats; private readonly VoidSettings _settings; public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment, IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads, ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings, UserManager userManager) { _storage = storage; _metadata = metadata; _paymentStore = payment; _paymentFactory = paymentFactory; _fileInfo = fileInfo; _userUploads = userUploads; _timeSeriesStats = timeSeriesStats; _userStore = userStore; _settings = settings; _userManager = userManager; } /// /// 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. /// /// True if you want to return only the url of the file in the response /// Returns [HttpPost] [HttpOptions] [HttpHead] [DisableRequestSizeLimit] [DisableFormValueModelBinding] [Authorize(AuthenticationSchemes = "Bearer,Nostr", Policy = Policies.RequireNostr)] [AllowAnonymous] public async Task UploadFile([FromQuery] bool cli = false) { try { var stripMetadata = Request.Headers.GetHeader("V-Strip-Metadata") ?.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false; if (_settings.MaintenanceMode && !stripMetadata) { throw new InvalidOperationException("Site is in maintenance mode"); } var bodyLength = Request.ContentLength ?? 0; if ((ulong)bodyLength >= (_settings.MaxFileSize ?? ulong.MaxValue)) { throw new InvalidOperationException("File size too large"); } var uid = HttpContext.GetUserId(); var pubkey = HttpContext.GetPubKey(); if (uid == default && !string.IsNullOrEmpty(pubkey)) { var nostrUser = await _userManager.LoginOrRegister(pubkey); uid = nostrUser.Id; } 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; } } if (string.IsNullOrEmpty(mime)) { mime = "application/octet-stream"; } // detect store for ingress var store = _settings.DefaultFileStore; if (uid.HasValue) { var user = await _userStore.Get(uid.Value); if (user?.Storage != default) { store = user.Storage!; } } var meta = new File { MimeType = mime, Name = filename, Description = Request.Headers.GetHeader("V-Description"), Digest = Request.Headers.GetHeader("V-Full-Digest"), Size = (ulong?)Request.ContentLength ?? 0UL, Storage = store, EncryptionParams = Request.Headers.GetHeader("V-EncryptionParams") }; var (segment, totalSegments) = ParseSegmentsHeader(); var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments, stripMetadata), HttpContext.RequestAborted); // save metadata await _metadata.Add(vf); // attach file upload to user if (uid.HasValue) { await _userUploads.AddFile(uid.Value, vf.Id); } if (cli) { var urlBuilder = new UriBuilder(_settings.SiteUrl) { Path = $"/d/{vf.Id.ToBase58()}" }; return Content(urlBuilder.Uri.ToString(), "text/plain"); } return Json(UploadResult.Success(vf.ToResponse(true))); } 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 stripMetadata = Request.Headers.GetHeader("V-Strip-Metadata") ?.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false; if (_settings.MaintenanceMode && !stripMetadata) { throw new InvalidOperationException("Site is in maintenance mode"); } var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return UploadResult.Error("File not found"); // Parse V-Segment header var (segment, totalSegments) = ParseSegmentsHeader(); // sanity check for append operations if (segment <= 1 || totalSegments <= 1) { return UploadResult.Error("Malformed request, segment must be > 1 for append"); } var editSecret = Request.Headers.GetHeader("V-EditSecret"); var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments, stripMetadata) { EditSecret = editSecret?.FromBase58Guid() ?? Guid.Empty, Id = gid }, HttpContext.RequestAborted); // update file size await _metadata.Update(vf.Id, vf); return UploadResult.Success(vf.ToResponse(true)); } catch (Exception ex) { return UploadResult.Error(ex.Message); } } /// /// Return information about a specific file /// /// /// [HttpGet] [Route("{id}")] public async Task GetInfo([FromRoute] string id) { if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404); var uid = HttpContext.GetUserId(); var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid; var info = await _fileInfo.Get(fid, isOwner || HttpContext.IsRole(Roles.Admin)); if (info == default) return StatusCode(404); return Json(info); } /// /// Return information about a specific file /// /// /// [HttpGet] [Route("{id}/metrics")] public async Task Metrics([FromRoute] string id) { if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404); var stats = await _timeSeriesStats.GetBandwidth(fid, DateTime.UtcNow.Subtract(TimeSpan.FromDays(30)), DateTime.UtcNow); return Json(stats); } /// /// Create a payment order to pay /// /// File id /// [HttpGet] [Route("{id}/payment")] public async ValueTask CreateOrder([FromRoute] string id) { var gid = id.FromBase58Guid(); var file = await _fileInfo.Get(gid, false); var config = await _paymentStore.Get(gid); var provider = await _paymentFactory.CreateProvider(config!.Service); return await provider.CreateOrder(file!.Payment!); } /// /// Return the status of an order /// /// File id /// Order id /// [HttpGet] [Route("{id}/payment/{order:guid}")] public async ValueTask GetOrderStatus([FromRoute] string id, [FromRoute] Guid order) { var gid = id.FromBase58Guid(); var config = await _paymentStore.Get(gid); var provider = await _paymentFactory.CreateProvider(config!.Service); return await provider.GetOrderStatus(order); } /// /// Update the payment config /// /// File id /// Requested config to set on the file /// [HttpPost] [Route("{id}/payment")] public async Task SetPaymentConfig([FromRoute] string id, [FromBody] SetPaymentConfigRequest req) { var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return NotFound(); if (!meta.CanEdit(req.EditSecret)) return Unauthorized(); if (req.StrikeHandle != default) { if (meta.Paywall?.Id != default) { await _paymentStore.Delete(meta.Paywall.Id); } await _paymentStore.Add(gid, new Paywall { FileId = meta.Id, Service = PaywallService.Strike, Upstream = req.StrikeHandle, Amount = req.Amount, Currency = Enum.Parse(req.Currency), Required = req.Required }); return Ok(); } // if none set, delete existing config if (meta.Paywall?.Id != default) { await _paymentStore.Delete(meta.Paywall.Id); } 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] VoidFileMeta fileMeta) { var gid = id.FromBase58Guid(); var meta = await _metadata.Get(gid); if (meta == default) return NotFound(); if (!(meta.CanEdit(fileMeta.EditSecret) || HttpContext.IsRole(Roles.Admin))) return Unauthorized(); await _metadata.Update(gid, new() { Name = fileMeta.Name, Description = fileMeta.Description, Expires = fileMeta.Expires, MimeType = fileMeta.MimeType }); return Ok(); } private (int Segment, int TotalSegments) ParseSegmentsHeader() { // Parse V-Segment header int segment = 1, totalSegments = 1; var segmentHeader = Request.Headers.GetHeader("V-Segment"); if (!string.IsNullOrEmpty(segmentHeader)) { var split = segmentHeader.Split("/"); if (split.Length == 2 && int.TryParse(split[0], out var a) && int.TryParse(split[1], out var b)) { segment = a; totalSegments = b; } } return (segment, totalSegments); } } [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, VoidFileResponse? File, string? ErrorMessage) { public static UploadResult Success(VoidFileResponse vf) => new(true, vf, null); public static UploadResult Error(string message) => new(false, null, message); } public record SetPaymentConfigRequest { [JsonConverter(typeof(Base58GuidConverter))] public Guid EditSecret { get; init; } public decimal Amount { get; init; } public string Currency { get; init; } = null!; public string? StrikeHandle { get; init; } public bool Required { get; init; } } }