forked from Kieran/void.cat
Postgres paywall support
Various fixes
This commit is contained in:
parent
3582431640
commit
e49c2fe870
@ -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;
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
@ -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!;
|
||||
}
|
@ -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; }
|
||||
}
|
@ -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);
|
||||
|
@ -4,5 +4,5 @@ namespace VoidCat.Services.Abstractions;
|
||||
|
||||
public interface IPaywallFactory
|
||||
{
|
||||
ValueTask<IPaywallProvider> CreateProvider(PaywallServices svc);
|
||||
ValueTask<IPaywallProvider> CreateProvider(PaymentServices svc);
|
||||
}
|
17
VoidCat/Services/Abstractions/IPaywallOrderStore.cs
Normal file
17
VoidCat/Services/Abstractions/IPaywallOrderStore.cs
Normal 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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
;
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
86
VoidCat/Services/Migrations/MigrateToPostgres.cs
Normal file
86
VoidCat/Services/Migrations/MigrateToPostgres.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
26
VoidCat/Services/Paywall/CachePaywallOrderStore.cs
Normal file
26
VoidCat/Services/Paywall/CachePaywallOrderStore.cs
Normal 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}";
|
||||
}
|
28
VoidCat/Services/Paywall/CachePaywallStore.cs
Normal file
28
VoidCat/Services/Paywall/CachePaywallStore.cs
Normal 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}";
|
||||
}
|
@ -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>());
|
||||
}
|
||||
}
|
32
VoidCat/Services/Paywall/PaywallStartup.cs
Normal file
32
VoidCat/Services/Paywall/PaywallStartup.cs
Normal 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>();
|
||||
}
|
||||
}
|
@ -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}";
|
||||
}
|
104
VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs
Normal file
104
VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs
Normal 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; }
|
||||
}
|
||||
}
|
93
VoidCat/Services/Paywall/PostgresPaywallStore.cs
Normal file
93
VoidCat/Services/Paywall/PostgresPaywallStore.cs
Normal 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; }
|
||||
}
|
||||
}
|
@ -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
|
||||
{
|
||||
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)
|
||||
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)!;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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");
|
||||
|
Loading…
Reference in New Issue
Block a user