diff --git a/VoidCat/Controllers/BaseDownloadController.cs b/VoidCat/Controllers/BaseDownloadController.cs new file mode 100644 index 0000000..5401fc1 --- /dev/null +++ b/VoidCat/Controllers/BaseDownloadController.cs @@ -0,0 +1,215 @@ +using System.Net; +using Microsoft.AspNetCore.Mvc; +using VoidCat.Database; +using VoidCat.Model; +using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; + +namespace VoidCat.Controllers; + +public abstract class BaseDownloadController : Controller +{ + private readonly ILogger _logger; + private readonly VoidSettings _settings; + private readonly FileInfoManager _fileInfo; + private readonly IPaymentOrderStore _paymentOrders; + private readonly IPaymentFactory _paymentFactory; + private readonly FileStoreFactory _storage; + + protected BaseDownloadController(VoidSettings settings, FileInfoManager fileInfo, IPaymentOrderStore paymentOrders, + IPaymentFactory paymentFactory, ILogger logger, FileStoreFactory storage) + { + _settings = settings; + _fileInfo = fileInfo; + _paymentOrders = paymentOrders; + _paymentFactory = paymentFactory; + _logger = logger; + _storage = storage; + } + + protected async Task SendResponse(string id, VoidFileResponse voidFile) + { + var gid = voidFile.Id; + + if (id.EndsWith(".torrent")) + { + var t = await voidFile.Metadata.MakeTorrent(voidFile.Id, + await _storage.Open(new(gid, Enumerable.Empty()), CancellationToken.None), + _settings.SiteUrl, _settings.TorrentTrackers); + + Response.Headers.ContentDisposition = $"inline; filename=\"{id}\""; + Response.ContentType = "application/x-bittorent"; + await t.EncodeToAsync(Response.Body); + return; + } + + var egressReq = new EgressRequest(gid, GetRanges(Request, (long)voidFile!.Metadata!.Size)); + if (egressReq.Ranges.Count() > 1) + { + _logger.LogWarning("Multi-range request not supported!"); + // downgrade to full send + egressReq = egressReq with + { + Ranges = Enumerable.Empty() + }; + } + else if (egressReq.Ranges.Count() == 1) + { + Response.StatusCode = (int)HttpStatusCode.PartialContent; + if (egressReq.Ranges.Sum(a => a.Size) == 0) + { + Response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; + return; + } + } + else + { + Response.Headers.AcceptRanges = "bytes"; + } + + foreach (var range in egressReq.Ranges) + { + Response.Headers.Add("content-range", range.ToContentRange()); + Response.ContentLength = range.Size; + } + + var preResult = await _storage.StartEgress(egressReq); + if (preResult.Redirect != null) + { + Response.StatusCode = (int)HttpStatusCode.Redirect; + Response.Headers.Location = preResult.Redirect.ToString(); + Response.ContentLength = 0; + return; + } + + var cts = HttpContext.RequestAborted; + await Response.StartAsync(cts); + await _storage.Egress(egressReq, Response.Body, cts); + await Response.CompleteAsync(); + } + + protected async Task SetupDownload(Guid id) + { + var meta = await _fileInfo.Get(id, false); + if (meta == null) + { + Response.StatusCode = 404; + return default; + } + + return await CheckDownload(meta); + } + + private async Task CheckDownload(VoidFileResponse meta) + { + var origin = Request.Headers.Referer.FirstOrDefault() ?? Request.Headers.Origin.FirstOrDefault(); + if (!string.IsNullOrEmpty(origin) && Uri.TryCreate(origin, UriKind.RelativeOrAbsolute, out var u)) + { + if (_settings.BlockedOrigins.Any(a => string.Equals(a, u.DnsSafeHost, StringComparison.InvariantCultureIgnoreCase))) + { + Response.StatusCode = (int)HttpStatusCode.Forbidden; + return default; + } + } + + // check payment order + if (meta.Payment != default && meta.Payment.Service != PaywallService.None && meta.Payment.Required) + { + var h402 = Request.Headers.FirstOrDefault(a => a.Key.Equals("Authorization", StringComparison.InvariantCultureIgnoreCase)) + .Value.FirstOrDefault(a => a?.StartsWith("L402") ?? false); + + var orderId = Request.Headers.GetHeader("V-OrderId") ?? h402 ?? Request.Query["orderId"]; + if (!await IsOrderPaid(orderId!)) + { + Response.Headers.CacheControl = "no-cache"; + Response.StatusCode = (int)HttpStatusCode.PaymentRequired; + if (meta.Payment.Service is PaywallService.Strike or PaywallService.LnProxy) + { + var accept = Request.Headers.GetHeader("accept"); + if (accept == "L402") + { + var provider = await _paymentFactory.CreateProvider(meta.Payment.Service); + var order = await provider.CreateOrder(meta.Payment!); + if (order != default) + { + Response.Headers.Add("access-control-expose-headers", "www-authenticate"); + Response.Headers.Add("www-authenticate", + $"L402 macaroon=\"{Convert.ToBase64String(order.Id.ToByteArray())}\", invoice=\"{order!.OrderLightning!.Invoice}\""); + } + } + } + + return default; + } + } + + // prevent hot-linking viruses + var referer = Request.Headers.Referer.Count > 0 ? new Uri(Request.Headers.Referer.First()!) : null; + var hasCorrectReferer = referer?.Host.Equals(_settings.SiteUrl.Host, StringComparison.InvariantCultureIgnoreCase) ?? + false; + + if (meta.VirusScan?.IsVirus == true && !hasCorrectReferer) + { + Response.StatusCode = (int)HttpStatusCode.Redirect; + Response.Headers.Location = $"/{meta.Id.ToBase58()}"; + return default; + } + + Response.Headers.XFrameOptions = "SAMEORIGIN"; + Response.Headers.ContentDisposition = $"inline; filename=\"{meta?.Metadata?.Name}\""; + Response.ContentType = meta?.Metadata?.MimeType ?? "application/octet-stream"; + + return meta; + } + + private async ValueTask IsOrderPaid(string? orderId) + { + if (orderId?.StartsWith("L402") ?? false) + { + orderId = new Guid(Convert.FromBase64String(orderId.Substring(5).Split(":")[0])).ToString(); + } + + if (Guid.TryParse(orderId, out var oid)) + { + var order = await _paymentOrders.Get(oid); + if (order?.Status == PaywallOrderStatus.Paid) + { + return true; + } + + if (order?.Status is PaywallOrderStatus.Unpaid) + { + // check status + var svc = await _paymentFactory.CreateProvider(order.Service); + var status = await svc.GetOrderStatus(order.Id); + if (status != default && status.Status != order.Status) + { + await _paymentOrders.UpdateStatus(order.Id, status.Status); + } + + if (status?.Status == PaywallOrderStatus.Paid) + { + return true; + } + } + } + + return false; + } + + private IEnumerable GetRanges(HttpRequest request, long totalSize) + { + foreach (var rangeHeader in request.Headers.Range) + { + if (string.IsNullOrEmpty(rangeHeader)) + { + continue; + } + + foreach (var h in RangeRequest.Parse(rangeHeader, totalSize)) + { + yield return h; + } + } + } +} diff --git a/VoidCat/Controllers/DownloadController.cs b/VoidCat/Controllers/DownloadController.cs index b1e5b54..d384f7f 100644 --- a/VoidCat/Controllers/DownloadController.cs +++ b/VoidCat/Controllers/DownloadController.cs @@ -1,7 +1,5 @@ -using System.Net; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; -using VoidCat.Database; using VoidCat.Model; using VoidCat.Services.Abstractions; using VoidCat.Services.Files; @@ -9,24 +7,12 @@ using VoidCat.Services.Files; namespace VoidCat.Controllers; [Route("d")] -public class DownloadController : Controller +public class DownloadController : BaseDownloadController { - private readonly VoidSettings _settings; - private readonly FileStoreFactory _storage; - private readonly FileInfoManager _fileInfo; - private readonly IPaymentOrderStore _paymentOrders; - private readonly IPaymentFactory _paymentFactory; - private readonly ILogger _logger; - public DownloadController(FileStoreFactory storage, ILogger logger, FileInfoManager fileInfo, IPaymentOrderStore paymentOrderStore, VoidSettings settings, IPaymentFactory paymentFactory) + : base(settings, fileInfo, paymentOrderStore, paymentFactory, logger, storage) { - _storage = storage; - _logger = logger; - _fileInfo = fileInfo; - _paymentOrders = paymentOrderStore; - _settings = settings; - _paymentFactory = paymentFactory; } [HttpOptions] @@ -52,180 +38,6 @@ public class DownloadController : Controller var voidFile = await SetupDownload(gid); if (voidFile == default) return; - if (id.EndsWith(".torrent")) - { - var t = await voidFile.Metadata.MakeTorrent(voidFile.Id, - await _storage.Open(new(gid, Enumerable.Empty()), CancellationToken.None), - _settings.SiteUrl, _settings.TorrentTrackers); - - Response.Headers.ContentDisposition = $"inline; filename=\"{id}\""; - Response.ContentType = "application/x-bittorent"; - await t.EncodeToAsync(Response.Body); - return; - } - - var egressReq = new EgressRequest(gid, GetRanges(Request, (long)voidFile!.Metadata!.Size)); - if (egressReq.Ranges.Count() > 1) - { - _logger.LogWarning("Multi-range request not supported!"); - // downgrade to full send - egressReq = egressReq with - { - Ranges = Enumerable.Empty() - }; - } - else if (egressReq.Ranges.Count() == 1) - { - Response.StatusCode = (int)HttpStatusCode.PartialContent; - if (egressReq.Ranges.Sum(a => a.Size) == 0) - { - Response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; - return; - } - } - else - { - Response.Headers.AcceptRanges = "bytes"; - } - - foreach (var range in egressReq.Ranges) - { - Response.Headers.Add("content-range", range.ToContentRange()); - Response.ContentLength = range.Size; - } - - var preResult = await _storage.StartEgress(egressReq); - if (preResult.Redirect != null) - { - Response.StatusCode = (int)HttpStatusCode.Redirect; - Response.Headers.Location = preResult.Redirect.ToString(); - Response.ContentLength = 0; - return; - } - - var cts = HttpContext.RequestAborted; - await Response.StartAsync(cts); - await _storage.Egress(egressReq, Response.Body, cts); - await Response.CompleteAsync(); - } - - private async Task SetupDownload(Guid id) - { - var origin = Request.Headers.Referer.FirstOrDefault() ?? Request.Headers.Origin.FirstOrDefault(); - if (!string.IsNullOrEmpty(origin) && Uri.TryCreate(origin, UriKind.RelativeOrAbsolute, out var u)) - { - if (_settings.BlockedOrigins.Any(a => string.Equals(a, u.DnsSafeHost, StringComparison.InvariantCultureIgnoreCase))) - { - Response.StatusCode = (int)HttpStatusCode.Forbidden; - return default; - } - } - - var meta = await _fileInfo.Get(id, false); - if (meta == null) - { - Response.StatusCode = 404; - return default; - } - - // check payment order - if (meta.Payment != default && meta.Payment.Service != PaywallService.None && meta.Payment.Required) - { - var h402 = Request.Headers.FirstOrDefault(a => a.Key.Equals("Authorization", StringComparison.InvariantCultureIgnoreCase)) - .Value.FirstOrDefault(a => a?.StartsWith("L402") ?? false); - - var orderId = Request.Headers.GetHeader("V-OrderId") ?? h402 ?? Request.Query["orderId"]; - if (!await IsOrderPaid(orderId!)) - { - Response.Headers.CacheControl = "no-cache"; - Response.StatusCode = (int)HttpStatusCode.PaymentRequired; - if (meta.Payment.Service is PaywallService.Strike or PaywallService.LnProxy) - { - var accept = Request.Headers.GetHeader("accept"); - if (accept == "L402") - { - var provider = await _paymentFactory.CreateProvider(meta.Payment.Service); - var order = await provider.CreateOrder(meta.Payment!); - if (order != default) - { - Response.Headers.Add("access-control-expose-headers", "www-authenticate"); - Response.Headers.Add("www-authenticate", - $"L402 macaroon=\"{Convert.ToBase64String(order.Id.ToByteArray())}\", invoice=\"{order!.OrderLightning!.Invoice}\""); - } - } - } - - return default; - } - } - - // prevent hot-linking viruses - var referer = Request.Headers.Referer.Count > 0 ? new Uri(Request.Headers.Referer.First()!) : null; - var hasCorrectReferer = referer?.Host.Equals(_settings.SiteUrl.Host, StringComparison.InvariantCultureIgnoreCase) ?? - false; - - if (meta.VirusScan?.IsVirus == true && !hasCorrectReferer) - { - Response.StatusCode = (int)HttpStatusCode.Redirect; - Response.Headers.Location = $"/{id.ToBase58()}"; - return default; - } - - Response.Headers.XFrameOptions = "SAMEORIGIN"; - Response.Headers.ContentDisposition = $"inline; filename=\"{meta?.Metadata?.Name}\""; - Response.ContentType = meta?.Metadata?.MimeType ?? "application/octet-stream"; - - return meta; - } - - private async ValueTask IsOrderPaid(string? orderId) - { - if (orderId?.StartsWith("L402") ?? false) - { - orderId = new Guid(Convert.FromBase64String(orderId.Substring(5).Split(":")[0])).ToString(); - } - - if (Guid.TryParse(orderId, out var oid)) - { - var order = await _paymentOrders.Get(oid); - if (order?.Status == PaywallOrderStatus.Paid) - { - return true; - } - - if (order?.Status is PaywallOrderStatus.Unpaid) - { - // check status - var svc = await _paymentFactory.CreateProvider(order.Service); - var status = await svc.GetOrderStatus(order.Id); - if (status != default && status.Status != order.Status) - { - await _paymentOrders.UpdateStatus(order.Id, status.Status); - } - - if (status?.Status == PaywallOrderStatus.Paid) - { - return true; - } - } - } - - return false; - } - - private IEnumerable GetRanges(HttpRequest request, long totalSize) - { - foreach (var rangeHeader in request.Headers.Range) - { - if (string.IsNullOrEmpty(rangeHeader)) - { - continue; - } - - foreach (var h in RangeRequest.Parse(rangeHeader, totalSize)) - { - yield return h; - } - } + await SendResponse(id, voidFile); } } diff --git a/VoidCat/Controllers/NostrController.cs b/VoidCat/Controllers/NostrController.cs new file mode 100644 index 0000000..2385c11 --- /dev/null +++ b/VoidCat/Controllers/NostrController.cs @@ -0,0 +1,232 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json; +using VoidCat.Model; +using VoidCat.Services; +using VoidCat.Services.Abstractions; +using VoidCat.Services.Files; +using VoidCat.Services.Users; +using File = VoidCat.Database.File; + +namespace VoidCat.Controllers; + +[Route("nostr")] +public class NostrController : BaseDownloadController +{ + private readonly VoidSettings _settings; + private readonly UserManager _userManager; + private readonly FileStoreFactory _storeFactory; + private readonly IFileMetadataStore _fileMetadata; + private readonly IUserUploadsStore _userUploads; + private readonly FileInfoManager _fileInfo; + + public NostrController(VoidSettings settings, UserManager userManager, FileStoreFactory storeFactory, IFileMetadataStore fileMetadata, + IUserUploadsStore userUploads, FileInfoManager fileInfo, IPaymentOrderStore paymentOrderStore, IPaymentFactory paymentFactory, + ILogger logger) + : base(settings, fileInfo, paymentOrderStore, paymentFactory, logger, storeFactory) + { + _settings = settings; + _userManager = userManager; + _storeFactory = storeFactory; + _fileMetadata = fileMetadata; + _userUploads = userUploads; + _fileInfo = fileInfo; + } + + [HttpGet("/.well-known/nostr/nip96.json")] + public IActionResult GetInfo() + { + var info = new Nip96Info + { + ApiUri = new Uri(_settings.SiteUrl, "/nostr"), + Plans = new() + { + { + "free", new Nip96Plan + { + Name = "Default", + MaxUploadSize = (long?)_settings.UploadSegmentSize + } + } + } + }; + + return Json(info); + } + + [HttpPost] + [DisableRequestSizeLimit] + [DisableFormValueModelBinding] + [Authorize(AuthenticationSchemes = NostrAuth.Scheme, Policy = Policies.RequireNostr)] + public async Task Upload() + { + var pubkey = HttpContext.GetPubKey(); + if (string.IsNullOrEmpty(pubkey)) + { + return Unauthorized(); + } + + try + { + var nostrUser = await _userManager.LoginOrRegister(pubkey); + var file = Request.Form.Files.First(); + + var meta = new File + { + MimeType = file.ContentType, + Name = file.FileName, + Description = Request.Form.TryGetValue("alt", out var sd) ? sd.First() : default, + Size = (ulong)file.Length, + Storage = nostrUser.Storage + }; + + var vf = await _storeFactory.Ingress(new(file.OpenReadStream(), meta, 1, 1, true), HttpContext.RequestAborted); + + // save metadata + await _fileMetadata.Add(vf); + await _userUploads.AddFile(nostrUser.Id, vf.Id); + + var ret = new Nip96UploadResult + { + FileHeader = new() + { + Tags = new() + { + new() {"url", new Uri(_settings.SiteUrl, $"/nostr/{vf.OriginalDigest}{Path.GetExtension(vf.Name)}").ToString()}, + new() {"ox", vf.OriginalDigest ?? "", _settings.SiteUrl.ToString()}, + new() {"x", vf.Digest ?? ""}, + new() {"m", vf.MimeType} + } + } + }; + + return Json(ret); + } + catch (Exception ex) + { + return Json(new Nip96UploadResult() + { + Status = "error", + Message = ex.Message + }); + } + } + + [HttpGet("{id}")] + public async Task GetFile([FromRoute] string id) + { + var digest = Path.GetFileNameWithoutExtension(id); + var file = await _fileMetadata.GetHash(digest); + if (file == default) + { + Response.StatusCode = 404; + return; + } + + var meta = await SetupDownload(file.Id); + if (meta == default) return; + + await SendResponse(id, meta); + } + + [HttpDelete("{id}")] + [Authorize(AuthenticationSchemes = NostrAuth.Scheme, Policy = Policies.RequireNostr)] + public async Task DeleteFile([FromRoute] string id) + { + var digest = Path.GetFileNameWithoutExtension(id); + var file = await _fileMetadata.GetHash(digest); + if (file == default) + { + return NotFound(); + } + + var pubkey = HttpContext.GetPubKey(); + if (string.IsNullOrEmpty(pubkey)) + { + return Unauthorized(); + } + + var nostrUser = await _userManager.LoginOrRegister(pubkey); + var uploader = await _userUploads.Uploader(file.Id); + if (uploader == default || uploader != nostrUser.Id) + { + return Forbid(); + } + + await _fileInfo.Delete(file.Id); + + return Json(new Nip96UploadResult()); + } +} + +public class Nip96Info +{ + [JsonProperty("api_url")] + public Uri ApiUri { get; init; } = null!; + + [JsonProperty("download_url")] + public Uri? DownloadUrl { get; init; } + + [JsonProperty("delegated_to_url")] + public Uri? DelegatedTo { get; init; } + + [JsonProperty("supported_nips")] + public List? SupportedNips { get; init; } + + [JsonProperty("tos_url")] + public Uri? Tos { get; init; } + + [JsonProperty("content_types")] + public List? ContentTypes { get; init; } + + [JsonProperty("plans")] + public Dictionary? Plans { get; init; } +} + +public class Nip96Plan +{ + [JsonProperty("name")] + public string Name { get; init; } = null!; + + [JsonProperty("is_nip98_required")] + public bool Nip98Required { get; init; } = true; + + [JsonProperty("url")] + public Uri? LandingPage { get; init; } + + [JsonProperty("max_byte_size")] + public long? MaxUploadSize { get; init; } + + [JsonProperty("file_expiration")] + public int[] FileExpiration { get; init; } = {0, 0}; + + [JsonProperty("media_transformations")] + public Nip96MediaTransformations? MediaTransformations { get; init; } +} + +public class Nip96MediaTransformations +{ + [JsonProperty("image")] + public List? Image { get; init; } +} + +public class Nip96UploadResult +{ + [JsonProperty("status")] + public string Status { get; init; } = "success"; + + [JsonProperty("message")] + public string? Message { get; init; } + + [JsonProperty("processing_url")] + public Uri? ProcessingUrl { get; init; } + + [JsonProperty("nip94_event")] + public Nip94Info FileHeader { get; init; } = null!; +} + +public class Nip94Info +{ + [JsonProperty("tags")] + public List> Tags { get; init; } = new(); +} diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index faf07c5..7e6b3d5 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -59,8 +59,7 @@ namespace VoidCat.Controllers [HttpPost] [DisableRequestSizeLimit] [DisableFormValueModelBinding] - [Authorize(AuthenticationSchemes = "Bearer,Nostr")] - [AllowAnonymous] + [Authorize(AuthenticationSchemes = "Bearer,Nostr", Policy = Policies.RequireNostr)] public async Task UploadFile([FromQuery] bool cli = false) { try diff --git a/VoidCat/Database/Configurations/FileConfiguration.cs b/VoidCat/Database/Configurations/FileConfiguration.cs index 92bfdea..3ec7a1f 100644 --- a/VoidCat/Database/Configurations/FileConfiguration.cs +++ b/VoidCat/Database/Configurations/FileConfiguration.cs @@ -26,7 +26,7 @@ public class FileConfiguration : IEntityTypeConfiguration .IsRequired(); builder.Property(a => a.Expires); - + builder.Property(a => a.Storage) .IsRequired() .HasDefaultValue("local-disk"); @@ -34,6 +34,13 @@ public class FileConfiguration : IEntityTypeConfiguration builder.Property(a => a.EncryptionParams); builder.Property(a => a.MagnetLink); + builder.Property(a => a.OriginalDigest); + + builder.Property(a => a.MediaDimensions); + builder.HasIndex(a => a.Uploaded); + + builder.HasIndex(a => a.Digest); + builder.HasIndex(a => a.OriginalDigest); } } diff --git a/VoidCat/Database/File.cs b/VoidCat/Database/File.cs index 7052c6d..eb45bf8 100644 --- a/VoidCat/Database/File.cs +++ b/VoidCat/Database/File.cs @@ -14,6 +14,8 @@ public record File public string Storage { get; set; } = "local-disk"; public string? EncryptionParams { get; set; } public string? MagnetLink { get; set; } + public string? OriginalDigest { get; init; } + public string? MediaDimensions { get; init; } public Paywall? Paywall { get; init; } } diff --git a/VoidCat/Migrations/20231120132852_Nip96.Designer.cs b/VoidCat/Migrations/20231120132852_Nip96.Designer.cs new file mode 100644 index 0000000..bfe06d1 --- /dev/null +++ b/VoidCat/Migrations/20231120132852_Nip96.Designer.cs @@ -0,0 +1,491 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using VoidCat.Services; + +#nullable disable + +namespace VoidCat.Migrations +{ + [DbContext(typeof(VoidContext))] + [Migration("20231120132852_Nip96")] + partial class Nip96 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("VoidCat.Database.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Expiry") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ApiKey", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.EmailVerification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasColumnType("uuid"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("EmailVerification", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.File", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Digest") + .HasColumnType("text"); + + b.Property("EditSecret") + .HasColumnType("uuid"); + + b.Property("EncryptionParams") + .HasColumnType("text"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("MagnetLink") + .HasColumnType("text"); + + b.Property("MediaDimensions") + .HasColumnType("text"); + + b.Property("MimeType") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("application/octet-stream"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("OriginalDigest") + .HasColumnType("text"); + + b.Property("Size") + .HasColumnType("numeric(20,0)"); + + b.Property("Storage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("local-disk"); + + b.Property("Uploaded") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Digest"); + + b.HasIndex("OriginalDigest"); + + b.HasIndex("Uploaded"); + + b.ToTable("Files", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.Paywall", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("Currency") + .HasColumnType("smallint"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("Required") + .HasColumnType("boolean"); + + b.Property("Service") + .HasColumnType("integer"); + + b.Property("Upstream") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("FileId") + .IsUnique(); + + b.ToTable("Payment", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.PaywallOrder", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Amount") + .HasColumnType("numeric"); + + b.Property("Currency") + .HasColumnType("smallint"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("Service") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("FileId"); + + b.HasIndex("Status"); + + b.ToTable("PaymentOrder", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.PaywallOrderLightning", b => + { + b.Property("OrderId") + .HasColumnType("uuid"); + + b.Property("Expire") + .HasColumnType("timestamp with time zone"); + + b.Property("Invoice") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("OrderId"); + + b.ToTable("PaymentOrderLightning", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthType") + .HasColumnType("integer"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("void user"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property("Flags") + .HasColumnType("integer"); + + b.Property("LastLogin") + .HasColumnType("timestamp with time zone"); + + b.Property("Password") + .HasColumnType("text"); + + b.Property("Storage") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("local-disk"); + + b.HasKey("Id"); + + b.HasIndex("Email"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.UserAuthToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone"); + + b.Property("IdToken") + .HasColumnType("text"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("text"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("text"); + + b.Property("TokenType") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UsersAuthToken", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.UserFile", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "FileId"); + + b.HasIndex("FileId") + .IsUnique(); + + b.ToTable("UserFiles", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.UserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Role") + .HasColumnType("text"); + + b.HasKey("UserId", "Role"); + + b.ToTable("UserRoles", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.VirusScanResult", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("FileId") + .HasColumnType("uuid"); + + b.Property("Names") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScanTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Scanner") + .IsRequired() + .HasColumnType("text"); + + b.Property("Score") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("FileId"); + + b.ToTable("VirusScanResult", (string)null); + }); + + modelBuilder.Entity("VoidCat.Database.ApiKey", b => + { + b.HasOne("VoidCat.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("VoidCat.Database.EmailVerification", b => + { + b.HasOne("VoidCat.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("VoidCat.Database.Paywall", b => + { + b.HasOne("VoidCat.Database.File", "File") + .WithOne("Paywall") + .HasForeignKey("VoidCat.Database.Paywall", "FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + }); + + modelBuilder.Entity("VoidCat.Database.PaywallOrder", b => + { + b.HasOne("VoidCat.Database.File", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + }); + + modelBuilder.Entity("VoidCat.Database.PaywallOrderLightning", b => + { + b.HasOne("VoidCat.Database.PaywallOrder", "Order") + .WithOne("OrderLightning") + .HasForeignKey("VoidCat.Database.PaywallOrderLightning", "OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + }); + + modelBuilder.Entity("VoidCat.Database.UserAuthToken", b => + { + b.HasOne("VoidCat.Database.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("VoidCat.Database.UserFile", b => + { + b.HasOne("VoidCat.Database.File", "File") + .WithOne() + .HasForeignKey("VoidCat.Database.UserFile", "FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("VoidCat.Database.User", "User") + .WithMany("UserFiles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("VoidCat.Database.UserRole", b => + { + b.HasOne("VoidCat.Database.User", "User") + .WithMany("Roles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("VoidCat.Database.VirusScanResult", b => + { + b.HasOne("VoidCat.Database.File", "File") + .WithMany() + .HasForeignKey("FileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("File"); + }); + + modelBuilder.Entity("VoidCat.Database.File", b => + { + b.Navigation("Paywall"); + }); + + modelBuilder.Entity("VoidCat.Database.PaywallOrder", b => + { + b.Navigation("OrderLightning"); + }); + + modelBuilder.Entity("VoidCat.Database.User", b => + { + b.Navigation("Roles"); + + b.Navigation("UserFiles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/VoidCat/Migrations/20231120132852_Nip96.cs b/VoidCat/Migrations/20231120132852_Nip96.cs new file mode 100644 index 0000000..1771c44 --- /dev/null +++ b/VoidCat/Migrations/20231120132852_Nip96.cs @@ -0,0 +1,56 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace VoidCat.Migrations +{ + /// + public partial class Nip96 : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "MediaDimensions", + table: "Files", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "OriginalDigest", + table: "Files", + type: "text", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Files_Digest", + table: "Files", + column: "Digest"); + + migrationBuilder.CreateIndex( + name: "IX_Files_OriginalDigest", + table: "Files", + column: "OriginalDigest"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Files_Digest", + table: "Files"); + + migrationBuilder.DropIndex( + name: "IX_Files_OriginalDigest", + table: "Files"); + + migrationBuilder.DropColumn( + name: "MediaDimensions", + table: "Files"); + + migrationBuilder.DropColumn( + name: "OriginalDigest", + table: "Files"); + } + } +} diff --git a/VoidCat/Migrations/VoidContextModelSnapshot.cs b/VoidCat/Migrations/VoidContextModelSnapshot.cs index c0d2a6e..8351a9f 100644 --- a/VoidCat/Migrations/VoidContextModelSnapshot.cs +++ b/VoidCat/Migrations/VoidContextModelSnapshot.cs @@ -94,6 +94,9 @@ namespace VoidCat.Migrations b.Property("MagnetLink") .HasColumnType("text"); + b.Property("MediaDimensions") + .HasColumnType("text"); + b.Property("MimeType") .IsRequired() .ValueGeneratedOnAdd() @@ -103,6 +106,9 @@ namespace VoidCat.Migrations b.Property("Name") .HasColumnType("text"); + b.Property("OriginalDigest") + .HasColumnType("text"); + b.Property("Size") .HasColumnType("numeric(20,0)"); @@ -117,6 +123,10 @@ namespace VoidCat.Migrations b.HasKey("Id"); + b.HasIndex("Digest"); + + b.HasIndex("OriginalDigest"); + b.HasIndex("Uploaded"); b.ToTable("Files", (string)null); diff --git a/VoidCat/Services/Abstractions/IFileMetadataStore.cs b/VoidCat/Services/Abstractions/IFileMetadataStore.cs index ac648a4..bf9012a 100644 --- a/VoidCat/Services/Abstractions/IFileMetadataStore.cs +++ b/VoidCat/Services/Abstractions/IFileMetadataStore.cs @@ -15,6 +15,13 @@ public interface IFileMetadataStore /// ValueTask Get(Guid id); + /// + /// Get metadata for a single file by its hash + /// + /// + /// + ValueTask GetHash(string digest); + /// /// Get metadata for multiple files /// diff --git a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs index 7223f0b..c95265f 100644 --- a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs +++ b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs @@ -26,6 +26,11 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore { return GetMeta(id); } + + public ValueTask GetHash(string digest) + { + throw new NotImplementedException(); + } /// public async ValueTask> Get(Guid[] ids) diff --git a/VoidCat/Services/Files/LocalDiskFileStorage.cs b/VoidCat/Services/Files/LocalDiskFileStorage.cs index 62763a1..da45f76 100644 --- a/VoidCat/Services/Files/LocalDiskFileStorage.cs +++ b/VoidCat/Services/Files/LocalDiskFileStorage.cs @@ -50,6 +50,8 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore if (payload.ShouldStripMetadata && payload.Segment == payload.TotalSegments) { + fsTemp.Seek(0, SeekOrigin.Begin); + var originalHash = await SHA256.Create().ComputeHashAsync(fsTemp, cts); fsTemp.Close(); var ext = Path.GetExtension(vf.Name); var srcPath = $"{finalPath}_orig{ext}"; @@ -60,6 +62,7 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore if (res.Success) { File.Move(res.OutPath, finalPath); + File.Delete(srcPath); // recompute metadata @@ -69,7 +72,8 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore { Size = (ulong)fInfo.Length, Digest = hash.ToHex(), - MimeType = res.MimeType ?? vf.MimeType + MimeType = res.MimeType ?? vf.MimeType, + OriginalDigest = originalHash.ToHex() }; } else @@ -125,7 +129,7 @@ public class LocalDiskFileStore : StreamFileStore, IFileStore return path; } - + private string MapPath(Guid id) => Path.Join(_settings.DataDirectory, "files-v2", id.ToString()[..2], id.ToString()[2..4], id.ToString()); } diff --git a/VoidCat/Services/Files/PostgresFileMetadataStore.cs b/VoidCat/Services/Files/PostgresFileMetadataStore.cs index 7b0a28e..483fe8d 100644 --- a/VoidCat/Services/Files/PostgresFileMetadataStore.cs +++ b/VoidCat/Services/Files/PostgresFileMetadataStore.cs @@ -1,10 +1,10 @@ using Microsoft.EntityFrameworkCore; using VoidCat.Model; using VoidCat.Services.Abstractions; +using File = VoidCat.Database.File; namespace VoidCat.Services.Files; -/// public class PostgresFileMetadataStore : IFileMetadataStore { private readonly VoidContext _db; @@ -18,8 +18,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore public string? Key => "postgres"; - /// - public async ValueTask Get(Guid id) + public async ValueTask Get(Guid id) { return await _db.Files .AsNoTracking() @@ -27,13 +26,20 @@ public class PostgresFileMetadataStore : IFileMetadataStore .SingleOrDefaultAsync(a => a.Id == id); } - public async ValueTask Add(Database.File f) + public async ValueTask GetHash(string digest) + { + return await _db.Files + .AsNoTracking() + .Include(a => a.Paywall) + .SingleOrDefaultAsync(a => a.Digest == digest || a.OriginalDigest == digest); + } + + public async ValueTask Add(File f) { _db.Files.Add(f); await _db.SaveChangesAsync(); } - /// public async ValueTask Delete(Guid id) { await _db.Files @@ -41,8 +47,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore .ExecuteDeleteAsync(); } - /// - public async ValueTask> Get(Guid[] ids) + public async ValueTask> Get(Guid[] ids) { return await _db.Files .Include(a => a.Paywall) @@ -50,8 +55,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore .ToArrayAsync(); } - /// - public async ValueTask Update(Guid id, Database.File obj) + public async ValueTask Update(Guid id, File obj) { var existing = await _db.Files.FindAsync(id); if (existing == default) @@ -63,10 +67,9 @@ public class PostgresFileMetadataStore : IFileMetadataStore await _db.SaveChangesAsync(); } - /// - public async ValueTask> ListFiles(PagedRequest request) + public async ValueTask> ListFiles(PagedRequest request) { - IQueryable MakeQuery(VoidContext db) + IQueryable MakeQuery(VoidContext db) { var q = db.Files.AsNoTracking().AsQueryable(); switch (request.SortBy, request.SortOrder) @@ -99,12 +102,12 @@ public class PostgresFileMetadataStore : IFileMetadataStore return q.Skip(request.Page * request.PageSize).Take(request.PageSize); } - - async IAsyncEnumerable Enumerate() + + async IAsyncEnumerable Enumerate() { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - + await foreach (var r in MakeQuery(db).AsAsyncEnumerable()) { yield return r; @@ -121,7 +124,6 @@ public class PostgresFileMetadataStore : IFileMetadataStore }; } - /// public async ValueTask Stats() { var size = await _db.Files diff --git a/VoidCat/Services/Files/S3FileMetadataStore.cs b/VoidCat/Services/Files/S3FileMetadataStore.cs index ee740a7..e0530f9 100644 --- a/VoidCat/Services/Files/S3FileMetadataStore.cs +++ b/VoidCat/Services/Files/S3FileMetadataStore.cs @@ -2,6 +2,7 @@ using Newtonsoft.Json; using VoidCat.Model; using VoidCat.Services.Abstractions; +using File = VoidCat.Database.File; namespace VoidCat.Services.Files; @@ -40,6 +41,11 @@ public class S3FileMetadataStore : IFileMetadataStore return default; } + + public ValueTask GetHash(string digest) + { + throw new NotImplementedException(); + } /// public async ValueTask> Get(Guid[] ids) diff --git a/VoidCat/VoidStartup.cs b/VoidCat/VoidStartup.cs index 4c6579e..e853123 100644 --- a/VoidCat/VoidStartup.cs +++ b/VoidCat/VoidStartup.cs @@ -112,7 +112,7 @@ public static class VoidStartup if (voidSettings.CorsOrigins.Count > 0) { - p.WithOrigins(voidSettings.CorsOrigins.Select(a => a.OriginalString).ToArray()) + p.WithOrigins(voidSettings.CorsOrigins.Select(a => a.ToString()).ToArray()) .AllowCredentials(); } else diff --git a/VoidCat/appsettings.json b/VoidCat/appsettings.json index 64b324b..db9f49c 100644 --- a/VoidCat/appsettings.json +++ b/VoidCat/appsettings.json @@ -8,11 +8,8 @@ }, "AllowedHosts": "*", "Settings": { - "SiteUrl": "https://localhost:7195", + "SiteUrl": "http://localhost:7195", "DataDirectory": "./data", - "CorsOrigins": [ - "http://localhost:3000", - "http://localhost:8080" - ] + "CorsOrigins": [] } }