Postgres paywall support

Various fixes
This commit is contained in:
Kieran 2022-06-13 14:35:26 +01:00
parent 3582431640
commit e49c2fe870
Signed by: Kieran
GPG Key ID: DE71CEB3925BE941
26 changed files with 622 additions and 175 deletions

View File

@ -11,16 +11,16 @@ public class DownloadController : Controller
{
private readonly IFileStore _storage;
private readonly IFileInfoManager _fileInfo;
private readonly IPaywallStore _paywall;
private readonly IPaywallOrderStore _paywallOrders;
private readonly ILogger<DownloadController> _logger;
public DownloadController(IFileStore storage, ILogger<DownloadController> logger, IFileInfoManager fileInfo,
IPaywallStore paywall)
IPaywallOrderStore paywall)
{
_storage = storage;
_logger = logger;
_fileInfo = fileInfo;
_paywall = paywall;
_paywallOrders = paywall;
}
[HttpOptions]
@ -90,7 +90,7 @@ public class DownloadController : Controller
}
// check paywall
if (meta.Paywall != default && meta.Paywall.Service != PaywallServices.None)
if (meta.Paywall != default && meta.Paywall.Service != PaymentServices.None)
{
var orderId = Request.Headers.GetHeader("V-OrderId") ?? Request.Query["orderId"];
if (!await IsOrderPaid(orderId))
@ -111,7 +111,7 @@ public class DownloadController : Controller
{
if (Guid.TryParse(orderId, out var oid))
{
var order = await _paywall.GetOrder(oid);
var order = await _paywallOrders.Get(oid);
if (order?.Status == PaywallOrderStatus.Paid)
{
return true;

View File

@ -177,7 +177,7 @@ namespace VoidCat.Controllers
var config = await _paywall.Get(gid);
var provider = await _paywallFactory.CreateProvider(config!.Service);
return await provider.CreateOrder(file!);
return await provider.CreateOrder(file!.Paywall!);
}
/// <summary>
@ -214,12 +214,17 @@ namespace VoidCat.Controllers
if (req.Strike != default)
{
await _paywall.Add(gid, req.Strike!);
await _paywall.Add(gid, new StrikePaywallConfig()
{
Service = PaymentServices.Strike,
Handle = req.Strike.Handle,
Cost = req.Strike.Cost
});
return Ok();
}
// if none set, set NoPaywallConfig
await _paywall.Add(gid, new NoPaywallConfig());
// if none set, delete config
await _paywall.Delete(gid);
return Ok();
}

View File

@ -208,4 +208,7 @@ public static class Extensions
var hashParts = vu.Password.Split(":");
return vu.Password == password.Hash(hashParts[0], hashParts.Length == 3 ? hashParts[1] : null);
}
public static bool HasPostgres(this VoidSettings settings)
=> !string.IsNullOrEmpty(settings.Postgres);
}

View File

@ -1,16 +1,56 @@
namespace VoidCat.Model.Paywall;
public enum PaywallServices
/// <summary>
/// Payment services supported by the system
/// </summary>
public enum PaymentServices
{
/// <summary>
/// No service
/// </summary>
None,
/// <summary>
/// Strike.me payment service
/// </summary>
Strike
}
public abstract record PaywallConfig(PaywallServices Service, PaywallMoney Cost);
public record NoPaywallConfig() : PaywallConfig(PaywallServices.None, new PaywallMoney(0m, PaywallCurrencies.BTC));
public record StrikePaywallConfig(PaywallMoney Cost) : PaywallConfig(PaywallServices.Strike, Cost)
/// <summary>
/// Base paywall config
/// </summary>
public abstract class PaywallConfig
{
/// <summary>
/// File this config is for
/// </summary>
public Guid File { get; init; }
/// <summary>
/// Service used to pay the paywall
/// </summary>
public PaymentServices Service { get; init; } = PaymentServices.None;
/// <summary>
/// The cost for the paywall to pass
/// </summary>
public PaywallMoney Cost { get; init; } = new(0m, PaywallCurrencies.BTC);
}
/// <inheritdoc />
public sealed class NoPaywallConfig : PaywallConfig
{
}
/// <summary>
/// Paywall config for <see cref="PaymentServices.Strike"/> service
/// </summary>
/// <param name="Cost"></param>
public sealed class StrikePaywallConfig : PaywallConfig
{
/// <summary>
/// Strike username to pay to
/// </summary>
public string Handle { get; init; } = null!;
}

