diff --git a/NostrStreamer/Controllers/NostrController.cs b/NostrStreamer/Controllers/NostrController.cs index ac65f47..bf0714b 100644 --- a/NostrStreamer/Controllers/NostrController.cs +++ b/NostrStreamer/Controllers/NostrController.cs @@ -28,7 +28,8 @@ public class NostrController : Controller private readonly ILogger _logger; private readonly PushSender _pushSender; - public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, UserService userService, + public NostrController(StreamerContext db, Config config, StreamManagerFactory streamManager, + UserService userService, IClipService clipService, ILogger logger, PushSender pushSender) { _db = db; @@ -185,7 +186,8 @@ public class NostrController : Controller } [HttpPost("clip/{streamId:guid}/{tempClipId:guid}")] - public async Task MakeClip([FromRoute] Guid streamId, [FromRoute] Guid tempClipId, [FromQuery] float start, + public async Task MakeClip([FromRoute] Guid streamId, [FromRoute] Guid tempClipId, + [FromQuery] float start, [FromQuery] float length) { var pk = GetPubKey(); @@ -265,7 +267,7 @@ public class NostrController : Controller var existing = await _db.PushSubscriptions.FirstOrDefaultAsync(a => a.Key == sub.Key); if (existing != default) { - return Json(new {id = existing.Id}); + return Json(new { id = existing.Id }); } var newId = Guid.NewGuid(); @@ -296,7 +298,7 @@ public class NostrController : Controller var sub = await _db.PushSubscriptionTargets .Join(_db.PushSubscriptions, a => a.SubscriberPubkey, b => b.Pubkey, - (a, b) => new {a.SubscriberPubkey, a.TargetPubkey, b.Auth}) + (a, b) => new { a.SubscriberPubkey, a.TargetPubkey, b.Auth }) .Where(a => a.SubscriberPubkey == userPubkey && a.Auth == auth) .Select(a => a.TargetPubkey) .ToListAsync(); @@ -351,6 +353,22 @@ public class NostrController : Controller return Accepted(); } + [HttpPost("withdraw")] + public async Task WithdrawFunds([FromQuery] string invoice) + { + if (string.IsNullOrEmpty(invoice)) return BadRequest(); + + var userPubkey = GetPubKey(); + if (string.IsNullOrEmpty(userPubkey)) + return BadRequest(); + + var (fee, preimage) = await _userService.WithdrawFunds(userPubkey, invoice); + return Json(new + { + fee, preimage + }); + } + private async Task GetUser() { var pk = GetPubKey(); @@ -362,4 +380,4 @@ public class NostrController : Controller var claim = HttpContext.User.Claims.FirstOrDefault(a => a.Type == ClaimTypes.Name); return claim!.Value; } -} +} \ No newline at end of file diff --git a/NostrStreamer/Database/Payment.cs b/NostrStreamer/Database/Payment.cs index 1fa75ae..47f7827 100644 --- a/NostrStreamer/Database/Payment.cs +++ b/NostrStreamer/Database/Payment.cs @@ -11,6 +11,9 @@ public class Payment public bool IsPaid { get; set; } + /// + /// Payment amount in sats!! + /// public ulong Amount { get; init; } public DateTime Created { get; init; } = DateTime.UtcNow; @@ -24,5 +27,6 @@ public enum PaymentType { Topup = 0, Zap = 1, - Credit = 2 + Credit = 2, + Withdrawal = 3, } \ No newline at end of file diff --git a/NostrStreamer/Services/Background/RecordingDeleter.cs b/NostrStreamer/Services/Background/RecordingDeleter.cs index 5ed47ba..84f560e 100644 --- a/NostrStreamer/Services/Background/RecordingDeleter.cs +++ b/NostrStreamer/Services/Background/RecordingDeleter.cs @@ -67,4 +67,17 @@ public class RecordingDeleter : BackgroundService await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken); } } + + private async Task DeleteBroken(StreamerContext db, IDvrStore dvrStore, CancellationToken stoppingToken) + { + var olderThan = DateTime.UtcNow.Subtract(TimeSpan.FromDays(_config.RetainRecordingsDays)); + + var toDelete = await db.Streams + .AsNoTracking() + .Where(a => a.Starts < olderThan) + .Select(a => a.Id) + .ToListAsync(cancellationToken: stoppingToken); + + _logger.LogInformation("Starting (broken) delete of {n:###0} stream recordings", toDelete.Count); + } } \ No newline at end of file diff --git a/NostrStreamer/Services/UserService.cs b/NostrStreamer/Services/UserService.cs index a5b0de1..8d53fd0 100644 --- a/NostrStreamer/Services/UserService.cs +++ b/NostrStreamer/Services/UserService.cs @@ -1,7 +1,9 @@ using System.Security.Cryptography; using System.Text; +using BTCPayServer.Lightning; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; +using NBitcoin; using Nostr.Client.Utils; using NostrStreamer.ApiModel; using NostrStreamer.Database; @@ -79,6 +81,90 @@ public class UserService return invoice.PaymentRequest; } + public async Task<(long Fee, string Preimage)> WithdrawFunds(string pubkey, string invoice) + { + var user = await GetUser(pubkey); + if (user == default) throw new Exception("No user found"); + + var maxOut = await MaxWithdrawalAmount(pubkey); + var pr = BOLT11PaymentRequest.Parse(invoice, invoice.StartsWith("lnbc1") ? Network.Main : Network.RegTest); + if (pr.MinimumAmount == 0) + { + throw new Exception("0 amount invoice not supported"); + } + + if (maxOut <= pr.MinimumAmount.MilliSatoshi) + { + throw new Exception("Not enough balance to pay invoice"); + } + + // start by taking balance + var rHash = pr.PaymentHash!.ToString(); + await using (var tx = await _db.Database.BeginTransactionAsync()) + { + await _db.Users + .Where(a => a.PubKey == pubkey) + .ExecuteUpdateAsync(p => p.SetProperty(o => o.Balance, b => b.Balance - pr.MinimumAmount.MilliSatoshi)); + _db.Payments.Add(new() + { + PubKey = pubkey, + Invoice = invoice, + Type = PaymentType.Withdrawal, + PaymentHash = rHash, + Amount = (ulong)pr.MinimumAmount.MilliSatoshi / 1000, + }); + + await tx.CommitAsync(); + } + + try + { + const double feeMax = 0.005; // 0.5% max fee + var result = await _lnd.SendPayment(invoice, (long)(pr.MinimumAmount.MilliSatoshi * feeMax)); + if (result?.Status is Lnrpc.Payment.Types.PaymentStatus.Succeeded) + { + await _db.Payments + .Where(a => a.PaymentHash == rHash) + .ExecuteUpdateAsync(o => o.SetProperty(v => v.IsPaid, true) + .SetProperty(v => v.Amount, b => b.Amount + (ulong)result.FeeSat)); + return (result.FeeMsat, result.PaymentPreimage); + } + + throw new Exception("Payment failed"); + } + catch + { + // return balance on error + await _db.Users + .Where(a => a.PubKey == pubkey) + .ExecuteUpdateAsync(p => p.SetProperty(o => o.Balance, b => b.Balance + pr.MinimumAmount.MilliSatoshi)); + throw; + } + } + + public async Task> ListPayments(string pubkey) + { + return await _db.Payments + .Where(a => a.PubKey == pubkey && a.IsPaid) + .ToListAsync(); + } + + public async Task MaxWithdrawalAmount(string pubkey) + { + var credit = 1000 * await _db.Payments + .Where(a => a.PubKey == pubkey && + a.IsPaid && + a.Type == PaymentType.Credit) + .SumAsync(a => (long)a.Amount); + + var balance = await _db.Users + .Where(a => a.PubKey == pubkey) + .Select(a => a.Balance) + .FirstAsync(); + + return Math.Max(0, balance - credit); + } + public async Task GetUser(string pubkey) { return await _db.Users.AsNoTracking() @@ -124,4 +210,4 @@ public class UserService .SetProperty(v => v.ContentWarning, req.ContentWarning) .SetProperty(v => v.Goal, req.Goal)); } -} +} \ No newline at end of file