Finish stike paywall

This commit is contained in:
Kieran 2022-02-21 12:54:57 +00:00
parent 0eda25ba00
commit edf26c2137
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
24 changed files with 536 additions and 182 deletions

View File

@ -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<bool> 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<RangeRequest> GetRanges(HttpRequest request, long totalSize)
{
foreach (var rangeHeader in request.Headers.Range)
@ -134,4 +139,4 @@ public class DownloadController : Controller
}
}
}
}
}

View File

@ -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<PaywallOrder?> CreateOrder([FromRoute] string id)
public async ValueTask<PaywallOrder?> 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<PaywallOrder?> 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<IActionResult> 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; }
}
}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
using VoidCat.Services.Abstractions;
using VoidCat.Services;
using VoidCat.Services.Abstractions;
namespace VoidCat.Model
{

View File

@ -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<VoidSettings>();
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<IFileMetadataStore, LocalDiskFileMetadataStore>();
services.AddScoped<IFileStore, LocalDiskFileStore>();
services.AddScoped<IAggregateStatsCollector, AggregateStatsCollector>();
services.AddScoped<IStatsCollector, PrometheusStatsCollector>();
// file storage
services.AddTransient<IFileMetadataStore, LocalDiskFileMetadataStore>();
services.AddTransient<IFileStore, LocalDiskFileStore>();
// stats
services.AddTransient<IAggregateStatsCollector, AggregateStatsCollector>();
services.AddTransient<IStatsCollector, PrometheusStatsCollector>();
// paywall
services.AddVoidPaywall();
if (useRedis)
{
services.AddScoped<RedisStatsController>();
services.AddScoped<IStatsCollector>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddScoped<IStatsReporter>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddScoped<IPaywallStore, RedisPaywallStore>();
services.AddTransient<RedisStatsController>();
services.AddTransient<IStatsCollector>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddTransient<IStatsReporter>(svc => svc.GetRequiredService<RedisStatsController>());
services.AddTransient<IPaywallStore, RedisPaywallStore>();
}
else
{
services.AddMemoryCache();
services.AddScoped<InMemoryStatsController>();
services.AddScoped<IStatsReporter>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddScoped<IStatsCollector>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddScoped<IPaywallStore, InMemoryPaywallStore>();
services.AddTransient<InMemoryStatsController>();
services.AddTransient<IStatsReporter>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddTransient<IStatsCollector>(svc => svc.GetRequiredService<InMemoryStatsController>());
services.AddTransient<IPaywallStore, InMemoryPaywallStore>();
}
var app = builder.Build();
@ -90,4 +101,4 @@ app.UseEndpoints(ep =>
ep.MapFallbackToFile("index.html");
});
app.Run();
app.Run();

View File

@ -1,15 +1,16 @@
using VoidCat.Model;
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
public interface IPaywallFactory
{
ValueTask<IPaywallProvider> CreateStrikeProvider();
ValueTask<IPaywallProvider> CreateProvider(PaywallServices svc);
}
public interface IPaywallProvider
{
ValueTask<PaywallOrder?> CreateOrder(PaywallConfig config);
ValueTask<PaywallOrder?> CreateOrder(PublicVoidFile file);
ValueTask<PaywallOrder?> GetOrderStatus(Guid id);
}

View File

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

View File

@ -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<IPaywallProvider> CreateProvider(PaywallServices svc)
{
return ValueTask.FromResult<IPaywallProvider>(svc switch
{
PaywallServices.Strike => _services.GetRequiredService<StrikePaywallProvider>(),
_ => throw new ArgumentException("Must have a paywall config", nameof(svc))
});
}
}
public static class Paywall
{
public static IServiceCollection AddVoidPaywall(this IServiceCollection services)
{
services.AddTransient<IPaywallFactory, PaywallFactory>();
// strike
services.AddTransient<StrikeApi>();
services.AddTransient<StrikePaywallProvider>();
services.AddTransient<IPaywallProvider>((svc) => svc.GetRequiredService<StrikePaywallProvider>());
return services;
}
}

View File

@ -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<StrikePaywallProvider> _logger;
private readonly StrikeApi _strike;
private readonly IPaywallStore _store;
public StrikePaywallProvider(ILogger<StrikePaywallProvider> logger, StrikeApi strike, IPaywallStore store)
{
_logger = logger;
_strike = strike;
_store = store;
}
public async ValueTask<PaywallOrder?> 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<PaywallOrder?> 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;
}
}

View File