View File

@ -1,13 +1,69 @@
namespace VoidCat.Model.Paywall;
/// <summary>
/// Status of paywall order
/// </summary>
public enum PaywallOrderStatus : byte
{
/// <summary>
/// Invoice is not paid yet
/// </summary>
Unpaid,
/// <summary>
/// Invoice is paid
/// </summary>
Paid,
/// <summary>
/// Invoice has expired and cant be paid
/// </summary>
Expired
}
public record PaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status);
/// <summary>
/// Base paywall order
/// </summary>
public class PaywallOrder
{
/// <summary>
/// Unique id of the order
/// </summary>
public Guid Id { get; init; }
public record LightningPaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status, string LnInvoice,
DateTimeOffset Expire) : PaywallOrder(Id, Price, Status);
/// <summary>
/// File id this order is for
/// </summary>
public Guid File { get; init; }
/// <summary>
/// Service used to generate this order
/// </summary>
public PaymentServices Service { get; init; }
/// <summary>
/// The price of the order
/// </summary>
public PaywallMoney Price { get; init; } = null!;
/// <summary>
/// Current status of the order
/// </summary>
public PaywallOrderStatus Status { get; set; }
}
/// <summary>
/// A paywall order lightning network invoice
/// </summary>
public class LightningPaywallOrder : PaywallOrder
{
/// <summary>
/// Lightning invoice
/// </summary>
public string Invoice { get; init; } = null!;
/// <summary>
/// Expire time of the order
/// </summary>
public DateTime Expire { get; init; }
}

View File

