nip96
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Kieran 2023-11-20 15:22:12 +00:00
parent 3f373e6ca3
commit 992ea50aba
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
16 changed files with 1063 additions and 218 deletions

View File

@ -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<RangeRequest>()), 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<RangeRequest>()
};
}
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<VoidFileResponse?> 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<VoidFileResponse?> 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<bool> 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<RangeRequest> 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;
}
}
}
}

View File

@ -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<DownloadController> _logger;
public DownloadController(FileStoreFactory storage, ILogger<DownloadController> 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<RangeRequest>()), 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<RangeRequest>()
};
}
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<VoidFileResponse?> 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<bool> 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<RangeRequest> 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);
}
}

View File

@ -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<NostrController> 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<IActionResult> 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<IActionResult> 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<int>? SupportedNips { get; init; }
[JsonProperty("tos_url")]
public Uri? Tos { get; init; }
[JsonProperty("content_types")]
public List<string>? ContentTypes { get; init; }
[JsonProperty("plans")]
public Dictionary<string, Nip96Plan>? 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<string>? 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<List<string>> Tags { get; init; } = new();
}

View File

@ -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<IActionResult> UploadFile([FromQuery] bool cli = false)
{
try

View File

@ -26,7 +26,7 @@ public class FileConfiguration : IEntityTypeConfiguration<File>
.IsRequired();
builder.Property(a => a.Expires);
builder.Property(a => a.Storage)
.IsRequired()
.HasDefaultValue("local-disk");
@ -34,6 +34,13 @@ public class FileConfiguration : IEntityTypeConfiguration<File>
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);
}
}

View File

@ -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; }
}

View File

@ -0,0 +1,491 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ApiKey", (string)null);
});
modelBuilder.Entity("VoidCat.Database.EmailVerification", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("Code")
.HasColumnType("uuid");
b.Property<DateTime>("Expires")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("EmailVerification", (string)null);
});
modelBuilder.Entity("VoidCat.Database.File", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Digest")
.HasColumnType("text");
b.Property<Guid>("EditSecret")
.HasColumnType("uuid");
b.Property<string>("EncryptionParams")
.HasColumnType("text");
b.Property<DateTime?>("Expires")
.HasColumnType("timestamp with time zone");
b.Property<string>("MagnetLink")
.HasColumnType("text");
b.Property<string>("MediaDimensions")
.HasColumnType("text");
b.Property<string>("MimeType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("application/octet-stream");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("OriginalDigest")
.HasColumnType("text");
b.Property<decimal>("Size")
.HasColumnType("numeric(20,0)");
b.Property<string>("Storage")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("local-disk");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<byte>("Currency")
.HasColumnType("smallint");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<bool>("Required")
.HasColumnType("boolean");
b.Property<int>("Service")
.HasColumnType("integer");
b.Property<string>("Upstream")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("FileId")
.IsUnique();
b.ToTable("Payment", (string)null);
});
modelBuilder.Entity("VoidCat.Database.PaywallOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<decimal>("Amount")
.HasColumnType("numeric");
b.Property<byte>("Currency")
.HasColumnType("smallint");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<int>("Service")
.HasColumnType("integer");
b.Property<byte>("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<Guid>("OrderId")
.HasColumnType("uuid");
b.Property<DateTime>("Expire")
.HasColumnType("timestamp with time zone");
b.Property<string>("Invoice")
.IsRequired()
.HasColumnType("text");
b.HasKey("OrderId");
b.ToTable("PaymentOrderLightning", (string)null);
});
modelBuilder.Entity("VoidCat.Database.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AuthType")
.HasColumnType("integer");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("Created")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("void user");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("text");
b.Property<int>("Flags")
.HasColumnType("integer");
b.Property<DateTime?>("LastLogin")
.HasColumnType("timestamp with time zone");
b.Property<string>("Password")
.HasColumnType("text");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AccessToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Expires")
.HasColumnType("timestamp with time zone");
b.Property<string>("IdToken")
.HasColumnType("text");
b.Property<string>("Provider")
.IsRequired()
.HasColumnType("text");
b.Property<string>("RefreshToken")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Scope")
.IsRequired()
.HasColumnType("text");
b.Property<string>("TokenType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("UsersAuthToken", (string)null);
});
modelBuilder.Entity("VoidCat.Database.UserFile", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.HasKey("UserId", "FileId");
b.HasIndex("FileId")
.IsUnique();
b.ToTable("UserFiles", (string)null);
});
modelBuilder.Entity("VoidCat.Database.UserRole", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("Role")
.HasColumnType("text");
b.HasKey("UserId", "Role");
b.ToTable("UserRoles", (string)null);
});
modelBuilder.Entity("VoidCat.Database.VirusScanResult", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("FileId")
.HasColumnType("uuid");
b.Property<string>("Names")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("ScanTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Scanner")
.IsRequired()
.HasColumnType("text");
b.Property<decimal>("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
}
}
}

