From e49c2fe870793ddd601b8a2c582ee77ee26d0ab8 Mon Sep 17 00:00:00 2001 From: Kieran Date: Mon, 13 Jun 2022 14:35:26 +0100 Subject: [PATCH] Postgres paywall support Various fixes --- VoidCat/Controllers/DownloadController.cs | 10 +- VoidCat/Controllers/UploadController.cs | 13 ++- VoidCat/Model/Extensions.cs | 3 + VoidCat/Model/Paywall/Paywall.cs | 52 ++++++++- VoidCat/Model/Paywall/PaywallOrder.cs | 62 ++++++++++- VoidCat/Program.cs | 3 +- .../Services/Abstractions/IPaywallFactory.cs | 2 +- .../Abstractions/IPaywallOrderStore.cs | 17 +++ .../Services/Abstractions/IPaywallProvider.cs | 16 ++- .../Services/Abstractions/IPaywallStore.cs | 5 +- .../Files/LocalDiskFileMetadataStore.cs | 5 +- VoidCat/Services/InMemory/InMemoryCache.cs | 19 +++- .../Services/Migrations/Database/00-Init.cs | 17 ++- .../Migrations/LocalDiskToPostgres.cs | 45 -------- .../Services/Migrations/MigrateToPostgres.cs | 86 +++++++++++++++ .../Paywall/CachePaywallOrderStore.cs | 26 +++++ VoidCat/Services/Paywall/CachePaywallStore.cs | 28 +++++ VoidCat/Services/Paywall/PaywallFactory.cs | 19 +--- VoidCat/Services/Paywall/PaywallStartup.cs | 32 ++++++ VoidCat/Services/Paywall/PaywallStore.cs | 38 ------- .../Paywall/PostgresPaywallOrderStore.cs | 104 ++++++++++++++++++ .../Services/Paywall/PostgresPaywallStore.cs | 93 ++++++++++++++++ .../Services/Paywall/StrikePaywallProvider.cs | 94 +++++++++------- VoidCat/Services/Strike/StrikeApi.cs | 2 + VoidCat/spa/src/FileEdit.js | 4 +- VoidCat/spa/src/LightningPaywall.js | 2 +- 26 files changed, 622 insertions(+), 175 deletions(-) create mode 100644 VoidCat/Services/Abstractions/IPaywallOrderStore.cs delete mode 100644 VoidCat/Services/Migrations/LocalDiskToPostgres.cs create mode 100644 VoidCat/Services/Migrations/MigrateToPostgres.cs create mode 100644 VoidCat/Services/Paywall/CachePaywallOrderStore.cs create mode 100644 VoidCat/Services/Paywall/CachePaywallStore.cs create mode 100644 VoidCat/Services/Paywall/PaywallStartup.cs delete mode 100644 VoidCat/Services/Paywall/PaywallStore.cs create mode 100644 VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs create mode 100644 VoidCat/Services/Paywall/PostgresPaywallStore.cs diff --git a/VoidCat/Controllers/DownloadController.cs b/VoidCat/Controllers/DownloadController.cs index 278fb0e..d1b1c38 100644 --- a/VoidCat/Controllers/DownloadController.cs +++ b/VoidCat/Controllers/DownloadController.cs @@ -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 _logger; public DownloadController(IFileStore storage, ILogger 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; diff --git a/VoidCat/Controllers/UploadController.cs b/VoidCat/Controllers/UploadController.cs index 652fed9..6cc894c 100644 --- a/VoidCat/Controllers/UploadController.cs +++ b/VoidCat/Controllers/UploadController.cs @@ -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!); } /// @@ -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(); } diff --git a/VoidCat/Model/Extensions.cs b/VoidCat/Model/Extensions.cs index dc655b4..532beaa 100644 --- a/VoidCat/Model/Extensions.cs +++ b/VoidCat/Model/Extensions.cs @@ -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); } \ No newline at end of file diff --git a/VoidCat/Model/Paywall/Paywall.cs b/VoidCat/Model/Paywall/Paywall.cs index 0fddedf..8001660 100644 --- a/VoidCat/Model/Paywall/Paywall.cs +++ b/VoidCat/Model/Paywall/Paywall.cs @@ -1,16 +1,56 @@ namespace VoidCat.Model.Paywall; -public enum PaywallServices +/// +/// Payment services supported by the system +/// +public enum PaymentServices { + /// + /// No service + /// None, + + /// + /// Strike.me payment service + /// 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) +/// +/// Base paywall config +/// +public abstract class PaywallConfig { + /// + /// File this config is for + /// + public Guid File { get; init; } + + /// + /// Service used to pay the paywall + /// + public PaymentServices Service { get; init; } = PaymentServices.None; + + /// + /// The cost for the paywall to pass + /// + public PaywallMoney Cost { get; init; } = new(0m, PaywallCurrencies.BTC); +} + +/// +public sealed class NoPaywallConfig : PaywallConfig +{ + +} + +/// +/// Paywall config for service +/// +/// +public sealed class StrikePaywallConfig : PaywallConfig +{ + /// + /// Strike username to pay to + /// public string Handle { get; init; } = null!; } \ No newline at end of file diff --git a/VoidCat/Model/Paywall/PaywallOrder.cs b/VoidCat/Model/Paywall/PaywallOrder.cs index 33a4627..3fae47c 100644 --- a/VoidCat/Model/Paywall/PaywallOrder.cs +++ b/VoidCat/Model/Paywall/PaywallOrder.cs @@ -1,13 +1,69 @@ namespace VoidCat.Model.Paywall; +/// +/// Status of paywall order +/// public enum PaywallOrderStatus : byte { + /// + /// Invoice is not paid yet + /// Unpaid, + + /// + /// Invoice is paid + /// Paid, + + /// + /// Invoice has expired and cant be paid + /// Expired } -public record PaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status); +/// +/// Base paywall order +/// +public class PaywallOrder +{ + /// + /// Unique id of the order + /// + public Guid Id { get; init; } + + /// + /// File id this order is for + /// + public Guid File { get; init; } + + /// + /// Service used to generate this order + /// + public PaymentServices Service { get; init; } -public record LightningPaywallOrder(Guid Id, PaywallMoney Price, PaywallOrderStatus Status, string LnInvoice, - DateTimeOffset Expire) : PaywallOrder(Id, Price, Status); \ No newline at end of file + /// + /// The price of the order + /// + public PaywallMoney Price { get; init; } = null!; + + /// + /// Current status of the order + /// + public PaywallOrderStatus Status { get; set; } +} + +/// +/// A paywall order lightning network invoice +/// +public class LightningPaywallOrder : PaywallOrder +{ + /// + /// Lightning invoice + /// + public string Invoice { get; init; } = null!; + + /// + /// Expire time of the order + /// + public DateTime Expire { get; init; } +} \ No newline at end of file diff --git a/VoidCat/Program.cs b/VoidCat/Program.cs index 74bb0b5..e51a680 100644 --- a/VoidCat/Program.cs +++ b/VoidCat/Program.cs @@ -132,6 +132,7 @@ services.AddAuthorization((opt) => // services.AddTransient(); services.AddTransient(); +services.AddTransient(); // file storage services.AddStorage(voidSettings); @@ -141,7 +142,7 @@ services.AddTransient(); services.AddTransient(); // paywall -services.AddVoidPaywall(); +services.AddPaywallServices(voidSettings); // users services.AddUserServices(voidSettings); diff --git a/VoidCat/Services/Abstractions/IPaywallFactory.cs b/VoidCat/Services/Abstractions/IPaywallFactory.cs index fcfe9a1..9511ab5 100644 --- a/VoidCat/Services/Abstractions/IPaywallFactory.cs +++ b/VoidCat/Services/Abstractions/IPaywallFactory.cs @@ -4,5 +4,5 @@ namespace VoidCat.Services.Abstractions; public interface IPaywallFactory { - ValueTask CreateProvider(PaywallServices svc); + ValueTask CreateProvider(PaymentServices svc); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallOrderStore.cs b/VoidCat/Services/Abstractions/IPaywallOrderStore.cs new file mode 100644 index 0000000..406b13c --- /dev/null +++ b/VoidCat/Services/Abstractions/IPaywallOrderStore.cs @@ -0,0 +1,17 @@ +using VoidCat.Model.Paywall; + +namespace VoidCat.Services.Abstractions; + +/// +/// Paywall order store +/// +public interface IPaywallOrderStore : IBasicStore +{ + /// + /// Update the status of an order + /// + /// + /// + /// + ValueTask UpdateStatus(Guid order, PaywallOrderStatus status); +} \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallProvider.cs b/VoidCat/Services/Abstractions/IPaywallProvider.cs index 5fc3c69..ad6fc0e 100644 --- a/VoidCat/Services/Abstractions/IPaywallProvider.cs +++ b/VoidCat/Services/Abstractions/IPaywallProvider.cs @@ -1,11 +1,23 @@ -using VoidCat.Model; using VoidCat.Model.Paywall; namespace VoidCat.Services.Abstractions; +/// +/// Provider to generate orders for a specific config +/// public interface IPaywallProvider { - ValueTask CreateOrder(PublicVoidFile file); + /// + /// Create an order with the provider + /// + /// + /// + ValueTask CreateOrder(PaywallConfig file); + /// + /// Get the status of an existing order with the provider + /// + /// + /// ValueTask GetOrderStatus(Guid id); } \ No newline at end of file diff --git a/VoidCat/Services/Abstractions/IPaywallStore.cs b/VoidCat/Services/Abstractions/IPaywallStore.cs index be182e5..f0a1a16 100644 --- a/VoidCat/Services/Abstractions/IPaywallStore.cs +++ b/VoidCat/Services/Abstractions/IPaywallStore.cs @@ -2,8 +2,9 @@ using VoidCat.Model.Paywall; namespace VoidCat.Services.Abstractions; +/// +/// Store for paywall configs +/// public interface IPaywallStore : IBasicStore { - ValueTask GetOrder(Guid id); - ValueTask SaveOrder(PaywallOrder order); } \ No newline at end of file diff --git a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs index a615344..4a2cd4d 100644 --- a/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs +++ b/VoidCat/Services/Files/LocalDiskFileMetadataStore.cs @@ -8,13 +8,11 @@ namespace VoidCat.Services.Files; public class LocalDiskFileMetadataStore : IFileMetadataStore { private const string MetadataDir = "metadata-v3"; - private readonly ILogger _logger; private readonly VoidSettings _settings; - public LocalDiskFileMetadataStore(VoidSettings settings, ILogger 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); } diff --git a/VoidCat/Services/InMemory/InMemoryCache.cs b/VoidCat/Services/InMemory/InMemoryCache.cs index f92a9b3..e1dcf6e 100644 --- a/VoidCat/Services/InMemory/InMemoryCache.cs +++ b/VoidCat/Services/InMemory/InMemoryCache.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.Caching.Memory; +using Newtonsoft.Json; using VoidCat.Services.Abstractions; namespace VoidCat.Services.InMemory; +/// public class InMemoryCache : ICache { private readonly IMemoryCache _cache; @@ -12,30 +14,38 @@ public class InMemoryCache : ICache _cache = cache; } + /// public ValueTask Get(string key) { - return ValueTask.FromResult(_cache.Get(key)); + var json = _cache.Get(key); + if (string.IsNullOrEmpty(json)) return default; + + return ValueTask.FromResult(JsonConvert.DeserializeObject(json)); } + /// public ValueTask Set(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; } + /// public ValueTask GetList(string key) { return ValueTask.FromResult(_cache.Get(key) ?? Array.Empty()); } + /// public ValueTask AddToList(string key, string value) { var list = new HashSet(GetList(key).Result); @@ -44,6 +54,7 @@ public class InMemoryCache : ICache return ValueTask.CompletedTask; } + /// public ValueTask RemoveFromList(string key, string value) { var list = new HashSet(GetList(key).Result); @@ -52,10 +63,10 @@ public class InMemoryCache : ICache return ValueTask.CompletedTask; } + /// public ValueTask Delete(string key) { _cache.Remove(key); return ValueTask.CompletedTask; - ; } } \ No newline at end of file diff --git a/VoidCat/Services/Migrations/Database/00-Init.cs b/VoidCat/Services/Migrations/Database/00-Init.cs index 051e506..d05f6ce 100644 --- a/VoidCat/Services/Migrations/Database/00-Init.cs +++ b/VoidCat/Services/Migrations/Database/00-Init.cs @@ -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(); diff --git a/VoidCat/Services/Migrations/LocalDiskToPostgres.cs b/VoidCat/Services/Migrations/LocalDiskToPostgres.cs deleted file mode 100644 index da2ddc9..0000000 --- a/VoidCat/Services/Migrations/LocalDiskToPostgres.cs +++ /dev/null @@ -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 _logger; - private readonly ILoggerFactory _loggerFactory; - private readonly VoidSettings _settings; - - public LocalDiskToPostgres(VoidSettings settings, ILoggerFactory loggerFactory, IFileInfoManager fileInfoManager, - IUserUploadsStore userUploadsStore, IAggregateStatsCollector statsCollector) - { - _logger = loggerFactory.CreateLogger(); - _settings = settings; - _loggerFactory = loggerFactory; - } - - public async ValueTask Migrate(string[] args) - { - if (!args.Contains("--migrate-local-to-postgres")) - { - return IMigration.MigrationResult.Skipped; - } - - var metaStore = - new LocalDiskFileMetadataStore(_settings, _loggerFactory.CreateLogger()); - - var files = await metaStore.ListFiles(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; - } -} \ No newline at end of file diff --git a/VoidCat/Services/Migrations/MigrateToPostgres.cs b/VoidCat/Services/Migrations/MigrateToPostgres.cs new file mode 100644 index 0000000..e220484 --- /dev/null +++ b/VoidCat/Services/Migrations/MigrateToPostgres.cs @@ -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 _logger; + private readonly VoidSettings _settings; + private readonly IFileMetadataStore _fileMetadata; + private readonly ICache _cache; + private readonly IPaywallStore _paywallStore; + + public MigrateToPostgres(VoidSettings settings, ILogger logger, IFileMetadataStore fileMetadata, + ICache cache, IPaywallStore paywallStore) + { + _logger = logger; + _settings = settings; + _fileMetadata = fileMetadata; + _cache = cache; + _paywallStore = paywallStore; + } + + public async ValueTask 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(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(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); + } + } + } +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/CachePaywallOrderStore.cs b/VoidCat/Services/Paywall/CachePaywallOrderStore.cs new file mode 100644 index 0000000..cf5fd78 --- /dev/null +++ b/VoidCat/Services/Paywall/CachePaywallOrderStore.cs @@ -0,0 +1,26 @@ +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +/// +public class CachePaywallOrderStore : BasicCacheStore, IPaywallOrderStore +{ + public CachePaywallOrderStore(ICache cache) : base(cache) + { + } + + /// + public async ValueTask UpdateStatus(Guid order, PaywallOrderStatus status) + { + var old = await Get(order); + if (old == default) return; + + old.Status = status; + + await Add(order, old); + } + + /// + protected override string MapKey(Guid id) => $"paywall:order:{id}"; +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/CachePaywallStore.cs b/VoidCat/Services/Paywall/CachePaywallStore.cs new file mode 100644 index 0000000..0732ecc --- /dev/null +++ b/VoidCat/Services/Paywall/CachePaywallStore.cs @@ -0,0 +1,28 @@ +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +/// +public class CachePaywallStore : BasicCacheStore, IPaywallStore +{ + public CachePaywallStore(ICache database) + : base(database) + { + } + + /// + public override async ValueTask Get(Guid id) + { + var cfg = await Cache.Get(MapKey(id)); + return cfg?.Service switch + { + PaymentServices.None => cfg, + PaymentServices.Strike => await Cache.Get(MapKey(id)), + _ => default + }; + } + + /// + protected override string MapKey(Guid id) => $"paywall:config:{id}"; +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PaywallFactory.cs b/VoidCat/Services/Paywall/PaywallFactory.cs index fb4044b..568fd74 100644 --- a/VoidCat/Services/Paywall/PaywallFactory.cs +++ b/VoidCat/Services/Paywall/PaywallFactory.cs @@ -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 CreateProvider(PaywallServices svc) + public ValueTask CreateProvider(PaymentServices svc) { return ValueTask.FromResult(svc switch { - PaywallServices.Strike => _services.GetRequiredService(), + PaymentServices.Strike => _services.GetRequiredService(), _ => throw new ArgumentException("Must have a paywall config", nameof(svc)) }); } -} - -public static class Paywall -{ - public static void AddVoidPaywall(this IServiceCollection services) - { - services.AddTransient(); - services.AddTransient(); - - // strike - services.AddTransient(); - services.AddTransient(); - services.AddTransient((svc) => svc.GetRequiredService()); - } } \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PaywallStartup.cs b/VoidCat/Services/Paywall/PaywallStartup.cs new file mode 100644 index 0000000..39985ea --- /dev/null +++ b/VoidCat/Services/Paywall/PaywallStartup.cs @@ -0,0 +1,32 @@ +using VoidCat.Model; +using VoidCat.Services.Abstractions; +using VoidCat.Services.Strike; + +namespace VoidCat.Services.Paywall; + +public static class PaywallStartup +{ + /// + /// Add services required to use paywall functions + /// + /// + /// + public static void AddPaywallServices(this IServiceCollection services, VoidSettings settings) + { + services.AddTransient(); + if (settings.HasPostgres()) + { + services.AddTransient(); + services.AddTransient(); + } + else + { + services.AddTransient(); + services.AddTransient(); + } + + // strike + services.AddTransient(); + services.AddTransient(); + } +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PaywallStore.cs b/VoidCat/Services/Paywall/PaywallStore.cs deleted file mode 100644 index 8dcd43a..0000000 --- a/VoidCat/Services/Paywall/PaywallStore.cs +++ /dev/null @@ -1,38 +0,0 @@ -using VoidCat.Model.Paywall; -using VoidCat.Services.Abstractions; - -namespace VoidCat.Services.Paywall; - -public class PaywallStore : BasicCacheStore, IPaywallStore -{ - public PaywallStore(ICache database) - : base(database) - { - } - - /// - public override async ValueTask Get(Guid id) - { - var cfg = await Cache.Get(MapKey(id)); - return cfg?.Service switch - { - PaywallServices.None => cfg, - PaywallServices.Strike => await Cache.Get(MapKey(id)), - _ => default - }; - } - - public async ValueTask GetOrder(Guid id) - { - return await Cache.Get(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}"; -} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs b/VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs new file mode 100644 index 0000000..0ef0be8 --- /dev/null +++ b/VoidCat/Services/Paywall/PostgresPaywallOrderStore.cs @@ -0,0 +1,104 @@ +using Dapper; +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +/// +public class PostgresPaywallOrderStore : IPaywallOrderStore +{ + private readonly PostgresConnectionFactory _connection; + + public PostgresPaywallOrderStore(PostgresConnectionFactory connection) + { + _connection = connection; + } + + /// + public async ValueTask Get(Guid id) + { + await using var conn = await _connection.Get(); + var order = await conn.QuerySingleOrDefaultAsync( + @"select * from ""PaywallOrder"" where ""Id"" = :id", new {id}); + if (order.Service is PaymentServices.Strike) + { + var lnDetails = await conn.QuerySingleAsync( + @"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; + } + + /// + public ValueTask> Get(Guid[] ids) + { + throw new NotImplementedException(); + } + + /// + 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(); + } + + /// + public async ValueTask Delete(Guid id) + { + await using var conn = await _connection.Get(); + await conn.ExecuteAsync(@"delete from ""PaywallOrder"" where ""Id"" = :id", new {id}); + } + + /// + 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; } + } +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/PostgresPaywallStore.cs b/VoidCat/Services/Paywall/PostgresPaywallStore.cs new file mode 100644 index 0000000..23df6e5 --- /dev/null +++ b/VoidCat/Services/Paywall/PostgresPaywallStore.cs @@ -0,0 +1,93 @@ +using Dapper; +using VoidCat.Model.Paywall; +using VoidCat.Services.Abstractions; + +namespace VoidCat.Services.Paywall; + +/// +public sealed class PostgresPaywallStore : IPaywallStore +{ + private readonly PostgresConnectionFactory _connection; + + public PostgresPaywallStore(PostgresConnectionFactory connection) + { + _connection = connection; + } + + /// + public async ValueTask Get(Guid id) + { + await using var conn = await _connection.Get(); + var svc = await conn.QuerySingleOrDefaultAsync( + @"select * from ""Paywall"" where ""File"" = :file", new {file = id}); + if (svc != default) + { + switch (svc.Service) + { + case PaymentServices.Strike: + { + var handle = + await conn.ExecuteScalarAsync( + @"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; + } + + /// + public ValueTask> Get(Guid[] ids) + { + throw new NotImplementedException(); + } + + /// + 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(); + } + + /// + 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; } + } +} \ No newline at end of file diff --git a/VoidCat/Services/Paywall/StrikePaywallProvider.cs b/VoidCat/Services/Paywall/StrikePaywallProvider.cs index 546262f..2f5242f 100644 --- a/VoidCat/Services/Paywall/StrikePaywallProvider.cs +++ b/VoidCat/Services/Paywall/StrikePaywallProvider.cs @@ -6,31 +6,31 @@ using VoidCat.Services.Strike; namespace VoidCat.Services.Paywall; +/// public class StrikePaywallProvider : IPaywallProvider { private readonly ILogger _logger; private readonly StrikeApi _strike; - private readonly IPaywallStore _store; + private readonly IPaywallOrderStore _orderStore; - public StrikePaywallProvider(ILogger logger, StrikeApi strike, IPaywallStore store) + public StrikePaywallProvider(ILogger logger, StrikeApi strike, IPaywallOrderStore orderStore) { _logger = logger; _strike = strike; - _store = store; + _orderStore = orderStore; } - public async ValueTask CreateOrder(PublicVoidFile file) + /// + public async ValueTask 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; } + /// public async ValueTask 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)!; } } \ No newline at end of file diff --git a/VoidCat/Services/Strike/StrikeApi.cs b/VoidCat/Services/Strike/StrikeApi.cs index 14616e0..3075f0d 100644 --- a/VoidCat/Services/Strike/StrikeApi.cs +++ b/VoidCat/Services/Strike/StrikeApi.cs @@ -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 diff --git a/VoidCat/spa/src/FileEdit.js b/VoidCat/spa/src/FileEdit.js index ddc904d..810f747 100644 --- a/VoidCat/spa/src/FileEdit.js +++ b/VoidCat/spa/src/FileEdit.js @@ -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; } diff --git a/VoidCat/spa/src/LightningPaywall.js b/VoidCat/spa/src/LightningPaywall.js index a8f716b..58f85b0 100644 --- a/VoidCat/spa/src/LightningPaywall.js +++ b/VoidCat/spa/src/LightningPaywall.js @@ -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");