@ -132,6 +132,7 @@ services.AddAuthorization((opt) =>
//
services.AddTransient<RazorPartialToStringRenderer>();
services.AddTransient<IMigration, PopulateMetadataId>();
services.AddTransient<IMigration, MigrateToPostgres>();
// file storage
services.AddStorage(voidSettings);
@ -141,7 +142,7 @@ services.AddTransient<IAggregateStatsCollector, AggregateStatsCollector>();
services.AddTransient<IStatsCollector, PrometheusStatsCollector>();
// paywall
services.AddVoidPaywall();
services.AddPaywallServices(voidSettings);
// users
services.AddUserServices(voidSettings);

View File

@ -4,5 +4,5 @@ namespace VoidCat.Services.Abstractions;
public interface IPaywallFactory
{
ValueTask<IPaywallProvider> CreateProvider(PaywallServices svc);
ValueTask<IPaywallProvider> CreateProvider(PaymentServices svc);
}

View File

@ -0,0 +1,17 @@
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
/// <summary>
/// Paywall order store
/// </summary>
public interface IPaywallOrderStore : IBasicStore<PaywallOrder>
{
/// <summary>
/// Update the status of an order
/// </summary>
/// <param name="order"></param>
/// <param name="status"></param>
/// <returns></returns>
ValueTask UpdateStatus(Guid order, PaywallOrderStatus status);
}

View File

@ -1,11 +1,23 @@
using VoidCat.Model;
using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
/// <summary>
/// Provider to generate orders for a specific config
/// </summary>
public interface IPaywallProvider
{
ValueTask<PaywallOrder?> CreateOrder(PublicVoidFile file);
/// <summary>
/// Create an order with the provider
/// </summary>
/// <param name="file"></param>
/// <returns></returns>
ValueTask<PaywallOrder?> CreateOrder(PaywallConfig file);
/// <summary>
/// Get the status of an existing order with the provider
/// </summary>
/// <param name="id"></param>
/// <returns></returns>
ValueTask<PaywallOrder?> GetOrderStatus(Guid id);
}

View File

@ -2,8 +2,9 @@ using VoidCat.Model.Paywall;
namespace VoidCat.Services.Abstractions;
/// <summary>
/// Store for paywall configs
/// </summary>
public interface IPaywallStore : IBasicStore<PaywallConfig>
{
ValueTask<PaywallOrder?> GetOrder(Guid id);
ValueTask SaveOrder(PaywallOrder order);
}

View File

@ -8,13 +8,11 @@ namespace VoidCat.Services.Files;
public class LocalDiskFileMetadataStore : IFileMetadataStore
{
private const string MetadataDir = "metadata-v3";
private readonly ILogger<LocalDiskFileMetadataStore> _logger;
private readonly VoidSettings _settings;
public LocalDiskFileMetadataStore(VoidSettings settings, ILogger<LocalDiskFileMetadataStore> logger)
public LocalDiskFileMetadataStore(VoidSettings settings)
{
_settings = settings;
_logger = logger;
var metaPath = Path.Combine(_settings.DataDirectory, MetadataDir);
if (!Directory.Exists(metaPath))
@ -134,7 +132,6 @@ public class LocalDiskFileMetadataStore : IFileMetadataStore
var path = MapMeta(id);
if (File.Exists(path))
{
_logger.LogInformation("Deleting metadata file {Path}", path);
File.Delete(path);
}

View File

@ -1,8 +1,10 @@
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.InMemory;
/// <inheritdoc />
public class InMemoryCache : ICache
{
private readonly IMemoryCache _cache;
@ -12,30 +14,38 @@ public class InMemoryCache : ICache
_cache = cache;
}
/// <inheritdoc />
public ValueTask<T?> Get<T>(string key)
{
return ValueTask.FromResult(_cache.Get<T?>(key));
var json = _cache.Get<string>(key);
if (string.IsNullOrEmpty(json)) return default;
return ValueTask.FromResult(JsonConvert.DeserializeObject<T?>(json));
}
/// <inheritdoc />
public ValueTask Set<T>(string key, T value, TimeSpan? expire = null)
{
var json = JsonConvert.SerializeObject(value);
if (expire.HasValue)
{
_cache.Set(key, value, expire.Value);
_cache.Set(key, json, expire.Value);
}
else
{
_cache.Set(key, value);
_cache.Set(key, json);
}
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask<string[]> GetList(string key)
{
return ValueTask.FromResult(_cache.Get<string[]>(key) ?? Array.Empty<string>());
}
/// <inheritdoc />
public ValueTask AddToList(string key, string value)
{
var list = new HashSet<string>(GetList(key).Result);
@ -44,6 +54,7 @@ public class InMemoryCache : ICache
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask RemoveFromList(string key, string value)
{
var list = new HashSet<string>(GetList(key).Result);
@ -52,10 +63,10 @@ public class InMemoryCache : ICache
return ValueTask.CompletedTask;
}
/// <inheritdoc />
public ValueTask Delete(string key)
{
_cache.Remove(key);
return ValueTask.CompletedTask;
;
}
}

View File

@ -39,14 +39,27 @@ public class Init : Migration
Create.Table("Paywall")
.WithColumn("File").AsGuid().ForeignKey("Files", "Id").OnDelete(Rule.Cascade).PrimaryKey()
.WithColumn("Type").AsInt16()
.WithColumn("Service").AsInt16()
.WithColumn("Currency").AsInt16()
.WithColumn("Amount").AsDecimal();
Create.Table("PaywallStrike")
.WithColumn("File").AsGuid().ForeignKey("Files", "Id").OnDelete(Rule.Cascade).PrimaryKey()
.WithColumn("File").AsGuid().ForeignKey("Paywall", "File").OnDelete(Rule.Cascade).PrimaryKey()
.WithColumn("Handle").AsString();
Create.Table("PaywallOrder")
.WithColumn("Id").AsGuid().PrimaryKey()
.WithColumn("File").AsGuid().ForeignKey("Files", "Id").OnDelete(Rule.Cascade).Indexed()
.WithColumn("Service").AsInt16()
.WithColumn("Currency").AsInt16()
.WithColumn("Amount").AsDecimal()
.WithColumn("Status").AsInt16().Indexed();
Create.Table("PaywallOrderLightning")
.WithColumn("Order").AsGuid().ForeignKey("PaywallOrder", "Id").OnDelete(Rule.Cascade).PrimaryKey()
.WithColumn("Invoice").AsString()
.WithColumn("Expire").AsDateTimeOffset();
Create.Table("UserRoles")
.WithColumn("User").AsGuid().ForeignKey("Users", "Id").OnDelete(Rule.Cascade).Indexed()
.WithColumn("Role").AsString().NotNullable();

View File

@ -1,45 +0,0 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
namespace VoidCat.Services.Migrations;
public class LocalDiskToPostgres : IMigration
{
private readonly ILogger<LocalDiskToPostgres> _logger;
private readonly ILoggerFactory _loggerFactory;
private readonly VoidSettings _settings;
public LocalDiskToPostgres(VoidSettings settings, ILoggerFactory loggerFactory, IFileInfoManager fileInfoManager,
IUserUploadsStore userUploadsStore, IAggregateStatsCollector statsCollector)
{
_logger = loggerFactory.CreateLogger<LocalDiskToPostgres>();
_settings = settings;
_loggerFactory = loggerFactory;
}
public async ValueTask<IMigration.MigrationResult> Migrate(string[] args)
{
if (!args.Contains("--migrate-local-to-postgres"))
{
return IMigration.MigrationResult.Skipped;
}
var metaStore =
new LocalDiskFileMetadataStore(_settings, _loggerFactory.CreateLogger<LocalDiskFileMetadataStore>());
var files = await metaStore.ListFiles<SecretVoidFileMeta>(new(0, Int32.MaxValue));
await foreach (var file in files.Results)
{
_logger.LogInformation("Migrating file {File}", file.Id);
try
{
}
catch (Exception ex)
{
}
}
return IMigration.MigrationResult.ExitCompleted;
}
}

View File

@ -0,0 +1,86 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Files;
using VoidCat.Services.Paywall;
namespace VoidCat.Services.Migrations;
public class MigrateToPostgres : IMigration
{
private readonly ILogger<MigrateToPostgres> _logger;
private readonly VoidSettings _settings;
private readonly IFileMetadataStore _fileMetadata;
private readonly ICache _cache;
private readonly IPaywallStore _paywallStore;
public MigrateToPostgres(VoidSettings settings, ILogger<MigrateToPostgres> logger, IFileMetadataStore fileMetadata,
ICache cache, IPaywallStore paywallStore)
{
_logger = logger;
_settings = settings;
_fileMetadata = fileMetadata;
_cache = cache;
_paywallStore = paywallStore;
}
public async ValueTask<IMigration.MigrationResult> Migrate(string[] args)
{
if (args.Contains("--migrate-local-metadata-to-postgres"))
{
await MigrateFiles();
return IMigration.MigrationResult.ExitCompleted;
}
if (args.Contains("--migrate-cache-paywall-to-postgres"))
{
await MigratePaywall();
return IMigration.MigrationResult.ExitCompleted;
}
return IMigration.MigrationResult.Skipped;
}
private async Task MigrateFiles()
{
var localDiskMetaStore =
new LocalDiskFileMetadataStore(_settings);
var files = await localDiskMetaStore.ListFiles<SecretVoidFileMeta>(new(0, int.MaxValue));
await foreach (var file in files.Results)
{
try
{
await _fileMetadata.Set(file.Id, file);
await localDiskMetaStore.Delete(file.Id);
_logger.LogInformation("Migrated file metadata for {File}", file.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate file metadata for {File}", file.Id);
}
}
}
private async Task MigratePaywall()
{
var cachePaywallStore = new CachePaywallStore(_cache);
var files = await _fileMetadata.ListFiles<VoidFileMeta>(new(0, int.MaxValue));
await foreach (var file in files.Results)
{
try
{
var old = await cachePaywallStore.Get(file.Id);
if (old != default)
{
await _paywallStore.Add(file.Id, old);
_logger.LogInformation("Migrated paywall config for {File}", file.Id);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to migrate paywall config for {File}", file.Id);
}
}
}
}

View File

@ -0,0 +1,26 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
/// <inheritdoc cref="IPaywallOrderStore"/>
public class CachePaywallOrderStore : BasicCacheStore<PaywallOrder>, IPaywallOrderStore
{
public CachePaywallOrderStore(ICache cache) : base(cache)
{
}
/// <inheritdoc />
public async ValueTask UpdateStatus(Guid order, PaywallOrderStatus status)
{
var old = await Get(order);
if (old == default) return;
old.Status = status;
await Add(order, old);
}
/// <inheritdoc />
protected override string MapKey(Guid id) => $"paywall:order:{id}";
}

View File

@ -0,0 +1,28 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
/// <inheritdoc cref="IPaywallStore"/>
public class CachePaywallStore : BasicCacheStore<PaywallConfig>, IPaywallStore
{
public CachePaywallStore(ICache database)
: base(database)
{
}
/// <inheritdoc />
public override async ValueTask<PaywallConfig?> Get(Guid id)
{
var cfg = await Cache.Get<NoPaywallConfig>(MapKey(id));
return cfg?.Service switch
{
PaymentServices.None => cfg,
PaymentServices.Strike => await Cache.Get<StrikePaywallConfig>(MapKey(id)),
_ => default
};
}
/// <inheritdoc />
protected override string MapKey(Guid id) => $"paywall:config:{id}";
}

View File

@ -1,6 +1,5 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Strike;
namespace VoidCat.Services.Paywall;
@ -13,26 +12,12 @@ public class PaywallFactory : IPaywallFactory
_services = services;
}
public ValueTask<IPaywallProvider> CreateProvider(PaywallServices svc)
public ValueTask<IPaywallProvider> CreateProvider(PaymentServices svc)
{
return ValueTask.FromResult<IPaywallProvider>(svc switch
{
PaywallServices.Strike => _services.GetRequiredService<StrikePaywallProvider>(),
PaymentServices.Strike => _services.GetRequiredService<StrikePaywallProvider>(),
_ => throw new ArgumentException("Must have a paywall config", nameof(svc))
});
}
}
public static class Paywall
{
public static void AddVoidPaywall(this IServiceCollection services)
{
services.AddTransient<IPaywallFactory, PaywallFactory>();
services.AddTransient<IPaywallStore, PaywallStore>();
// strike
services.AddTransient<StrikeApi>();
services.AddTransient<StrikePaywallProvider>();
services.AddTransient<IPaywallProvider>((svc) => svc.GetRequiredService<StrikePaywallProvider>());
}
}

View File

@ -0,0 +1,32 @@
using VoidCat.Model;
using VoidCat.Services.Abstractions;
using VoidCat.Services.Strike;
namespace VoidCat.Services.Paywall;
public static class PaywallStartup
{
/// <summary>
/// Add services required to use paywall functions
/// </summary>
/// <param name="services"></param>
/// <param name="settings"></param>
public static void AddPaywallServices(this IServiceCollection services, VoidSettings settings)
{
services.AddTransient<IPaywallFactory, PaywallFactory>();
if (settings.HasPostgres())
{
services.AddTransient<IPaywallStore, PostgresPaywallStore>();
services.AddTransient<IPaywallOrderStore, PostgresPaywallOrderStore>();
}
else
{
services.AddTransient<IPaywallStore, CachePaywallStore>();
services.AddTransient<IPaywallOrderStore, CachePaywallOrderStore>();
}
// strike
services.AddTransient<StrikeApi>();
services.AddTransient<StrikePaywallProvider>();
}
}

View File

@ -1,38 +0,0 @@
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
public class PaywallStore : BasicCacheStore<PaywallConfig>, IPaywallStore
{
public PaywallStore(ICache database)
: base(database)
{
}
/// <inheritdoc />
public override async ValueTask<PaywallConfig?> Get(Guid id)
{
var cfg = await Cache.Get<NoPaywallConfig>(MapKey(id));
return cfg?.Service switch
{
PaywallServices.None => cfg,
PaywallServices.Strike => await Cache.Get<StrikePaywallConfig>(MapKey(id)),
_ => default
};
}
public async ValueTask<PaywallOrder?> GetOrder(Guid id)
{
return await Cache.Get<PaywallOrder>(OrderKey(id));
}
public ValueTask SaveOrder(PaywallOrder order)
{
return Cache.Set(OrderKey(order.Id), order,
order.Status == PaywallOrderStatus.Paid ? TimeSpan.FromDays(1) : TimeSpan.FromSeconds(5));
}
protected override string MapKey(Guid id) => $"paywall:config:{id}";
private string OrderKey(Guid id) => $"paywall:order:{id}";
}

View File

@ -0,0 +1,104 @@
using Dapper;
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
/// <inheritdoc />
public class PostgresPaywallOrderStore : IPaywallOrderStore
{
private readonly PostgresConnectionFactory _connection;
public PostgresPaywallOrderStore(PostgresConnectionFactory connection)
{
_connection = connection;
}
/// <inheritdoc />
public async ValueTask<PaywallOrder?> Get(Guid id)
{
await using var conn = await _connection.Get();
var order = await conn.QuerySingleOrDefaultAsync<DtoPaywallOrder>(
@"select * from ""PaywallOrder"" where ""Id"" = :id", new {id});
if (order.Service is PaymentServices.Strike)
{
var lnDetails = await conn.QuerySingleAsync<LightningPaywallOrder>(
@"select * from ""PaywallOrderLightning"" where ""Order"" = :id", new
{
id = order.Id
});
return new LightningPaywallOrder
{
Id = order.Id,
File = order.File,
Price = new(order.Amount, order.Currency),
Service = order.Service,
Status = order.Status,
Invoice = lnDetails.Invoice,
Expire = lnDetails.Expire
};
}
return order;
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<PaywallOrder>> Get(Guid[] ids)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async ValueTask Add(Guid id, PaywallOrder obj)
{
await using var conn = await _connection.Get();
await using var txn = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
@"insert into ""PaywallOrder""(""Id"", ""File"", ""Service"", ""Currency"", ""Amount"", ""Status"")
values(:id, :file, :service, :currency, :amt, :status)",
new
{
id,
file = obj.File,
service = (int) obj.Service,
currency = (int) obj.Price.Currency,
amt = obj.Price.Amount, // :amount wasn't working?
status = (int) obj.Status
});
if (obj is LightningPaywallOrder ln)
{
await conn.ExecuteAsync(
@"insert into ""PaywallOrderLightning""(""Order"", ""Invoice"", ""Expire"") values(:order, :invoice, :expire)",
new
{
order = id,
invoice = ln.Invoice,
expire = ln.Expire.ToUniversalTime()
});
}
await txn.CommitAsync();
}
/// <inheritdoc />
public async ValueTask Delete(Guid id)
{
await using var conn = await _connection.Get();
await conn.ExecuteAsync(@"delete from ""PaywallOrder"" where ""Id"" = :id", new {id});
}
/// <inheritdoc />
public async ValueTask UpdateStatus(Guid order, PaywallOrderStatus status)
{
await using var conn = await _connection.Get();
await conn.ExecuteAsync(@"update ""PaywallOrder"" set ""Status"" = :status where ""Id"" = :id",
new {id = order, status = (int) status});
}
private sealed class DtoPaywallOrder : PaywallOrder
{
public PaywallCurrencies Currency { get; init; }
public decimal Amount { get; init; }
}
}

View File

@ -0,0 +1,93 @@
using Dapper;
using VoidCat.Model.Paywall;
using VoidCat.Services.Abstractions;
namespace VoidCat.Services.Paywall;
/// <inheritdoc />
public sealed class PostgresPaywallStore : IPaywallStore
{
private readonly PostgresConnectionFactory _connection;
public PostgresPaywallStore(PostgresConnectionFactory connection)
{
_connection = connection;
}
/// <inheritdoc />
public async ValueTask<PaywallConfig?> Get(Guid id)
{
await using var conn = await _connection.Get();
var svc = await conn.QuerySingleOrDefaultAsync<DtoPaywallConfig>(
@"select * from ""Paywall"" where ""File"" = :file", new {file = id});
if (svc != default)
{
switch (svc.Service)
{
case PaymentServices.Strike:
{
var handle =
await conn.ExecuteScalarAsync<string>(
@"select ""Handle"" from ""PaywallStrike"" where ""File"" = :file", new {file = id});
return new StrikePaywallConfig
{
Cost = new(svc.Amount, svc.Currency),
File = svc.File,
Handle = handle,
Service = PaymentServices.Strike
};
}
}
}
return default;
}
/// <inheritdoc />
public ValueTask<IReadOnlyList<PaywallConfig>> Get(Guid[] ids)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public async ValueTask Add(Guid id, PaywallConfig obj)
{
await using var conn = await _connection.Get();
await using var txn = await conn.BeginTransactionAsync();
await conn.ExecuteAsync(
@"insert into ""Paywall""(""File"", ""Service"", ""Amount"", ""Currency"") values(:file, :service, :amount, :currency)
on conflict(""File"") do update set ""Service"" = :service, ""Amount"" = :amount, ""Currency"" = :currency",
new
{
file = id,
service = (int)obj.Service,
amount = obj.Cost.Amount,
currency = obj.Cost.Currency
});
if (obj is StrikePaywallConfig sc)
{
await conn.ExecuteAsync(@"insert into ""PaywallStrike""(""File"", ""Handle"") values(:file, :handle)
on conflict(""File"") do update set ""Handle"" = :handle", new
{
file = id,
handle = sc.Handle
});
}
await txn.CommitAsync();
}
/// <inheritdoc />
public async ValueTask Delete(Guid id)
{
await using var conn = await _connection.Get();
await conn.ExecuteAsync(@"delete from ""Paywall"" where ""File"" = :file", new {file = id});
}
private sealed class DtoPaywallConfig : PaywallConfig
{
public PaywallCurrencies Currency { get; init; }
public decimal Amount { get; init; }
}
}

View File

@ -6,31 +6,31 @@ using VoidCat.Services.Strike;
namespace VoidCat.Services.Paywall;
/// <inheritdoc />
public class StrikePaywallProvider : IPaywallProvider
{
private readonly ILogger<StrikePaywallProvider> _logger;
private readonly StrikeApi _strike;
private readonly IPaywallStore _store;
private readonly IPaywallOrderStore _orderStore;
public StrikePaywallProvider(ILogger<StrikePaywallProvider> logger, StrikeApi strike, IPaywallStore store)
public StrikePaywallProvider(ILogger<StrikePaywallProvider> logger, StrikeApi strike, IPaywallOrderStore orderStore)
{
_logger = logger;
_strike = strike;
_store = store;
_orderStore = orderStore;
}
public async ValueTask<PaywallOrder?> CreateOrder(PublicVoidFile file)
/// <inheritdoc />
public async ValueTask<PaywallOrder?> CreateOrder(PaywallConfig config)
{
IsStrikePaywall(file.Paywall, out var strikeConfig);
var config = file.Paywall!;
IsStrikePaywall(config, out var strikeConfig);
_logger.LogInformation("Generating invoice for {Currency} {Amount}", config.Cost.Currency, config.Cost.Amount);
var currency = MapCurrency(strikeConfig!.Cost.Currency);
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);
var profile = await _strike.GetProfile(strikeConfig.Handle);
if (profile != default)
{
var usd = profile.Currencies.FirstOrDefault(a => a.Currency == Currencies.USD);
@ -50,44 +50,70 @@ public class StrikePaywallProvider : IPaywallProvider
Amount = strikeConfig.Cost.Amount.ToString(CultureInfo.InvariantCulture),
Currency = currency
},
Description = file.Metadata?.Name
Description = config.File.ToBase58()
});
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);
var order = new LightningPaywallOrder
{
Id = invoice.InvoiceId,
File = config.File,
Service = PaymentServices.Strike,
Price = config.Cost,
Status = PaywallOrderStatus.Unpaid,
Invoice = quote.LnInvoice!,
Expire = DateTime.SpecifyKind(quote.Expiration.DateTime, DateTimeKind.Utc)
};
await _orderStore.Add(order.Id, order);
return order;
}
_logger.LogWarning("Failed to get quote for invoice: {Id}", invoice.InvoiceId);
}
_logger.LogWarning("Failed to get invoice for config: {Config}", config);
_logger.LogWarning("Failed to get invoice for config: File={File}, Service={Service}", config.File,
config.Service.ToString());
return default;
}
/// <inheritdoc />
public async ValueTask<PaywallOrder?> GetOrderStatus(Guid id)
{
var order = await _store.GetOrder(id);
if (order == default)
var order = await _orderStore.Get(id);
if (order is {Status: PaywallOrderStatus.Paid or PaywallOrderStatus.Expired}) return order;
var providerOrder = await _strike.GetInvoice(id);
if (providerOrder != default)
{
var invoice = await _strike.GetInvoice(id);
if (invoice != default)
var status = MapStatus(providerOrder.State);
await _orderStore.UpdateStatus(id, status);
return new()
{
order = new(id, new(decimal.Parse(invoice.Amount!.Amount!), MapCurrency(invoice.Amount.Currency)),
MapStatus(invoice.State));
await _store.SaveOrder(order);
}
Id = id,
Price = new(decimal.Parse(providerOrder!.Amount!.Amount!),
MapCurrency(providerOrder.Amount!.Currency!.Value)),
Service = PaymentServices.Strike,
Status = status
};
}
return order;
return default;
}
private PaywallOrderStatus MapStatus(InvoiceState providerOrderState)
=> providerOrderState switch
{
InvoiceState.UNPAID => PaywallOrderStatus.Unpaid,
InvoiceState.PENDING => PaywallOrderStatus.Unpaid,
InvoiceState.PAID => PaywallOrderStatus.Paid,
InvoiceState.CANCELLED => PaywallOrderStatus.Expired,
_ => throw new ArgumentOutOfRangeException(nameof(providerOrderState), providerOrderState, null)
};
private static Currencies MapCurrency(PaywallCurrencies c)
=> c switch
{
@ -98,34 +124,24 @@ public class StrikePaywallProvider : IPaywallProvider
_ => throw new ArgumentOutOfRangeException(nameof(c), c, null)
};
private static PaywallCurrencies MapCurrency(Currencies? c)
private static PaywallCurrencies MapCurrency(Currencies c)
=> c switch
{
Currencies.BTC => PaywallCurrencies.BTC,
Currencies.USD => PaywallCurrencies.USD,
Currencies.USDT => 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
private static void IsStrikePaywall(PaywallConfig? cfg, out StrikePaywallConfig strikeConfig)
{
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)
if (cfg?.Service != PaymentServices.Strike)
{
throw new ArgumentException("Must be strike paywall");
}
strikeConfig = cfg as StrikePaywallConfig;
strikeConfig = (cfg as StrikePaywallConfig)!;
}
}

View File

@ -3,6 +3,8 @@ using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
#pragma warning disable CS1591
namespace VoidCat.Services.Strike;
public class StrikeApi

View File

@ -16,7 +16,9 @@ export function FileEdit(props) {
const [name, setName] = useState(meta?.name);
const [description, setDescription] = useState(meta?.description);
const privateFile = profile?.id === file?.uploader?.id ? file : JSON.parse(window.localStorage.getItem(file.id));
const privateFile = file?.uploader?.id && profile?.id === file.uploader.id
? file
: JSON.parse(window.localStorage.getItem(file.id));
if (!privateFile || privateFile?.metadata?.editSecret === null) {
return null;
}

View File

@ -10,7 +10,7 @@ export function LightningPaywall(props) {
const file = props.file;
const order = props.order;
const onPaid = props.onPaid;
const link = `lightning:${order.lnInvoice}`;
const link = `lightning:${order.invoice}`;
function openInvoice() {
let a = document.createElement("a");