From edf26c21377fc26a4b5824d0d1dde87055381cc5 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 21 Feb 2022 12:54:57 +0000 Subject: [PATCH] Finish stike paywall --- VoidCat/Controllers/DownloadController.cs | 41 ++--- VoidCat/Controllers/UploadController.cs | 29 +++- VoidCat/Model/Extensions.cs | 5 +- VoidCat/Model/Paywall/Paywall.cs | 2 +- VoidCat/Model/Paywall/PaywallOrder.cs | 8 +- VoidCat/Model/VoidSettings.cs | 3 +- VoidCat/Program.cs | 37 +++-- .../Services/Abstractions/IPaywallFactory.cs | 5 +- .../Services/InMemory/InMemoryPaywallStore.cs | 5 +- VoidCat/Services/Paywall/PaywallFactory.cs | 38 +++++ .../Services/Paywall/StrikePaywallProvider.cs | 130 ++++++++++++++++ VoidCat/Services/Redis/RedisPaywallStore.cs | 9 +- .../Services/{Abstractions => }/StrikeApi.cs | 144 ++++++------------ VoidCat/spa/package.json | 1 + VoidCat/spa/src/Const.js | 11 ++ VoidCat/spa/src/Countdown.js | 21 +++ VoidCat/spa/src/FileEdit.css | 5 + VoidCat/spa/src/FileEdit.js | 11 +- VoidCat/spa/src/FilePaywall.js | 54 +++++-- VoidCat/spa/src/FilePreview.js | 26 +++- VoidCat/spa/src/LightningPaywall.js | 46 ++++++ VoidCat/spa/src/StrikePaywallConfig.js | 33 ++-- VoidCat/spa/src/Util.js | 38 +++++ VoidCat/spa/yarn.lock | 16 +- 24 files changed, 536 insertions(+), 182 deletions(-) create mode 100644 VoidCat/Services/Paywall/PaywallFactory.cs create mode 100644 VoidCat/Services/Paywall/StrikePaywallProvider.cs rename VoidCat/Services/{Abstractions => }/StrikeApi.cs (54%) create mode 100644 VoidCat/spa/src/Countdown.js create mode 100644 VoidCat/spa/src/LightningPaywall.js diff --git a/VoidCat/Controllers/DownloadController.cs b/VoidCat/Controllers/DownloadController.cs index 4f1d4e2..647198c 100644 --- a/VoidCat/Controllers/DownloadController.cs +++ b/VoidCat/Controllers/DownloadController.cs @@ -37,7 +37,7 @@ public class DownloadController : Controller var voidFile = await SetupDownload(gid); if (voidFile == default) return; - var egressReq = new EgressRequest(gid, GetRanges(Request, (long)voidFile!.Metadata!.Size)); + var egressReq = new EgressRequest(gid, GetRanges(Request, (long) voidFile!.Metadata!.Size)); if (egressReq.Ranges.Count() > 1) { _logger.LogWarning("Multi-range request not supported!"); @@ -49,10 +49,10 @@ public class DownloadController : Controller } else if (egressReq.Ranges.Count() == 1) { - Response.StatusCode = (int)HttpStatusCode.PartialContent; + Response.StatusCode = (int) HttpStatusCode.PartialContent; if (egressReq.Ranges.Sum(a => a.Size) == 0) { - Response.StatusCode = (int)HttpStatusCode.RequestedRangeNotSatisfiable; + Response.StatusCode = (int) HttpStatusCode.RequestedRangeNotSatisfiable; return; } } @@ -79,26 +79,17 @@ public class DownloadController : Controller if (meta == null) { Response.StatusCode = 404; - return null; + return default; } // check paywall if (meta.Paywall != default) { - var orderId = Request.Headers.GetHeader("V-OrderId"); - if (string.IsNullOrEmpty(orderId)) + var orderId = Request.Headers.GetHeader("V-OrderId") ?? Request.Query["orderId"]; + if (!await IsOrderPaid(orderId)) { - Response.StatusCode = (int)HttpStatusCode.PaymentRequired; - return null; - } - else - { - var order = await _paywall.GetOrder(orderId.FromBase58Guid()); - if (order?.Status != PaywallStatus.Paid) - { - Response.StatusCode = (int)HttpStatusCode.PaymentRequired; - return null; - } + Response.StatusCode = (int) HttpStatusCode.PaymentRequired; + return default; } } @@ -109,6 +100,20 @@ public class DownloadController : Controller return meta; } + private async ValueTask IsOrderPaid(string orderId) + { + if (Guid.TryParse(orderId, out var oid)) + { + var order = await _paywall.GetOrder(oid); + if (order?.Status == PaywallOrderStatus.Paid) + { + return true; + } + } + + return false; + } + private IEnumerable GetRanges(HttpRequest request, long totalSize) { foreach (var rangeHeader in request.Headers.Range) @@ -134,4 +139,4 @@ public class DownloadController : Controller } } } -} +} \ No newline at end of file diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 83494f0..bb4de6f 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -14,12 +14,15 @@ namespace VoidCat.Controllers private readonly IFileStore _storage; private readonly IFileMetadataStore _metadata; private readonly IPaywallStore _paywall; + private readonly IPaywallFactory _paywallFactory; - public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall) + public UploadController(IFileStore storage, IFileMetadataStore metadata, IPaywallStore paywall, + IPaywallFactory paywallFactory) { _storage = storage; _metadata = metadata; _paywall = paywall; + _paywallFactory = paywallFactory; } [HttpPost] @@ -85,11 +88,27 @@ namespace VoidCat.Controllers [HttpGet] [Route("{id}/paywall")] - public ValueTask CreateOrder([FromRoute] string id) + public async ValueTask CreateOrder([FromRoute] string id) { - throw new NotImplementedException(); + var gid = id.FromBase58Guid(); + var file = await _storage.Get(gid); + var config = await _paywall.GetConfig(gid); + + var provider = await _paywallFactory.CreateProvider(config!.Service); + return await provider.CreateOrder(file!); } + [HttpGet] + [Route("{id}/paywall/{order:guid}")] + public async ValueTask GetOrderStatus([FromRoute] string id, [FromRoute]Guid order) + { + var gid = id.FromBase58Guid(); + var config = await _paywall.GetConfig(gid); + + var provider = await _paywallFactory.CreateProvider(config!.Service); + return await provider.GetOrderStatus(order); + } + [HttpPost] [Route("{id}/paywall")] public async Task SetPaywallConfig([FromRoute] string id, [FromBody] SetPaywallConfigRequest req) @@ -99,7 +118,7 @@ namespace VoidCat.Controllers if (meta == default) return NotFound(); if (req.EditSecret != meta.EditSecret) return Unauthorized(); - + if (req.Strike != default) { await _paywall.SetConfig(gid, req.Strike!); @@ -139,7 +158,7 @@ namespace VoidCat.Controllers { [JsonConverter(typeof(Base58GuidConverter))] public Guid EditSecret { get; init; } - + public StrikePaywallConfig? Strike { get; init; } } } \ No newline at end of file diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index 341a2d8..fd1c5d4 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -16,7 +16,8 @@ public static class Extensions public static string? GetHeader(this IHeaderDictionary headers, string key) { - return headers - .FirstOrDefault(a => a.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)).Value.ToString(); + var h = headers + .FirstOrDefault(a => a.Key.Equals(key, StringComparison.InvariantCultureIgnoreCase)); + return !string.IsNullOrEmpty(h.Value.ToString()) ? h.Value.ToString() : default; } } \ No newline at end of file diff --git a/VoidCat/Model/Paywall/Paywall.cs b/VoidCat/Model/Paywall/Paywall.cs index f8ec879..8925b27 100644 --- a/VoidCat/Model/Paywall/Paywall.cs +++ b/VoidCat/Model/Paywall/Paywall.cs @@ -8,5 +8,5 @@ public enum PaywallServices Strike } -public abstract record PaywallConfig(PaywallServices Service, [property: JsonConverter(typeof(JsonStringEnumConverter))]PaywallMoney Cost); +public abstract record PaywallConfig(PaywallServices Service, PaywallMoney Cost); public record StrikePaywallConfig(string Handle, PaywallMoney Cost) : PaywallConfig(PaywallServices.Strike, Cost); diff --git a/VoidCat/Model/Paywall/PaywallOrder.cs b/VoidCat/Model/Paywall/PaywallOrder.cs index 2eb10df..33a4627 100644 --- a/VoidCat/Model/Paywall/PaywallOrder.cs +++ b/VoidCat/Model/Paywall/PaywallOrder.cs @@ -1,11 +1,13 @@ namespace VoidCat.Model.Paywall; -public enum PaywallStatus : byte +public enum PaywallOrderStatus : byte { Unpaid, Paid, Expired } -public abstract record PaywallOrder(Guid Id, PaywallMoney Price, PaywallStatus Status); -public record LightningPaywallOrder(Guid Id, PaywallMoney Price, PaywallStatus Status, string LnInvoice) : PaywallOrder(Id, Price, Status); +public record PaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status); + +public record LightningPaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status, string LnInvoice, + DateTimeOffset Expire) : PaywallOrder(Id, Price, Status); \ No newline at end of file diff --git a/VoidCat/Model/VoidSettings.cs b/VoidCat/Model/VoidSettings.cs index cfad8b9..fa2c0ed 100644 --- a/VoidCat/Model/VoidSettings.cs +++ b/VoidCat/Model/VoidSettings.cs @@ -1,4 +1,5 @@ -using VoidCat.Services.Abstractions; +using VoidCat.Services; +using VoidCat.Services.Abstractions; namespace VoidCat.Model { diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index 43ad655..64976f9 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -9,6 +9,7 @@ using VoidCat.Services; using VoidCat.Services.Abstractions; using VoidCat.Services.InMemory; using VoidCat.Services.Migrations; +using VoidCat.Services.Paywall; using VoidCat.Services.Redis; var builder = WebApplication.CreateBuilder(args); @@ -17,6 +18,7 @@ var services = builder.Services; var configuration = builder.Configuration; var voidSettings = configuration.GetSection("Settings").Get(); services.AddSingleton(voidSettings); +services.AddSingleton(voidSettings.Strike ?? new()); var seqSettings = configuration.GetSection("Seq"); builder.Logging.AddSeq(seqSettings); @@ -49,25 +51,34 @@ services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) }); // void.cat services +// services.AddVoidMigrations(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); + +// file storage +services.AddTransient(); +services.AddTransient(); + +// stats +services.AddTransient(); +services.AddTransient(); + +// paywall +services.AddVoidPaywall(); + if (useRedis) { - services.AddScoped(); - services.AddScoped(svc => svc.GetRequiredService()); - services.AddScoped(svc => svc.GetRequiredService()); - services.AddScoped(); + services.AddTransient(); + services.AddTransient(svc => svc.GetRequiredService()); + services.AddTransient(svc => svc.GetRequiredService()); + services.AddTransient(); } else { services.AddMemoryCache(); - services.AddScoped(); - services.AddScoped(svc => svc.GetRequiredService()); - services.AddScoped(svc => svc.GetRequiredService()); - services.AddScoped(); + services.AddTransient(); + services.AddTransient(svc => svc.GetRequiredService()); + services.AddTransient(svc => svc.GetRequiredService()); + services.AddTransient(); } var app = builder.Build(); @@ -90,4 +101,4 @@ app.UseEndpoints(ep => ep.MapFallbackToFile("index.html"); }); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallFactory.cs b/VoidCat/Services/Abstractions/IPaywallFactory.cs index c5ebf07..fa34c0f 100644 --- a/VoidCat/Services/Abstractions/IPaywallFactory.cs +++ b/VoidCat/Services/Abstractions/IPaywallFactory.cs @@ -1,15 +1,16 @@ +using VoidCat.Model; using VoidCat.Model.Paywall; namespace VoidCat.Services.Abstractions; public interface IPaywallFactory { - ValueTask CreateStrikeProvider(); + ValueTask CreateProvider(PaywallServices svc); } public interface IPaywallProvider { - ValueTask CreateOrder(PaywallConfig config); + ValueTask CreateOrder(PublicVoidFile file); ValueTask GetOrderStatus(Guid id); } diff --git a/VoidCat/Services/InMemory/InMemoryPaywallStore.cs b/VoidCat/Services/InMemory/InMemoryPaywallStore.cs index 7b9e039..bb21e30 100644 --- a/VoidCat/Services/InMemory/InMemoryPaywallStore.cs +++ b/VoidCat/Services/InMemory/InMemoryPaywallStore.cs @@ -31,7 +31,8 @@ public class InMemoryPaywallStore : IPaywallStore public ValueTask SaveOrder(PaywallOrder order) { - _cache.Set(order.Id, order); + _cache.Set(order.Id, order, + order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5)); return ValueTask.CompletedTask; } -} +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PaywallFactory.cs b/VoidCat/Services/Paywall/PaywallFactory.cs new file mode 100644 index 0000000..7995e9a --- /dev/null +++ b/VoidCat/Services/Paywall/PaywallFactory.cs @@ -0,0 +1,38 @@ +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +public class PaywallFactory : IPaywallFactory +{ + private readonly IServiceProvider _services; + + public PaywallFactory(IServiceProvider services) + { + _services = services; + } + + public ValueTask CreateProvider(PaywallServices svc) + { + return ValueTask.FromResult(svc switch + { + PaywallServices.Strike => _services.GetRequiredService(), + _ => throw new ArgumentException("Must have a paywall config", nameof(svc)) + }); + } +} + +public static class Paywall +{ + public static IServiceCollection AddVoidPaywall(this IServiceCollection services) + { + services.AddTransient(); + + // strike + services.AddTransient(); + services.AddTransient(); + services.AddTransient((svc) => svc.GetRequiredService()); + + return services; + } +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/StrikePaywallProvider.cs b/VoidCat/Services/Paywall/StrikePaywallProvider.cs new file mode 100644 index 0000000..cce1813 --- /dev/null +++ b/VoidCat/Services/Paywall/StrikePaywallProvider.cs @@ -0,0 +1,130 @@ +using System.Globalization; +using VoidCat.Model; +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +public class StrikePaywallProvider : IPaywallProvider +{ + private readonly ILogger _logger; + private readonly StrikeApi _strike; + private readonly IPaywallStore _store; + + public StrikePaywallProvider(ILogger logger, StrikeApi strike, IPaywallStore store) + { + _logger = logger; + _strike = strike; + _store = store; + } + + public async ValueTask CreateOrder(PublicVoidFile file) + { + IsStrikePaywall(file.Paywall, out var strikeConfig); + var config = file.Paywall!; + + _logger.LogInformation("Generating invoice for {Currency} {Amount}", config.Cost.Currency, config.Cost.Amount); + + var currency = MapCurrency(strikeConfig!.Cost.Currency); + if (currency == Currencies.USD) + { + // map USD to USDT if USD is not available and USDT is + var profile = await _strike.GetProfile(strikeConfig!.Handle); + if (profile != default) + { + var usd = profile.Currencies.FirstOrDefault(a => a.Currency == Currencies.USD); + var usdt = profile.Currencies.FirstOrDefault(a => a.Currency == Currencies.USDT); + if (!(usd?.IsAvailable ?? false) && (usdt?.IsAvailable ?? false)) + { + currency = Currencies.USDT; + } + } + } + + var invoice = await _strike.GenerateInvoice(new() + { + Handle = strikeConfig.Handle, + Amount = new() + { + Amount = strikeConfig.Cost.Amount.ToString(CultureInfo.InvariantCulture), + Currency = currency + }, + Description = file.Metadata?.Name + }); + if (invoice != default) + { + var quote = await _strike.GetInvoiceQuote(invoice.InvoiceId); + if (quote != default) + { + var order = new LightningPaywallOrder(invoice.InvoiceId, config.Cost, PaywallOrderStatus.Unpaid, + quote.LnInvoice!, + quote.Expiration); + await _store.SaveOrder(order); + return order; + } + + _logger.LogWarning("Failed to get quote for invoice: {Id}", invoice.InvoiceId); + } + + _logger.LogWarning("Failed to get invoice for config: {Config}", config); + return default; + } + + public async ValueTask GetOrderStatus(Guid id) + { + var order = await _store.GetOrder(id); + if (order == default) + { + var invoice = await _strike.GetInvoice(id); + if (invoice != default) + { + order = new(id, new(decimal.Parse(invoice.Amount!.Amount!), MapCurrency(invoice.Amount.Currency)), + MapStatus(invoice.State)); + await _store.SaveOrder(order); + } + } + + return order; + } + + private static Currencies MapCurrency(PaywallCurrencies c) + => c switch + { + PaywallCurrencies.BTC => Currencies.BTC, + PaywallCurrencies.USD => Currencies.USD, + PaywallCurrencies.EUR => Currencies.EUR, + PaywallCurrencies.GBP => Currencies.GBP, + _ => throw new ArgumentOutOfRangeException(nameof(c), c, null) + }; + + private static PaywallCurrencies MapCurrency(Currencies? c) + => c switch + { + Currencies.BTC => PaywallCurrencies.BTC, + Currencies.USD => PaywallCurrencies.USD, + Currencies.EUR => PaywallCurrencies.EUR, + Currencies.GBP => PaywallCurrencies.GBP, + Currencies.USDT => PaywallCurrencies.USD, + _ => throw new ArgumentOutOfRangeException(nameof(c), c, null) + }; + + private static PaywallOrderStatus MapStatus(InvoiceState s) + => s switch + { + InvoiceState.UNPAID => PaywallOrderStatus.Unpaid, + InvoiceState.PENDING => PaywallOrderStatus.Unpaid, + InvoiceState.PAID => PaywallOrderStatus.Paid, + InvoiceState.CANCELLED => PaywallOrderStatus.Expired, + _ => throw new ArgumentOutOfRangeException(nameof(s), s, null) + }; + + private static void IsStrikePaywall(PaywallConfig? cfg, out StrikePaywallConfig? strikeConfig) + { + if (cfg?.Service != PaywallServices.Strike) + { + throw new ArgumentException("Must be strike paywall"); + } + + strikeConfig = cfg as StrikePaywallConfig; + } +} \ No newline at end of file diff --git a/VoidCat/Services/Redis/RedisPaywallStore.cs b/VoidCat/Services/Redis/RedisPaywallStore.cs index a949d5e..43b8196 100644 --- a/VoidCat/Services/Redis/RedisPaywallStore.cs +++ b/VoidCat/Services/Redis/RedisPaywallStore.cs @@ -17,7 +17,7 @@ public class RedisPaywallStore : IPaywallStore public async ValueTask GetConfig(Guid id) { var json = await _database.StringGetAsync(ConfigKey(id)); - var cfg = JsonConvert.DeserializeObject(json); + var cfg = json.HasValue ? JsonConvert.DeserializeObject(json) : default; return cfg?.Service switch { PaywallServices.Strike => JsonConvert.DeserializeObject(json), @@ -33,14 +33,15 @@ public class RedisPaywallStore : IPaywallStore public async ValueTask GetOrder(Guid id) { var json = await _database.StringGetAsync(OrderKey(id)); - return JsonConvert.DeserializeObject(json); + return json.HasValue ? JsonConvert.DeserializeObject(json) : default; } public async ValueTask SaveOrder(PaywallOrder order) { - await _database.StringSetAsync(OrderKey(order.Id), JsonConvert.SerializeObject(order)); + await _database.StringSetAsync(OrderKey(order.Id), JsonConvert.SerializeObject(order), + order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5)); } private RedisKey ConfigKey(Guid id) => $"paywall:config:{id}"; private RedisKey OrderKey(Guid id) => $"paywall:order:{id}"; -} +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/StrikeApi.cs b/VoidCat/Services/StrikeApi.cs similarity index 54% rename from VoidCat/Services/Abstractions/StrikeApi.cs rename to VoidCat/Services/StrikeApi.cs index 8c3507a..db7a7e0 100644 --- a/VoidCat/Services/Abstractions/StrikeApi.cs +++ b/VoidCat/Services/StrikeApi.cs @@ -1,10 +1,10 @@ -namespace VoidCat.Services.Abstractions; - using System.Net; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Converters; +namespace VoidCat.Services; + public class StrikeApi { private readonly ILogger _logger; @@ -93,56 +93,41 @@ public class StrikeApi public class Profile { - [JsonProperty("handle")] - public string Handle { get; init; } = null; + [JsonProperty("handle")] public string Handle { get; init; } = null; - [JsonProperty("avatarUrl")] - public string? AvatarUrl { get; init; } + [JsonProperty("avatarUrl")] public string? AvatarUrl { get; init; } - [JsonProperty("description")] - public string? Description { get; init; } + [JsonProperty("description")] public string? Description { get; init; } - [JsonProperty("canReceive")] - public bool CanReceive { get; init; } + [JsonProperty("canReceive")] public bool CanReceive { get; init; } - [JsonProperty("currencies")] - public List Currencies { get; init; } = new(); + [JsonProperty("currencies")] public List Currencies { get; init; } = new(); } public class InvoiceQuote { - [JsonProperty("quoteId")] - public Guid QuoteId { get; init; } + [JsonProperty("quoteId")] public Guid QuoteId { get; init; } - [JsonProperty("description")] - public string? Description { get; init; } + [JsonProperty("description")] public string? Description { get; init; } - [JsonProperty("lnInvoice")] - public string? LnInvoice { get; init; } + [JsonProperty("lnInvoice")] public string? LnInvoice { get; init; } - [JsonProperty("onchainAddress")] - public string? OnChainAddress { get; init; } + [JsonProperty("onchainAddress")] public string? OnChainAddress { get; init; } - [JsonProperty("expiration")] - public DateTimeOffset Expiration { get; init; } + [JsonProperty("expiration")] public DateTimeOffset Expiration { get; init; } - [JsonProperty("expirationInSec")] - public ulong ExpirationSec { get; init; } + [JsonProperty("expirationInSec")] public ulong ExpirationSec { get; init; } - [JsonProperty("targetAmount")] - public CurrencyAmount? TargetAmount { get; init; } + [JsonProperty("targetAmount")] public CurrencyAmount? TargetAmount { get; init; } - [JsonProperty("sourceAmount")] - public CurrencyAmount? SourceAmount { get; init; } + [JsonProperty("sourceAmount")] public CurrencyAmount? SourceAmount { get; init; } - [JsonProperty("conversionRate")] - public ConversionRate? ConversionRate { get; init; } + [JsonProperty("conversionRate")] public ConversionRate? ConversionRate { get; init; } } public class ConversionRate { - [JsonProperty("amount")] - public string? Amount { get; init; } + [JsonProperty("amount")] public string? Amount { get; init; } [JsonProperty("sourceCurrency")] [JsonConverter(typeof(StringEnumConverter))] @@ -162,23 +147,18 @@ public class ErrorResponse : Exception public class CreateInvoiceRequest { - [JsonProperty("correlationId")] - public string? CorrelationId { get; init; } + [JsonProperty("correlationId")] public string? CorrelationId { get; init; } - [JsonProperty("description")] - public string? Description { get; init; } + [JsonProperty("description")] public string? Description { get; init; } - [JsonProperty("amount")] - public CurrencyAmount? Amount { get; init; } + [JsonProperty("amount")] public CurrencyAmount? Amount { get; init; } - [JsonProperty("handle")] - public string? Handle { get; init; } + [JsonProperty("handle")] public string? Handle { get; init; } } public class CurrencyAmount { - [JsonProperty("amount")] - public string? Amount { get; init; } + [JsonProperty("amount")] public string? Amount { get; init; } [JsonProperty("currency")] [JsonConverter(typeof(StringEnumConverter))] @@ -187,14 +167,11 @@ public class CurrencyAmount public class AvailableCurrency { - [JsonProperty("currency")] - public Currencies Currency { get; init; } + [JsonProperty("currency")] public Currencies Currency { get; init; } - [JsonProperty("isDefaultCurrency")] - public bool IsDefault { get; init; } + [JsonProperty("isDefaultCurrency")] public bool IsDefault { get; init; } - [JsonProperty("isAvailable")] - public bool IsAvailable { get; init; } + [JsonProperty("isAvailable")] public bool IsAvailable { get; init; } } public enum Currencies @@ -208,93 +185,70 @@ public enum Currencies public class Invoice { - [JsonProperty("invoiceId")] - public Guid InvoiceId { get; init; } + [JsonProperty("invoiceId")] public Guid InvoiceId { get; init; } - [JsonProperty("amount")] - public CurrencyAmount? Amount { get; init; } + [JsonProperty("amount")] public CurrencyAmount? Amount { get; init; } [JsonProperty("state")] [JsonConverter(typeof(StringEnumConverter))] public InvoiceState State { get; set; } - [JsonProperty("created")] - public DateTimeOffset? Created { get; init; } + [JsonProperty("created")] public DateTimeOffset? Created { get; init; } - [JsonProperty("correlationId")] - public string? CorrelationId { get; init; } + [JsonProperty("correlationId")] public string? CorrelationId { get; init; } - [JsonProperty("description")] - public string? Description { get; init; } + [JsonProperty("description")] public string? Description { get; init; } - [JsonProperty("issuerId")] - public Guid? IssuerId { get; init; } + [JsonProperty("issuerId")] public Guid? IssuerId { get; init; } - [JsonProperty("receiverId")] - public Guid? ReceiverId { get; init; } + [JsonProperty("receiverId")] public Guid? ReceiverId { get; init; } - [JsonProperty("payerId")] - public Guid? PayerId { get; init; } + [JsonProperty("payerId")] public Guid? PayerId { get; init; } } public abstract class WebhookBase { - [JsonProperty("webhookUrl")] - public Uri? Uri { get; init; } + [JsonProperty("webhookUrl")] public Uri? Uri { get; init; } - [JsonProperty("webhookVersion")] - public string? Version { get; init; } + [JsonProperty("webhookVersion")] public string? Version { get; init; } - [JsonProperty("enabled")] - public bool? Enabled { get; init; } + [JsonProperty("enabled")] public bool? Enabled { get; init; } - [JsonProperty("eventTypes")] - public HashSet? EventTypes { get; init; } + [JsonProperty("eventTypes")] public HashSet? EventTypes { get; init; } } public sealed class NewWebhook : WebhookBase { - [JsonProperty("secret")] - public string? Secret { get; init; } + [JsonProperty("secret")] public string? Secret { get; init; } } public sealed class WebhookSubscription : WebhookBase { - [JsonProperty("id")] - public Guid? Id { get; init; } + [JsonProperty("id")] public Guid? Id { get; init; } - [JsonProperty("created")] - public DateTimeOffset? Created { get; init; } + [JsonProperty("created")] public DateTimeOffset? Created { get; init; } } public class WebhookData { - [JsonProperty("entityId")] - public Guid? EntityId { get; set; } + [JsonProperty("entityId")] public Guid? EntityId { get; set; } - [JsonProperty("changes")] - public List? Changes { get; set; } + [JsonProperty("changes")] public List? Changes { get; set; } } public class WebhookEvent { - [JsonProperty("id")] - public Guid? Id { get; set; } + [JsonProperty("id")] public Guid? Id { get; set; } - [JsonProperty("eventType")] - public string? EventType { get; set; } + [JsonProperty("eventType")] public string? EventType { get; set; } - [JsonProperty("webhookVersion")] - public string? WebhookVersion { get; set; } + [JsonProperty("webhookVersion")] public string? WebhookVersion { get; set; } - [JsonProperty("data")] - public WebhookData? Data { get; set; } + [JsonProperty("data")] public WebhookData? Data { get; set; } - [JsonProperty("created")] - public DateTimeOffset? Created { get; set; } + [JsonProperty("created")] public DateTimeOffset? Created { get; set; } - [JsonProperty("deliverySuccess")] - public bool? DeliverySuccess { get; set; } + [JsonProperty("deliverySuccess")] public bool? DeliverySuccess { get; set; } public override string ToString() { @@ -314,4 +268,4 @@ public class StrikeApiSettings { public Uri? Uri { get; init; } public string? ApiKey { get; init; } -} +} \ No newline at end of file diff --git a/VoidCat/spa/package.json b/VoidCat/spa/package.json index fa8f38e..7b590bd 100644 --- a/VoidCat/spa/package.json +++ b/VoidCat/spa/package.json @@ -5,6 +5,7 @@ "proxy": "https://localhost:7195", "dependencies": { "feather-icons-react": "^0.5.0", + "qrcode.react": "^1.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-router-dom": "^6.2.1", diff --git a/VoidCat/spa/src/Const.js b/VoidCat/spa/src/Const.js index daef31d..8d9b7ac 100644 --- a/VoidCat/spa/src/Const.js +++ b/VoidCat/spa/src/Const.js @@ -36,4 +36,15 @@ export const PaywallCurrencies = { USD: 1, EUR: 2, GBP: 3 +} + +export const PaywallServices = { + None: 0, + Strike: 1 +} + +export const PaywallOrderState = { + Unpaid: 0, + Paid: 1, + Expired: 2 } \ No newline at end of file diff --git a/VoidCat/spa/src/Countdown.js b/VoidCat/spa/src/Countdown.js new file mode 100644 index 0000000..2b57ca2 --- /dev/null +++ b/VoidCat/spa/src/Countdown.js @@ -0,0 +1,21 @@ +import {useEffect, useState} from "react"; + +export function Countdown(props) { + const [time, setTime] = useState(0); + const onEnded = props.onEnded; + + useEffect(() => { + let t = setInterval(() => { + let to = new Date(props.to).getTime(); + let now = new Date().getTime(); + let seconds = (to - now) / 1000.0; + setTime(Math.max(0, seconds)); + if(seconds <= 0 && typeof onEnded === "function") { + onEnded(); + } + }, 100); + return () => clearInterval(t); + }, []) + + return
{time.toFixed(1)}s
+} \ No newline at end of file diff --git a/VoidCat/spa/src/FileEdit.css b/VoidCat/spa/src/FileEdit.css index 18d0ceb..0f0c258 100644 --- a/VoidCat/spa/src/FileEdit.css +++ b/VoidCat/spa/src/FileEdit.css @@ -6,4 +6,9 @@ .file-edit > div { flex: 1; +} + +.file-edit svg { + vertical-align: middle; + margin-left: 10px; } \ No newline at end of file diff --git a/VoidCat/spa/src/FileEdit.js b/VoidCat/spa/src/FileEdit.js index 094336b..ebe6813 100644 --- a/VoidCat/spa/src/FileEdit.js +++ b/VoidCat/spa/src/FileEdit.js @@ -4,9 +4,10 @@ import "./FileEdit.css"; import {StrikePaywallConfig} from "./StrikePaywallConfig"; export function FileEdit(props) { - const [paywall, setPaywall] = useState(); + const file = props.file; + const [paywall, setPaywall] = useState(file.paywall?.service); - const privateFile = JSON.parse(window.localStorage.getItem(props.file.id)); + const privateFile = JSON.parse(window.localStorage.getItem(file.id)); if (!privateFile) { return null; } @@ -14,13 +15,13 @@ export function FileEdit(props) { function renderPaywallConfig() { switch (paywall) { case 1: { - return + return } } return null; } - const meta = props.file.metadata; + const meta = file.metadata; return (
@@ -36,7 +37,7 @@ export function FileEdit(props) {

Paywall Config

Type: - setPaywall(parseInt(e.target.value))} value={paywall}> diff --git a/VoidCat/spa/src/FilePaywall.js b/VoidCat/spa/src/FilePaywall.js index 591d090..a989eb0 100644 --- a/VoidCat/spa/src/FilePaywall.js +++ b/VoidCat/spa/src/FilePaywall.js @@ -1,21 +1,49 @@ -import {ConstName} from "./Util"; -import {PaywallCurrencies} from "./Const"; +import {ConstName, FormatCurrency} from "./Util"; +import {PaywallCurrencies, PaywallServices} from "./Const"; import {useState} from "react"; +import {LightningPaywall} from "./LightningPaywall"; export function FilePaywall(props) { const file = props.file; const pw = file.paywall; - + const paywallKey = `paywall-${file.id}`; + const onPaid = props.onPaid; + const [order, setOrder] = useState(); - - async function fetchOrder() { - let req = await fetch("") + + async function fetchOrder(e) { + e.target.disabled = true; + let req = await fetch(`/upload/${file.id}/paywall`); + if (req.ok) { + setOrder(await req.json()); + } + } + + function reset() { + setOrder(undefined); + } + + function handlePaid(order) { + window.localStorage.setItem(paywallKey, JSON.stringify(order)); + if (typeof onPaid === "function") { + onPaid(); + } + } + + if (!order) { + return ( +
+

You must pay {FormatCurrency(pw.cost.amount, pw.cost.currency)} to view this + file.

+ +
+ ); + } else { + switch (pw.service) { + case PaywallServices.Strike: { + return ; + } + } + return null; } - - return ( -
-

You must pay {ConstName(PaywallCurrencies, pw.cost.currency)} {pw.cost.amount} to view this file.

- -
- ); } \ No newline at end of file diff --git a/VoidCat/spa/src/FilePreview.js b/VoidCat/spa/src/FilePreview.js index 33d807b..ece01a5 100644 --- a/VoidCat/spa/src/FilePreview.js +++ b/VoidCat/spa/src/FilePreview.js @@ -9,6 +9,8 @@ import {FilePaywall} from "./FilePaywall"; export function FilePreview() { const params = useParams(); const [info, setInfo] = useState(); + const [order, setOrder] = useState(); + const [link, setLink] = useState("#"); async function loadInfo() { let req = await fetch(`/upload/${params.id}`); @@ -19,11 +21,12 @@ export function FilePreview() { } function renderTypes() { - if(info.paywall) { - return ; + if (info.paywall) { + if (!order) { + return ; + } } - - let link = `/d/${info.id}`; + if (info.metadata) { switch (info.metadata.mimeType) { case "image/jpg": @@ -53,11 +56,24 @@ export function FilePreview() { loadInfo(); }, []); + useEffect(() => { + if (info) { + let order = window.localStorage.getItem(`paywall-${info.id}`); + if (order) { + let orderObj = JSON.parse(order); + setOrder(orderObj); + setLink(`/d/${info.id}?orderId=${orderObj.id}`); + } else { + setLink(`/d/${info.id}`); + } + } + }, [info]); + return (
{info ? ( - this.Download({info.metadata?.name ?? info.id}) + this.Download({info.metadata?.name ?? info.id}) {renderTypes()} diff --git a/VoidCat/spa/src/LightningPaywall.js b/VoidCat/spa/src/LightningPaywall.js new file mode 100644 index 0000000..173ec09 --- /dev/null +++ b/VoidCat/spa/src/LightningPaywall.js @@ -0,0 +1,46 @@ +import QRCode from "qrcode.react"; +import {Countdown} from "./Countdown"; +import {useEffect} from "react"; +import {PaywallOrderState} from "./Const"; + +export function LightningPaywall(props) { + const file = props.file; + const order = props.order; + const onPaid = props.onPaid; + const link = `lightning:${order.lnInvoice}`; + + function openInvoice() { + let a = document.createElement("a"); + a.href = link; + a.click(); + } + + async function checkStatus() { + let req = await fetch(`/upload/${file.id}/paywall/${order.id}`); + if (req.ok) { + let order = await req.json(); + + if (order.status === PaywallOrderState.Paid && typeof onPaid === "function") { + onPaid(order); + } + } + } + + useEffect(() => { + let t = setInterval(checkStatus, 2500); + return () => clearInterval(t); + }, []); + + return ( +
+ +
+
Expires:
+
+
+
+ ); +} \ No newline at end of file diff --git a/VoidCat/spa/src/StrikePaywallConfig.js b/VoidCat/spa/src/StrikePaywallConfig.js index 51483f5..f3b7a73 100644 --- a/VoidCat/spa/src/StrikePaywallConfig.js +++ b/VoidCat/spa/src/StrikePaywallConfig.js @@ -1,15 +1,21 @@ import {useState} from "react"; +import FeatherIcon from "feather-icons-react"; import {PaywallCurrencies} from "./Const"; export function StrikePaywallConfig(props) { - const editSecret = props.file.metadata.editSecret; - const id = props.file.id; + const file = props.file; + const privateFile = props.privateFile; + const paywall = file.paywall; + const editSecret = privateFile.metadata.editSecret; + const id = file.id; - const [username, setUsername] = useState("hrf"); - const [currency, setCurrency] = useState(PaywallCurrencies.USD); - const [price, setPrice] = useState(1); - - async function saveStrikeConfig() { + const [username, setUsername] = useState(paywall?.handle ?? "hrf"); + const [currency, setCurrency] = useState(paywall?.cost.currency ?? PaywallCurrencies.USD); + const [price, setPrice] = useState(paywall?.cost.amount ?? 1); + const [saveStatus, setSaveStatus] = useState(); + + async function saveStrikeConfig(e) { + e.target.disabled = true; let cfg = { editSecret, strike: { @@ -31,6 +37,8 @@ export function StrikePaywallConfig(props) { if (!req.ok) { alert("Error settings paywall config!"); } + setSaveStatus(req.ok); + e.target.disabled = false; } return ( @@ -40,17 +48,18 @@ export function StrikePaywallConfig(props) {
setUsername(e.target.value)}/>
Currency:
- setCurrency(parseInt(e.target.value))} value={currency}> + + + +
Price:
setPrice(parseFloat(e.target.value))}/>
+ {saveStatus ? : null}
); } \ No newline at end of file diff --git a/VoidCat/spa/src/Util.js b/VoidCat/spa/src/Util.js index ac2a1e7..e9c662f 100644 --- a/VoidCat/spa/src/Util.js +++ b/VoidCat/spa/src/Util.js @@ -36,4 +36,42 @@ export function ConstName(type, val) { return k; } } +} + +export function FormatCurrency(value, currency) { + if (typeof value !== "number") { + value = parseFloat(value); + } + switch (currency) { + case 0: + case "BTC": { + let hasDecimals = (value % 1) > 0; + return `₿${value.toLocaleString(undefined, { + minimumFractionDigits: hasDecimals ? 8 : 0, // Sats + maximumFractionDigits: 11 // MSats + })}`; + } + case 1: + case "USD":{ + return value.toLocaleString(undefined, { + style: "currency", + currency: "USD" + }); + } + case 2: + case "EUR": { + return value.toLocaleString(undefined, { + style: "currency", + currency: "EUR" + }); + } + case 3: + case "GBP": { + return value.toLocaleString(undefined, { + style: "currency", + currency: "GBP" + }); + } + } + return value.toString(); } \ No newline at end of file diff --git a/VoidCat/spa/yarn.lock b/VoidCat/spa/yarn.lock index 4b27b0a..8b75b71 100644 --- a/VoidCat/spa/yarn.lock +++ b/VoidCat/spa/yarn.lock @@ -6655,7 +6655,7 @@ prompts@^2.0.1, prompts@^2.4.2: kleur "^3.0.3" sisteransi "^1.0.5" -prop-types@^15.7.2: +prop-types@^15.6.0, prop-types@^15.7.2: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -6687,6 +6687,20 @@ q@^1.1.2: resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" integrity sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc= +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8= + +qrcode.react@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-1.0.1.tgz#2834bb50e5e275ffe5af6906eff15391fe9e38a5" + integrity sha512-8d3Tackk8IRLXTo67Y+c1rpaiXjoz/Dd2HpcMdW//62/x8J1Nbho14Kh8x974t9prsLHN6XqVgcnRiBGFptQmg== + dependencies: + loose-envify "^1.4.0" + prop-types "^15.6.0" + qr.js "0.0.0" + qs@6.9.6: version "6.9.6" resolved "https://registry.yarnpkg.com/qs/-/qs-6.9.6.tgz#26ed3c8243a431b2924aca84cc90471f35d5a0ee"