@ -17,7 +17,7 @@ public class RedisPaywallStore : IPaywallStore
public async ValueTask<PaywallConfig?> GetConfig(Guid id)
{
var json = await _database.StringGetAsync(ConfigKey(id));
var cfg = JsonConvert.DeserializeObject<PaywallConfig>(json);
var cfg = json.HasValue ? JsonConvert.DeserializeObject<PaywallConfig>(json) : default;
return cfg?.Service switch
{
PaywallServices.Strike => JsonConvert.DeserializeObject<StrikePaywallConfig>(json),
@ -33,14 +33,15 @@ public class RedisPaywallStore : IPaywallStore
public async ValueTask<PaywallOrder?> GetOrder(Guid id)
{
var json = await _database.StringGetAsync(OrderKey(id));
return JsonConvert.DeserializeObject<PaywallOrder>(json);
return json.HasValue ? JsonConvert.DeserializeObject<PaywallOrder>(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}";
}
}

View File

@ -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<StrikeApi> _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<AvailableCurrency> Currencies { get; init; } = new();
[JsonProperty("currencies")] public List<AvailableCurrency> 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<string>? EventTypes { get; init; }
[JsonProperty("eventTypes")] public HashSet<string>? 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<string>? Changes { get; set; }
[JsonProperty("changes")] public List<string>? 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; }
}
}

View File

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

View File

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

View File

@ -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 <div>{time.toFixed(1)}s</div>
}

View File

@ -6,4 +6,9 @@
.file-edit > div {
flex: 1;
}
.file-edit svg {
vertical-align: middle;
margin-left: 10px;
}

View File

@ -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 <StrikePaywallConfig file={privateFile}/>
return <StrikePaywallConfig file={file} privateFile={privateFile}/>
}
}
return null;
}
const meta = props.file.metadata;
const meta = file.metadata;
return (
<div className="file-edit">
<div>
@ -36,7 +37,7 @@ export function FileEdit(props) {
<div>
<h3>Paywall Config</h3>
Type:
<select onChange={(e) => setPaywall(parseInt(e.target.value))}>
<select onChange={(e) => setPaywall(parseInt(e.target.value))} value={paywall}>
<option value={0}>None</option>
<option value={1}>Strike</option>
</select>

View File

@ -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 (
<div className="paywall">
<h3>You must pay {FormatCurrency(pw.cost.amount, pw.cost.currency)} to view this
file.</h3>
<button onClick={fetchOrder}>Pay</button>
</div>
);
} else {
switch (pw.service) {
case PaywallServices.Strike: {
return <LightningPaywall file={file} order={order} onReset={reset} onPaid={handlePaid}/>;
}
}
return null;
}
return (
<div className="paywall">
<h3>You must pay {ConstName(PaywallCurrencies, pw.cost.currency)} {pw.cost.amount} to view this file.</h3>
<button onClick={fetchOrder}>Pay</button>
</div>
);
}

View File

@ -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 <FilePaywall file={info}/>;
if (info.paywall) {
if (!order) {
return <FilePaywall file={info} onPaid={loadInfo}/>;
}
}
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 (
<div className="preview">
{info ? (
<Fragment>
this.Download(<a className="btn" href={`/d/${info.id}`}>{info.metadata?.name ?? info.id}</a>)
this.Download(<a className="btn" href={link}>{info.metadata?.name ?? info.id}</a>)
{renderTypes()}
<FileEdit file={info}/>
</Fragment>

View File

@ -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 (
<div className="lightning-invoice" onClick={openInvoice}>
<QRCode
value={link}
size={512}
includeMargin={true}/>
<dl>
<dt>Expires:</dt>
<dd><Countdown to={order.expire} onEnded={props.onReset}/></dd>
</dl>
</div>
);
}

View File

@ -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) {
<dd><input type="text" value={username} onChange={(e) => setUsername(e.target.value)}/></dd>
<dt>Currency:</dt>
<dd>
<select onChange={(e) => setCurrency(parseInt(e.target.value))}>
<option selected={currency === PaywallCurrencies.BTC} value={PaywallCurrencies.BTC}>BTC</option>
<option selected={currency === PaywallCurrencies.USD} value={PaywallCurrencies.USD}>USD</option>
<option selected={currency === PaywallCurrencies.EUR} value={PaywallCurrencies.EUR}>EUR</option>
<option selected={currency === PaywallCurrencies.GBP} value={PaywallCurrencies.GBP}>GBP</option>
<select onChange={(e) => setCurrency(parseInt(e.target.value))} value={currency}>
<option value={PaywallCurrencies.BTC}>BTC</option>
<option value={PaywallCurrencies.USD}>USD</option>
<option value={PaywallCurrencies.EUR}>EUR</option>
<option value={PaywallCurrencies.GBP}>GBP</option>
</select>
</dd>
<dt>Price:</dt>
<dd><input type="number" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))}/></dd>
</dl>
<button onClick={saveStrikeConfig}>Save</button>
{saveStatus ? <FeatherIcon icon="check-circle"/> : null}
</div>
);
}

View File

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

View File

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