View File

@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace VoidCat.Migrations
{
/// <inheritdoc />
public partial class Nip96 : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "MediaDimensions",
table: "Files",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
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");
}
/// <inheritdoc />
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");
}
}
}

View File

@ -94,6 +94,9 @@ namespace VoidCat.Migrations
b.Property<string>("MagnetLink")
.HasColumnType("text");
b.Property<string>("MediaDimensions")
.HasColumnType("text");
b.Property<string>("MimeType")
.IsRequired()
.ValueGeneratedOnAdd()
@ -103,6 +106,9 @@ namespace VoidCat.Migrations
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("OriginalDigest")
.HasColumnType("text");
b.Property<decimal>("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);

View File

@ -15,6 +15,13 @@ public interface IFileMetadataStore
/// <returns></returns>
ValueTask<File?> Get(Guid id);
/// <summary>
/// Get metadata for a single file by its hash
/// </summary>
/// <param name="digest"></param>
/// <returns></returns>
ValueTask<File?> GetHash(string digest);
/// <summary>
/// Get metadata for multiple files
/// </summary>

View File

@ -26,6 +26,11 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
{
return GetMeta<Database.File>(id);
}
public ValueTask<Database.File?> GetHash(string digest)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<Database.File>> Get(Guid[] ids)

View File

@ -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());
}

View File

@ -1,10 +1,10 @@
using Microsoft.EntityFrameworkCore;
using VoidCat.Model;
using VoidCat.Services.Abstractions;
using File = VoidCat.Database.File;
namespace VoidCat.Services.Files;
/// <inheritdoc />
public class PostgresFileMetadataStore : IFileMetadataStore
{
private readonly VoidContext _db;
@ -18,8 +18,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore
public string? Key => "postgres";
/// <inheritdoc />
public async ValueTask<Database.File?> Get(Guid id)
public async ValueTask<File?> 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<File?> 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();
}
/// <inheritdoc />
public async ValueTask Delete(Guid id)
{
await _db.Files
@ -41,8 +47,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore
.ExecuteDeleteAsync();
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<Database.File>> Get(Guid[] ids)
public async ValueTask<IReadOnlyList<File>> Get(Guid[] ids)
{
return await _db.Files
.Include(a => a.Paywall)
@ -50,8 +55,7 @@ public class PostgresFileMetadataStore : IFileMetadataStore
.ToArrayAsync();
}
/// <inheritdoc />
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();
}
/// <inheritdoc />
public async ValueTask<PagedResult<Database.File>> ListFiles(PagedRequest request)
public async ValueTask<PagedResult<File>> ListFiles(PagedRequest request)
{
IQueryable<Database.File> MakeQuery(VoidContext db)
IQueryable<File> 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<Database.File> Enumerate()
async IAsyncEnumerable<File> Enumerate()
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<VoidContext>();
await foreach (var r in MakeQuery(db).AsAsyncEnumerable())
{
yield return r;
@ -121,7 +124,6 @@ public class PostgresFileMetadataStore : IFileMetadataStore
};
}
/// <inheritdoc />
public async ValueTask<IFileMetadataStore.StoreStats> Stats()
{
var size = await _db.Files

View File

@ -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<File?> GetHash(string digest)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<Database.File>> Get(Guid[] ids)

View File

@ -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

View File

@ -8,11 +8,8 @@
},
"AllowedHosts": "*",
"Settings": {
"SiteUrl": "https://localhost:7195",
"SiteUrl": "http://localhost:7195",
"DataDirectory": "./data",
"CorsOrigins": [
"http://localhost:3000",
"http://localhost:8080"
]
"CorsOrigins": []
}
}