voidkitty/VoidCat/Controllers/UploadController.cs

413 lines
15 KiB
C#
Raw Permalink Normal View History

2023-10-13 20:46:00 +00:00
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
2022-01-28 10:32:00 +00:00
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ModelBinding;
2022-03-14 09:40:18 +00:00
using Microsoft.AspNetCore.StaticFiles;
2022-01-25 23:39:51 +00:00
using Newtonsoft.Json;
using VoidCat.Database;
2022-01-25 16:17:48 +00:00
using VoidCat.Model;
2022-02-16 16:33:00 +00:00
using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
2023-10-13 19:07:35 +00:00
using VoidCat.Services.Users;
using File = VoidCat.Database.File;
2022-01-25 16:17:48 +00:00
namespace VoidCat.Controllers
{
[Route("upload")]
public class UploadController : Controller
{
private readonly FileStoreFactory _storage;
2022-02-21 09:39:59 +00:00
private readonly IFileMetadataStore _metadata;
2022-09-07 15:25:31 +00:00
private readonly IPaymentStore _paymentStore;
2022-09-07 14:52:40 +00:00
private readonly IPaymentFactory _paymentFactory;
2022-09-06 21:32:22 +00:00
private readonly FileInfoManager _fileInfo;
2022-06-08 16:17:53 +00:00
private readonly IUserUploadsStore _userUploads;
private readonly IUserStore _userStore;
2023-10-13 19:07:35 +00:00
private readonly UserManager _userManager;
2022-06-14 10:46:31 +00:00
private readonly ITimeSeriesStatsReporter _timeSeriesStats;
private readonly VoidSettings _settings;
2022-01-25 16:17:48 +00:00
2022-09-07 14:52:40 +00:00
public UploadController(FileStoreFactory storage, IFileMetadataStore metadata, IPaymentStore payment,
IPaymentFactory paymentFactory, FileInfoManager fileInfo, IUserUploadsStore userUploads,
2023-10-13 19:07:35 +00:00
ITimeSeriesStatsReporter timeSeriesStats, IUserStore userStore, VoidSettings settings, UserManager userManager)
2022-01-25 16:17:48 +00:00
{
2022-01-25 23:39:51 +00:00
_storage = storage;
2022-02-21 09:39:59 +00:00
_metadata = metadata;
2022-09-07 15:25:31 +00:00
_paymentStore = payment;
2022-09-07 14:52:40 +00:00
_paymentFactory = paymentFactory;
2022-02-27 13:54:25 +00:00
_fileInfo = fileInfo;
2022-06-08 16:17:53 +00:00
_userUploads = userUploads;
2022-06-14 10:46:31 +00:00
_timeSeriesStats = timeSeriesStats;
_userStore = userStore;
_settings = settings;
2023-10-13 19:07:35 +00:00
_userManager = userManager;
2022-01-25 16:17:48 +00:00
}
2022-03-08 13:47:42 +00:00
/// <summary>
/// Primary upload endpoint
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="cli">True if you want to return only the url of the file in the response</param>
/// <returns>Returns <see cref="UploadResult"/></returns>
2022-01-25 16:17:48 +00:00
[HttpPost]
2022-01-25 23:39:51 +00:00
[DisableRequestSizeLimit]
2022-01-28 10:32:00 +00:00
[DisableFormValueModelBinding]
2023-11-20 15:22:12 +00:00
[Authorize(AuthenticationSchemes = "Bearer,Nostr", Policy = Policies.RequireNostr)]
2023-11-28 14:38:43 +00:00
[AllowAnonymous]
2022-02-28 20:47:57 +00:00
public async Task<IActionResult> UploadFile([FromQuery] bool cli = false)
2022-01-25 16:17:48 +00:00
{
2022-02-10 22:22:34 +00:00
try
2022-02-03 23:06:39 +00:00
{
2023-08-23 14:01:35 +00:00
var stripMetadata = Request.Headers.GetHeader("V-Strip-Metadata")
?.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false;
if (_settings.MaintenanceMode && !stripMetadata)
2023-04-27 08:51:11 +00:00
{
throw new InvalidOperationException("Site is in maintenance mode");
}
2022-02-24 23:05:33 +00:00
var uid = HttpContext.GetUserId();
2023-10-13 19:07:35 +00:00
var pubkey = HttpContext.GetPubKey();
if (uid == default && !string.IsNullOrEmpty(pubkey))
{
var nostrUser = await _userManager.LoginOrRegister(pubkey);
uid = nostrUser.Id;
}
2022-03-14 09:40:18 +00:00
var mime = Request.Headers.GetHeader("V-Content-Type");
var filename = Request.Headers.GetHeader("V-Filename");
2023-01-26 13:49:38 +00:00
2022-03-14 09:40:18 +00:00
if (string.IsNullOrEmpty(mime) && !string.IsNullOrEmpty(filename))
{
if (new FileExtensionContentTypeProvider().TryGetContentType(filename, out var contentType))
{
mime = contentType;
}
}
2023-04-04 10:22:00 +00:00
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!;
}
}
2022-07-26 12:32:36 +00:00
var meta = new File
2022-02-10 22:22:34 +00:00
{
2022-03-14 09:40:18 +00:00
MimeType = mime,
Name = filename,
2022-02-17 15:52:49 +00:00
Description = Request.Headers.GetHeader("V-Description"),
2022-02-24 23:05:33 +00:00
Digest = Request.Headers.GetHeader("V-Full-Digest"),
2023-01-26 13:49:38 +00:00
Size = (ulong?)Request.ContentLength ?? 0UL,
2022-09-11 19:07:38 +00:00
Storage = store,
EncryptionParams = Request.Headers.GetHeader("V-EncryptionParams")
2022-02-10 22:22:34 +00:00
};
2022-07-26 12:32:36 +00:00
var (segment, totalSegments) = ParseSegmentsHeader();
2023-01-26 13:49:38 +00:00
var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments, stripMetadata),
2022-09-09 12:22:53 +00:00
HttpContext.RequestAborted);
2022-02-28 20:47:57 +00:00
2022-06-08 16:17:53 +00:00
// save metadata
await _metadata.Add(vf);
2022-06-10 20:42:36 +00:00
2022-06-08 16:17:53 +00:00
// attach file upload to user
if (uid.HasValue)
{
2023-01-26 13:49:38 +00:00
await _userUploads.AddFile(uid.Value, vf.Id);
2022-06-08 16:17:53 +00:00
}
2022-06-10 20:42:36 +00:00
2022-02-28 20:47:57 +00:00
if (cli)
{
2023-05-11 18:12:09 +00:00
var urlBuilder = new UriBuilder(_settings.SiteUrl)
{
Path = $"/d/{vf.Id.ToBase58()}"
};
2022-02-28 20:47:57 +00:00
return Content(urlBuilder.Uri.ToString(), "text/plain");
}
return Json(UploadResult.Success(vf.ToResponse(true)));
2022-02-10 22:22:34 +00:00
}
catch (Exception ex)
{
2022-02-28 20:47:57 +00:00
return Json(UploadResult.Error(ex.Message));
2022-02-10 22:22:34 +00:00
}
}
2022-03-08 13:47:42 +00:00
/// <summary>
/// Append data onto a file
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="id"></param>
/// <returns></returns>
2022-02-10 22:22:34 +00:00
[HttpPost]
[DisableRequestSizeLimit]
[DisableFormValueModelBinding]
[Route("{id}")]
public async Task<UploadResult> UploadFileAppend([FromRoute] string id)
{
try
{
2023-08-23 14:01:35 +00:00
var stripMetadata = Request.Headers.GetHeader("V-Strip-Metadata")
?.Equals("true", StringComparison.InvariantCultureIgnoreCase) ?? false;
if (_settings.MaintenanceMode && !stripMetadata)
2023-04-27 08:51:11 +00:00
{
throw new InvalidOperationException("Site is in maintenance mode");
}
2022-02-10 22:22:34 +00:00
var gid = id.FromBase58Guid();
var meta = await _metadata.Get(gid);
2022-02-27 13:54:25 +00:00
if (meta == default) return UploadResult.Error("File not found");
2022-02-10 22:22:34 +00:00
2022-07-26 12:32:36 +00:00
// 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");
}
2022-02-17 09:32:34 +00:00
var editSecret = Request.Headers.GetHeader("V-EditSecret");
2023-01-26 13:49:38 +00:00
var vf = await _storage.Ingress(new(Request.Body, meta, segment, totalSegments, stripMetadata)
2022-02-10 22:22:34 +00:00
{
2022-02-26 22:57:42 +00:00
EditSecret = editSecret?.FromBase58Guid() ?? Guid.Empty,
2022-07-26 12:32:36 +00:00
Id = gid
2022-02-10 22:22:34 +00:00
}, HttpContext.RequestAborted);
2022-06-10 20:42:36 +00:00
2022-06-08 16:17:53 +00:00
// update file size
await _metadata.Update(vf.Id, vf);
return UploadResult.Success(vf.ToResponse(true));
2022-02-10 22:22:34 +00:00
}
catch (Exception ex)
{
return UploadResult.Error(ex.Message);
}
2022-01-25 16:17:48 +00:00
}
2022-03-08 13:47:42 +00:00
/// <summary>
/// Return information about a specific file
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
2022-01-25 23:39:51 +00:00
[HttpGet]
[Route("{id}")]
2022-06-10 20:42:36 +00:00
public async Task<IActionResult> GetInfo([FromRoute] string id)
2022-01-25 16:17:48 +00:00
{
if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404);
2022-06-10 20:42:36 +00:00
var uid = HttpContext.GetUserId();
var isOwner = uid.HasValue && await _userUploads.Uploader(fid) == uid;
2023-10-17 20:42:54 +00:00
var info = await _fileInfo.Get(fid, isOwner || HttpContext.IsRole(Roles.Admin));
if (info == default) return StatusCode(404);
return Json(info);
2022-01-25 16:17:48 +00:00
}
2022-02-21 09:39:59 +00:00
2022-06-14 10:46:31 +00:00
/// <summary>
/// Return information about a specific file
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
[HttpGet]
[Route("{id}/metrics")]
public async Task<IActionResult> Metrics([FromRoute] string id)
{
if (!id.TryFromBase58Guid(out var fid)) return StatusCode(404);
2022-07-23 20:44:57 +00:00
var stats = await _timeSeriesStats.GetBandwidth(fid, DateTime.UtcNow.Subtract(TimeSpan.FromDays(30)),
DateTime.UtcNow);
2022-06-14 10:46:31 +00:00
return Json(stats);
}
2022-03-08 13:47:42 +00:00
/// <summary>
2022-09-07 14:52:40 +00:00
/// Create a payment order to pay
2022-03-08 13:47:42 +00:00
/// </summary>
/// <param name="id">File id</param>
/// <returns></returns>
2022-02-21 09:39:59 +00:00
[HttpGet]
2022-09-07 14:52:40 +00:00
[Route("{id}/payment")]
public async ValueTask<PaywallOrder?> CreateOrder([FromRoute] string id)
2022-02-21 09:39:59 +00:00
{
2022-02-21 12:54:57 +00:00
var gid = id.FromBase58Guid();
var file = await _fileInfo.Get(gid, false);
2022-09-07 15:25:31 +00:00
var config = await _paymentStore.Get(gid);
2022-02-21 12:54:57 +00:00
2022-09-07 14:52:40 +00:00
var provider = await _paymentFactory.CreateProvider(config!.Service);
return await provider.CreateOrder(file!.Payment!);
2022-02-21 09:39:59 +00:00
}
2022-02-27 13:54:25 +00:00
2022-03-08 13:47:42 +00:00
/// <summary>
/// Return the status of an order
/// </summary>
/// <param name="id">File id</param>
/// <param name="order">Order id</param>
/// <returns></returns>
2022-02-21 12:54:57 +00:00
[HttpGet]
2022-09-07 14:52:40 +00:00
[Route("{id}/payment/{order:guid}")]
public async ValueTask<PaywallOrder?> GetOrderStatus([FromRoute] string id, [FromRoute] Guid order)
2022-02-21 12:54:57 +00:00
{
var gid = id.FromBase58Guid();
2022-09-07 15:25:31 +00:00
var config = await _paymentStore.Get(gid);
2022-02-21 12:54:57 +00:00
2022-09-07 14:52:40 +00:00
var provider = await _paymentFactory.CreateProvider(config!.Service);
2022-02-21 12:54:57 +00:00
return await provider.GetOrderStatus(order);
}
2022-03-08 13:47:42 +00:00
/// <summary>
2022-09-07 14:52:40 +00:00
/// Update the payment config
2022-03-08 13:47:42 +00:00
/// </summary>
/// <param name="id">File id</param>
/// <param name="req">Requested config to set on the file</param>
/// <returns></returns>
2022-02-21 09:39:59 +00:00
[HttpPost]
2022-09-07 14:52:40 +00:00
[Route("{id}/payment")]
public async Task<IActionResult> SetPaymentConfig([FromRoute] string id, [FromBody] SetPaymentConfigRequest req)
2022-02-21 09:39:59 +00:00
{
var gid = id.FromBase58Guid();
var meta = await _metadata.Get(gid);
2022-02-21 09:39:59 +00:00
if (meta == default) return NotFound();
2022-06-10 20:42:36 +00:00
if (!meta.CanEdit(req.EditSecret)) return Unauthorized();
2022-02-21 12:54:57 +00:00
if (req.StrikeHandle != default)
2022-02-21 09:39:59 +00:00
{
2023-05-30 10:24:13 +00:00
if (meta.Paywall?.Id != default)
{
await _paymentStore.Delete(meta.Paywall.Id);
}
2023-08-23 14:01:35 +00:00
await _paymentStore.Add(gid, new Paywall
2022-06-13 13:35:26 +00:00
{
2023-05-30 10:24:13 +00:00
FileId = meta.Id,
Service = PaywallService.Strike,
2023-05-30 10:24:13 +00:00
Upstream = req.StrikeHandle,
Amount = req.Amount,
Currency = Enum.Parse<PaywallCurrency>(req.Currency),
2022-09-07 15:25:31 +00:00
Required = req.Required
2022-06-13 13:35:26 +00:00
});
2022-02-21 09:39:59 +00:00
return Ok();
}
2023-05-30 10:24:13 +00:00
// if none set, delete existing config
if (meta.Paywall?.Id != default)
{
await _paymentStore.Delete(meta.Paywall.Id);
}
2023-08-23 14:01:35 +00:00
2022-02-21 14:32:13 +00:00
return Ok();
2022-02-21 09:39:59 +00:00
}
2022-03-15 10:39:36 +00:00
/// <summary>
/// Update metadata about file
/// </summary>
/// <param name="id">Id of file to edit</param>
/// <param name="fileMeta">New metadata to update</param>
/// <returns></returns>
/// <remarks>
/// You can only change `Name`, `Description` and `MimeType`
/// </remarks>
[HttpPost]
[Route("{id}/meta")]
public async Task<IActionResult> UpdateFileMeta([FromRoute] string id, [FromBody] VoidFileMeta fileMeta)
2022-03-15 10:39:36 +00:00
{
var gid = id.FromBase58Guid();
var meta = await _metadata.Get(gid);
2022-03-15 10:39:36 +00:00
if (meta == default) return NotFound();
2023-10-17 20:42:54 +00:00
if (!(meta.CanEdit(fileMeta.EditSecret) || HttpContext.IsRole(Roles.Admin))) return Unauthorized();
2022-03-15 10:39:36 +00:00
await _metadata.Update(gid, new()
{
Name = fileMeta.Name,
Description = fileMeta.Description,
Expires = fileMeta.Expires,
MimeType = fileMeta.MimeType
});
2022-03-15 10:39:36 +00:00
return Ok();
}
2022-07-26 12:32:36 +00:00
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);
}
2022-01-25 16:17:48 +00:00
}
2022-01-28 10:32:00 +00:00
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class DisableFormValueModelBindingAttribute : Attribute, IResourceFilter
{
public void OnResourceExecuting(ResourceExecutingContext context)
{
var factories = context.ValueProviderFactories;
factories.RemoveType<FormValueProviderFactory>();
factories.RemoveType<FormFileValueProviderFactory>();
factories.RemoveType<JQueryFormValueProviderFactory>();
}
public void OnResourceExecuted(ResourceExecutedContext context)
{
}
}
2022-02-10 22:22:34 +00:00
public record UploadResult(bool Ok, VoidFileResponse? File, string? ErrorMessage)
2022-02-10 22:22:34 +00:00
{
public static UploadResult Success(VoidFileResponse vf)
2022-02-10 22:22:34 +00:00
=> new(true, vf, null);
public static UploadResult Error(string message)
=> new(false, null, message);
}
2022-02-21 09:39:59 +00:00
2022-09-07 14:52:40 +00:00
public record SetPaymentConfigRequest
2022-02-21 09:39:59 +00:00
{
[JsonConverter(typeof(Base58GuidConverter))]
public Guid EditSecret { get; init; }
2022-02-21 12:54:57 +00:00
public decimal Amount { get; init; }
public string Currency { get; init; } = null!;
public string? StrikeHandle { get; init; }
2022-09-09 12:22:53 +00:00
2022-09-07 15:25:31 +00:00
public bool Required { get; init; }
2022-02-21 09:39:59 +00:00
}
2023-01-26 13:49:38 +00:00
}