mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-03-11 01:35:22 +01:00
Prune webhook data from database
This commit is contained in:
parent
418b476725
commit
4e03c2523a
21 changed files with 515 additions and 161 deletions
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Data.Common;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
|
@ -21,7 +22,6 @@ namespace BTCPayServer.Abstractions.Contracts
|
|||
}
|
||||
|
||||
public abstract T CreateContext();
|
||||
|
||||
class CustomNpgsqlMigrationsSqlGenerator : NpgsqlMigrationsSqlGenerator
|
||||
{
|
||||
#pragma warning disable EF1001 // Internal EF Core API usage.
|
||||
|
|
|
@ -51,7 +51,8 @@ namespace BTCPayServer.Client
|
|||
{
|
||||
if (message.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
|
||||
{
|
||||
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(await message.Content.ReadAsStringAsync());
|
||||
var aa = await message.Content.ReadAsStringAsync();
|
||||
var err = JsonConvert.DeserializeObject<Models.GreenfieldValidationError[]>(aa);
|
||||
throw new GreenfieldValidationException(err);
|
||||
}
|
||||
if (message.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
|
|
|
@ -51,6 +51,10 @@ namespace BTCPayServer.Client.Models
|
|||
public DateTimeOffset Timestamp { get; set; }
|
||||
[JsonExtensionData]
|
||||
public IDictionary<string, JToken> AdditionalData { get; set; }
|
||||
public bool IsPruned()
|
||||
{
|
||||
return DeliveryId is null;
|
||||
}
|
||||
public T ReadAs<T>()
|
||||
{
|
||||
var str = JsonConvert.SerializeObject(this, DefaultSerializerSettings);
|
||||
|
|
|
@ -5,7 +5,7 @@ using Microsoft.EntityFrameworkCore.Infrastructure;
|
|||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
public class WebhookDeliveryData : IHasBlobUntyped
|
||||
public class WebhookDeliveryData
|
||||
{
|
||||
[Key]
|
||||
[MaxLength(25)]
|
||||
|
@ -17,10 +17,8 @@ namespace BTCPayServer.Data
|
|||
|
||||
[Required]
|
||||
public DateTimeOffset Timestamp { get; set; }
|
||||
[Obsolete("Use Blob2 instead")]
|
||||
public byte[] Blob { get; set; }
|
||||
public string Blob2 { get; set; }
|
||||
|
||||
public string Blob { get; set; }
|
||||
public bool Pruned { get; set; }
|
||||
|
||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||
{
|
||||
|
@ -28,11 +26,11 @@ namespace BTCPayServer.Data
|
|||
.HasOne(o => o.Webhook)
|
||||
.WithMany(a => a.Deliveries).OnDelete(DeleteBehavior.Cascade);
|
||||
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.WebhookId);
|
||||
|
||||
builder.Entity<WebhookDeliveryData>().HasIndex(o => o.Timestamp);
|
||||
if (databaseFacade.IsNpgsql())
|
||||
{
|
||||
builder.Entity<WebhookDeliveryData>()
|
||||
.Property(o => o.Blob2)
|
||||
.Property(o => o.Blob)
|
||||
.HasColumnType("JSONB");
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
using System;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
#nullable disable
|
||||
|
||||
namespace BTCPayServer.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20230529135505_WebhookDeliveriesCleanup")]
|
||||
public partial class WebhookDeliveriesCleanup : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (migrationBuilder.IsNpgsql())
|
||||
{
|
||||
migrationBuilder.Sql("DROP TABLE IF EXISTS \"InvoiceWebhookDeliveries\", \"WebhookDeliveries\";");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WebhookDeliveries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
WebhookId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Timestamp = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
Pruned = table.Column<bool>(type: "BOOLEAN", nullable: false),
|
||||
Blob = table.Column<string>(type: "JSONB", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WebhookDeliveries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_WebhookDeliveries_Webhooks_WebhookId",
|
||||
column: x => x.WebhookId,
|
||||
principalTable: "Webhooks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WebhookDeliveries_WebhookId",
|
||||
table: "WebhookDeliveries",
|
||||
column: "WebhookId");
|
||||
migrationBuilder.Sql("CREATE INDEX \"IX_WebhookDeliveries_Timestamp\" ON \"WebhookDeliveries\"(\"Timestamp\") WHERE \"Pruned\" IS FALSE");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InvoiceWebhookDeliveries",
|
||||
columns: table => new
|
||||
{
|
||||
InvoiceId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
DeliveryId = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InvoiceWebhookDeliveries", x => new { x.InvoiceId, x.DeliveryId });
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceWebhookDeliveries_WebhookDeliveries_DeliveryId",
|
||||
column: x => x.DeliveryId,
|
||||
principalTable: "WebhookDeliveries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_InvoiceWebhookDeliveries_Invoices_InvoiceId",
|
||||
column: x => x.InvoiceId,
|
||||
principalTable: "Invoices",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1019,12 +1019,12 @@ namespace BTCPayServer.Migrations
|
|||
.HasMaxLength(25)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("Blob")
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Blob2")
|
||||
b.Property<string>("Blob")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Pruned")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("Timestamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
@ -1035,6 +1035,8 @@ namespace BTCPayServer.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Timestamp");
|
||||
|
||||
b.HasIndex("WebhookId");
|
||||
|
||||
b.ToTable("WebhookDeliveries");
|
||||
|
|
|
@ -1432,7 +1432,7 @@ namespace BTCPayServer.Tests
|
|||
Assert.False(hook.AutomaticRedelivery);
|
||||
Assert.Equal(fakeServer.ServerUri.AbsoluteUri, hook.Url);
|
||||
}
|
||||
using var tester = CreateServerTester();
|
||||
using var tester = CreateServerTester(newDb: true);
|
||||
using var fakeServer = new FakeServer();
|
||||
await fakeServer.Start();
|
||||
await tester.StartAsync();
|
||||
|
@ -1509,6 +1509,14 @@ namespace BTCPayServer.Tests
|
|||
clientProfile = await user.CreateClient(Policies.CanModifyStoreSettings, Policies.CanCreateInvoice);
|
||||
await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, newDeliveryId);
|
||||
|
||||
|
||||
TestLogs.LogInformation("Can prune deliveries");
|
||||
var cleanup = tester.PayTester.GetService<HostedServices.CleanupWebhookDeliveriesTask>();
|
||||
cleanup.BatchSize = 1;
|
||||
cleanup.PruneAfter = TimeSpan.Zero;
|
||||
await cleanup.Do(default);
|
||||
await AssertHttpError(409, () => clientProfile.RedeliverWebhook(user.StoreId, hook.Id, delivery.Id));
|
||||
|
||||
TestLogs.LogInformation("Testing corner cases");
|
||||
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, "lol", newDeliveryId));
|
||||
Assert.Null(await clientProfile.GetWebhookDeliveryRequest(user.StoreId, hook.Id, "lol"));
|
||||
|
|
|
@ -153,15 +153,24 @@ namespace BTCPayServer.Controllers.Greenfield
|
|||
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
|
||||
if (delivery is null)
|
||||
return WebhookDeliveryNotFound();
|
||||
if (delivery.GetBlob().IsPruned())
|
||||
return WebhookDeliveryPruned();
|
||||
return this.Ok(new JValue(await WebhookSender.Redeliver(deliveryId)));
|
||||
}
|
||||
|
||||
private IActionResult WebhookDeliveryPruned()
|
||||
{
|
||||
return this.CreateAPIError(409, "webhookdelivery-pruned", "This webhook delivery has been pruned, so it can't be redelivered");
|
||||
}
|
||||
|
||||
[HttpGet("~/api/v1/stores/{storeId}/webhooks/{webhookId}/deliveries/{deliveryId}/request")]
|
||||
public async Task<IActionResult> GetDeliveryRequest(string storeId, string webhookId, string deliveryId)
|
||||
{
|
||||
var delivery = await StoreRepository.GetWebhookDelivery(CurrentStoreId, webhookId, deliveryId);
|
||||
if (delivery is null)
|
||||
return WebhookDeliveryNotFound();
|
||||
if (delivery.GetBlob().IsPruned())
|
||||
return WebhookDeliveryPruned();
|
||||
return File(delivery.GetBlob().Request, "application/json");
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -29,12 +30,30 @@ namespace BTCPayServer.Data
|
|||
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
|
||||
public WebhookDeliveryStatus Status { get; set; }
|
||||
public int? HttpCode { get; set; }
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public string ErrorMessage { get; set; }
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
|
||||
public byte[] Request { get; set; }
|
||||
public void Prune()
|
||||
{
|
||||
var request = JObject.Parse(UTF8Encoding.UTF8.GetString(Request));
|
||||
foreach (var prop in request.Properties().ToList())
|
||||
{
|
||||
if (prop.Name == "type")
|
||||
continue;
|
||||
prop.Remove();
|
||||
}
|
||||
Request = UTF8Encoding.UTF8.GetBytes(request.ToString(Formatting.None));
|
||||
}
|
||||
public T ReadRequestAs<T>()
|
||||
{
|
||||
return JsonConvert.DeserializeObject<T>(UTF8Encoding.UTF8.GetString(Request), HostedServices.WebhookSender.DefaultSerializerSettings);
|
||||
}
|
||||
|
||||
public bool IsPruned()
|
||||
{
|
||||
return ReadRequestAs<WebhookEvent>().IsPruned();
|
||||
}
|
||||
}
|
||||
public class WebhookBlob
|
||||
{
|
||||
|
@ -56,11 +75,17 @@ namespace BTCPayServer.Data
|
|||
}
|
||||
public static WebhookDeliveryBlob GetBlob(this WebhookDeliveryData webhook)
|
||||
{
|
||||
return webhook.HasTypedBlob<WebhookDeliveryBlob>().GetBlob(HostedServices.WebhookSender.DefaultSerializerSettings);
|
||||
if (webhook.Blob is null)
|
||||
return null;
|
||||
else
|
||||
return JsonConvert.DeserializeObject<WebhookDeliveryBlob>(webhook.Blob, HostedServices.WebhookSender.DefaultSerializerSettings);
|
||||
}
|
||||
public static void SetBlob(this WebhookDeliveryData webhook, WebhookDeliveryBlob blob)
|
||||
{
|
||||
webhook.HasTypedBlob<WebhookDeliveryBlob>().SetBlob(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
|
||||
if (blob is null)
|
||||
webhook.Blob = null;
|
||||
else
|
||||
webhook.Blob = JsonConvert.SerializeObject(blob, HostedServices.WebhookSender.DefaultSerializerSettings);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ using System.Threading.Tasks;
|
|||
using BTCPayServer.BIP78.Sender;
|
||||
using BTCPayServer.Configuration;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models;
|
||||
|
@ -25,6 +26,7 @@ using BTCPayServer.Services.Wallets;
|
|||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin;
|
||||
using NBitcoin.Payment;
|
||||
|
@ -115,6 +117,14 @@ namespace BTCPayServer
|
|||
return value;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
|
||||
where T : class, IPeriodicTask
|
||||
{
|
||||
services.AddSingleton<T>();
|
||||
services.AddTransient<ScheduledTask>(o => new ScheduledTask(typeof(T), every));
|
||||
return services;
|
||||
}
|
||||
|
||||
public static PaymentMethodId GetpaymentMethodId(this InvoiceCryptoInfo info)
|
||||
{
|
||||
return new PaymentMethodId(info.CryptoCode, PaymentTypes.Parse(info.PaymentType));
|
||||
|
|
61
BTCPayServer/HostedServices/CleanupWebhookDeliveriesTask.cs
Normal file
61
BTCPayServer/HostedServices/CleanupWebhookDeliveriesTask.cs
Normal file
|
@ -0,0 +1,61 @@
|
|||
using System;
|
||||
using Dapper;
|
||||
using System.Data.Common;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class CleanupWebhookDeliveriesTask : IPeriodicTask
|
||||
{
|
||||
public CleanupWebhookDeliveriesTask(ApplicationDbContextFactory dbContextFactory)
|
||||
{
|
||||
DbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public ApplicationDbContextFactory DbContextFactory { get; }
|
||||
public int BatchSize { get; set; } = 500;
|
||||
public TimeSpan PruneAfter { get; set; } = TimeSpan.FromDays(60);
|
||||
|
||||
public async Task Do(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
if (!ctx.Database.IsNpgsql())
|
||||
return;
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
bool pruned = false;
|
||||
int offset = 0;
|
||||
retry:
|
||||
var rows = await conn.QueryAsync<WebhookDeliveryData>(@"
|
||||
SELECT ""Id"", ""Blob""
|
||||
FROM ""WebhookDeliveries""
|
||||
WHERE ((now() - ""Timestamp"") > @PruneAfter) AND ""Pruned"" IS FALSE
|
||||
ORDER BY ""Timestamp""
|
||||
LIMIT @BatchSize OFFSET @offset
|
||||
", new { PruneAfter, BatchSize, offset });
|
||||
|
||||
foreach (var d in rows)
|
||||
{
|
||||
var blob = d.GetBlob();
|
||||
blob.Prune();
|
||||
d.SetBlob(blob);
|
||||
d.Pruned = true;
|
||||
pruned = true;
|
||||
}
|
||||
if (pruned)
|
||||
{
|
||||
pruned = false;
|
||||
await conn.ExecuteAsync("UPDATE \"WebhookDeliveries\" SET \"Blob\"=@Blob::JSONB, \"Pruned\"=@Pruned WHERE \"Id\"=@Id", rows);
|
||||
if (rows.Count() == BatchSize)
|
||||
{
|
||||
offset += BatchSize;
|
||||
goto retry;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
10
BTCPayServer/HostedServices/IPeriodicTask.cs
Normal file
10
BTCPayServer/HostedServices/IPeriodicTask.cs
Normal file
|
@ -0,0 +1,10 @@
|
|||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public interface IPeriodicTask
|
||||
{
|
||||
Task Do(CancellationToken cancellationToken);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class PeriodicTaskLauncherHostedService : IHostedService
|
||||
{
|
||||
public PeriodicTaskLauncherHostedService(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
|
||||
{
|
||||
ServiceProvider = serviceProvider;
|
||||
Logger = loggerFactory.CreateLogger("BTCPayServer.PeriodicTasks");
|
||||
}
|
||||
|
||||
public IServiceProvider ServiceProvider { get; }
|
||||
public ILogger Logger { get; }
|
||||
|
||||
Channel<ScheduledTask> jobs = Channel.CreateBounded<ScheduledTask>(100);
|
||||
CancellationTokenSource cts;
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cts = new CancellationTokenSource();
|
||||
foreach (var task in ServiceProvider.GetServices<ScheduledTask>())
|
||||
jobs.Writer.TryWrite(task);
|
||||
|
||||
loop = Task.WhenAll(Enumerable.Range(0, 3).Select(_ => Loop(cts.Token)).ToArray());
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
Task loop;
|
||||
private async Task Loop(CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var job in jobs.Reader.ReadAllAsync(token))
|
||||
{
|
||||
if (job.NextScheduled <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
var t = (IPeriodicTask)ServiceProvider.GetService(job.PeriodicTaskType);
|
||||
try
|
||||
{
|
||||
await t.Do(token);
|
||||
}
|
||||
catch when (token.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, $"Unhandled error in periodic task {job.PeriodicTaskType.Name}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
job.NextScheduled = DateTimeOffset.UtcNow + job.Every;
|
||||
}
|
||||
}
|
||||
_ = Wait(job, token);
|
||||
}
|
||||
}
|
||||
catch when (token.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Wait(ScheduledTask job, CancellationToken token)
|
||||
{
|
||||
var timeToWait = job.NextScheduled - DateTimeOffset.UtcNow;
|
||||
try
|
||||
{
|
||||
await Task.Delay(timeToWait, token);
|
||||
}
|
||||
catch { }
|
||||
while (await jobs.Writer.WaitToWriteAsync())
|
||||
{
|
||||
if (jobs.Writer.TryWrite(job))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cts?.Cancel();
|
||||
jobs.Writer.TryComplete();
|
||||
if (loop is not null)
|
||||
await loop;
|
||||
}
|
||||
}
|
||||
}
|
16
BTCPayServer/HostedServices/ScheduledTask.cs
Normal file
16
BTCPayServer/HostedServices/ScheduledTask.cs
Normal file
|
@ -0,0 +1,16 @@
|
|||
using System;
|
||||
|
||||
namespace BTCPayServer.HostedServices
|
||||
{
|
||||
public class ScheduledTask
|
||||
{
|
||||
public ScheduledTask(Type periodicTypeTask, TimeSpan every)
|
||||
{
|
||||
PeriodicTaskType = periodicTypeTask;
|
||||
Every = every;
|
||||
}
|
||||
public Type PeriodicTaskType { get; set; }
|
||||
public TimeSpan Every { get; set; } = TimeSpan.FromMinutes(5.0);
|
||||
public DateTimeOffset NextScheduled { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
|
@ -105,6 +105,8 @@ namespace BTCPayServer.HostedServices
|
|||
var newDeliveryBlob = new WebhookDeliveryBlob();
|
||||
newDeliveryBlob.Request = oldDeliveryBlob.Request;
|
||||
var webhookEvent = newDeliveryBlob.ReadRequestAs<WebhookEvent>();
|
||||
if (webhookEvent.IsPruned())
|
||||
return null;
|
||||
webhookEvent.DeliveryId = newDelivery.Id;
|
||||
webhookEvent.WebhookId = webhookDelivery.Webhook.Id;
|
||||
// if we redelivered a redelivery, we still want the initial delivery here
|
||||
|
|
|
@ -350,6 +350,9 @@ namespace BTCPayServer.Hosting
|
|||
services.AddSingleton<HostedServices.WebhookSender>();
|
||||
services.AddSingleton<IHostedService, WebhookSender>(o => o.GetRequiredService<WebhookSender>());
|
||||
services.AddSingleton<IHostedService, StoreEmailRuleProcessorSender>();
|
||||
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
|
||||
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
|
||||
|
||||
services.AddHttpClient(WebhookSender.OnionNamedClient)
|
||||
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
|
||||
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
|
||||
|
|
|
@ -213,6 +213,9 @@ namespace BTCPayServer.Hosting
|
|||
{
|
||||
var typeMapping = t.EntityTypeMappings.Single();
|
||||
var query = (IQueryable<object>)otherContext.GetType().GetMethod("Set", new Type[0])!.MakeGenericMethod(typeMapping.EntityType.ClrType).Invoke(otherContext, null)!;
|
||||
if (t.Name == "WebhookDeliveries" ||
|
||||
t.Name == "InvoiceWebhookDeliveries")
|
||||
continue;
|
||||
Logger.LogInformation($"Migrating table: " + t.Name);
|
||||
List<PropertyInfo> datetimeProperties = new List<PropertyInfo>();
|
||||
foreach (var col in t.Columns)
|
||||
|
|
|
@ -22,13 +22,16 @@ namespace BTCPayServer.Models.StoreViewModels
|
|||
Success = blob.Status == WebhookDeliveryStatus.HttpSuccess;
|
||||
ErrorMessage = blob.ErrorMessage ?? "Success";
|
||||
Time = s.Timestamp;
|
||||
Type = blob.ReadRequestAs<WebhookEvent>().Type;
|
||||
var evt = blob.ReadRequestAs<WebhookEvent>();
|
||||
Type = evt.Type;
|
||||
Pruned = evt.IsPruned();
|
||||
WebhookId = s.Id;
|
||||
PayloadUrl = s.Webhook?.GetBlob().Url;
|
||||
}
|
||||
public string Id { get; set; }
|
||||
public DateTimeOffset Time { get; set; }
|
||||
public WebhookEventType Type { get; private set; }
|
||||
public bool Pruned { get; set; }
|
||||
public string WebhookId { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string ErrorMessage { get; set; }
|
||||
|
|
|
@ -9,16 +9,26 @@
|
|||
@section PageHeadContent {
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<style>
|
||||
#posData td > table:last-child { margin-bottom: 0 !important; }
|
||||
#posData table > tbody > tr:first-child > td > h4 { margin-top: 0 !important; }
|
||||
.invoice-information { display: flex; flex-wrap: wrap; gap: var(--btcpay-space-xl) var(--btcpay-space-xxl); }
|
||||
#posData td > table:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#posData table > tbody > tr:first-child > td > h4 {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.invoice-information {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--btcpay-space-xl) var(--btcpay-space-xxl);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<script>
|
||||
const alertClasses = { "Settled (marked)": 'success', "Invalid (marked)": 'danger' }
|
||||
|
||||
|
||||
function changeInvoiceState(invoiceId, newState) {
|
||||
console.log(invoiceId, newState)
|
||||
const toggleButton = $("#markStatusDropdownMenuButton");
|
||||
|
@ -34,13 +44,13 @@
|
|||
alert("Invoice state update failed");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
delegate('click', '[data-change-invoice-status-button]', e => {
|
||||
const button = e.target.closest('[data-change-invoice-status-button]')
|
||||
const { id, status } = button.dataset
|
||||
changeInvoiceState(id, status)
|
||||
})
|
||||
|
||||
|
||||
const handleRefundResponse = async response => {
|
||||
const modalBody = document.querySelector('#RefundModal .modal-body')
|
||||
if (response.ok && response.redirected) {
|
||||
|
@ -51,14 +61,14 @@
|
|||
modalBody.innerHTML = '<div class="alert alert-danger" role="alert">Failed to load refund options.</div>'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
delegate('click', '#IssueRefund', async e => {
|
||||
e.preventDefault()
|
||||
const { href: url } = e.target
|
||||
const response = await fetch(url)
|
||||
await handleRefundResponse(response)
|
||||
})
|
||||
|
||||
|
||||
delegate('submit', '#RefundForm', async e => {
|
||||
e.preventDefault()
|
||||
const form = e.target
|
||||
|
@ -67,7 +77,7 @@
|
|||
const response = await fetch(url, { method, body })
|
||||
await handleRefundResponse(response)
|
||||
})
|
||||
|
||||
|
||||
function checkCustomAmount() {
|
||||
const $refundForm = document.getElementById('RefundForm');
|
||||
const currency = $refundForm.querySelector('#CustomCurrency').value;
|
||||
|
@ -77,7 +87,7 @@
|
|||
const fiatAmount = parseFloat($refundForm.querySelector('#FiatAmount').value);
|
||||
const cryptoAmountNow = parseFloat($refundForm.querySelector('#CryptoAmountNow').value);
|
||||
const cryptoAmountThen = parseFloat($refundForm.querySelector('#CryptoAmountThen').value);
|
||||
|
||||
|
||||
let isOverpaying = false;
|
||||
if (currency === cryptoCode) {
|
||||
isOverpaying = amount > Math.max(cryptoAmountNow, cryptoAmountThen);
|
||||
|
@ -88,7 +98,7 @@
|
|||
}
|
||||
delegate('change', '#CustomAmount', checkCustomAmount);
|
||||
delegate('change', '#CustomCurrency', checkCustomAmount);
|
||||
|
||||
|
||||
function updateSubtractPercentageResult() {
|
||||
const $refundForm = document.getElementById('RefundForm');
|
||||
const $result = document.getElementById('SubtractPercentageResult');
|
||||
|
@ -97,7 +107,7 @@
|
|||
$result.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const refundOption = $selectedRefundOption.value;
|
||||
const cryptoCode = $refundForm.querySelector('#CryptoCode').value;
|
||||
const customCurrency = $refundForm.querySelector('#CustomCurrency').value;
|
||||
|
@ -111,7 +121,7 @@
|
|||
const invoiceDivisibility = parseInt($refundForm.querySelector('#InvoiceDivisibility').value);
|
||||
const percentage = parseFloat($refundForm.querySelector('#SubtractPercentage').value);
|
||||
const isInvalid = isNaN(percentage);
|
||||
|
||||
|
||||
let amount = null;
|
||||
let currency = cryptoCode;
|
||||
let divisibility = cryptoDivisibility;
|
||||
|
@ -136,12 +146,12 @@
|
|||
divisibility = customCurrency === invoiceCurrency ? invoiceDivisibility : cryptoDivisibility;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (amount == null || isInvalid) {
|
||||
$result.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
console.log({ refundOption, isInvalid, amount, currency })
|
||||
const reduceByAmount = (amount * (percentage / 100));
|
||||
const refundAmount = (amount - reduceByAmount).toFixed(divisibility);
|
||||
|
@ -163,7 +173,7 @@
|
|||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="RefundTitle">Issue Refund</h4>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
|
||||
<vc:icon symbol="close"/>
|
||||
<vc:icon symbol="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
@ -211,7 +221,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<partial name="_StatusMessage"/>
|
||||
<partial name="_StatusMessage" />
|
||||
|
||||
<div class="invoice-details">
|
||||
<div class="invoice-information mb-5">
|
||||
|
@ -341,58 +351,59 @@
|
|||
</table>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-5">
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
|
||||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) ||
|
||||
Model.TypedMetadata.TaxIncluded is not null)
|
||||
{
|
||||
<div>
|
||||
<h3 class="mb-3">
|
||||
<span>Product Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<table class="table mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Item code</th>
|
||||
<td>@Model.TypedMetadata.ItemCode</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Item Description</th>
|
||||
<td>@Model.TypedMetadata.ItemDesc</td>
|
||||
</tr>
|
||||
}
|
||||
@if (Model.TaxIncluded is not null)
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Tax Included</th>
|
||||
<td>@Model.TaxIncluded</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (Model.TypedMetadata.BuyerName is not null ||
|
||||
Model.TypedMetadata.BuyerEmail is not null ||
|
||||
Model.TypedMetadata.BuyerPhone is not null ||
|
||||
Model.TypedMetadata.BuyerAddress1 is not null ||
|
||||
Model.TypedMetadata.BuyerAddress2 is not null ||
|
||||
Model.TypedMetadata.BuyerCity is not null ||
|
||||
Model.TypedMetadata.BuyerState is not null ||
|
||||
Model.TypedMetadata.BuyerCountry is not null ||
|
||||
Model.TypedMetadata.BuyerZip is not null)
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode) ||
|
||||
!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc) ||
|
||||
Model.TypedMetadata.TaxIncluded is not null)
|
||||
{
|
||||
<div>
|
||||
<h3 class="mb-3"><span>Buyer Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<h3 class="mb-3">
|
||||
<span>Product Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<table class="table mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemCode))
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Item code</th>
|
||||
<td>@Model.TypedMetadata.ItemCode</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.TypedMetadata.ItemDesc))
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Item Description</th>
|
||||
<td>@Model.TypedMetadata.ItemDesc</td>
|
||||
</tr>
|
||||
}
|
||||
@if (Model.TaxIncluded is not null)
|
||||
{
|
||||
<tr>
|
||||
<th class="fw-semibold">Tax Included</th>
|
||||
<td>@Model.TaxIncluded</td>
|
||||
</tr>
|
||||
}
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (Model.TypedMetadata.BuyerName is not null ||
|
||||
Model.TypedMetadata.BuyerEmail is not null ||
|
||||
Model.TypedMetadata.BuyerPhone is not null ||
|
||||
Model.TypedMetadata.BuyerAddress1 is not null ||
|
||||
Model.TypedMetadata.BuyerAddress2 is not null ||
|
||||
Model.TypedMetadata.BuyerCity is not null ||
|
||||
Model.TypedMetadata.BuyerState is not null ||
|
||||
Model.TypedMetadata.BuyerCountry is not null ||
|
||||
Model.TypedMetadata.BuyerZip is not null)
|
||||
{
|
||||
<div>
|
||||
<h3 class="mb-3">
|
||||
<span>Buyer Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<table class="table mb-0">
|
||||
@if (Model.TypedMetadata.BuyerName is not null)
|
||||
{
|
||||
|
@ -465,12 +476,12 @@
|
|||
@if (Model.AdditionalData.Any())
|
||||
{
|
||||
<div>
|
||||
<h3 class="mb-3">
|
||||
<span>Additional Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<h3 class="mb-3">
|
||||
<span>Additional Information</span>
|
||||
<a href="https://docs.btcpayserver.org/Development/InvoiceMetadata/" target="_blank" rel="noreferrer noopener">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<partial name="PosData" model="(Model.AdditionalData, 1)" />
|
||||
</div>
|
||||
}
|
||||
|
@ -478,7 +489,7 @@
|
|||
</div>
|
||||
|
||||
<h3 class="mb-3">Invoice Summary</h3>
|
||||
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)"/>
|
||||
<partial name="ListInvoicesPaymentsPartial" model="(Model, true)" />
|
||||
|
||||
@if (Model.Deliveries.Any())
|
||||
{
|
||||
|
@ -487,46 +498,49 @@
|
|||
<div class="table-responsive-xl">
|
||||
<table class="table table-hover mb-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Url</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>ID</th>
|
||||
<th>Type</th>
|
||||
<th>Url</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var delivery in Model.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<form asp-action="RedeliverWebhook"
|
||||
asp-route-storeId="@Model.StoreId"
|
||||
asp-route-invoiceId="@Model.Id"
|
||||
asp-route-deliveryId="@delivery.Id"
|
||||
method="post">
|
||||
@foreach (var delivery in Model.Deliveries)
|
||||
{
|
||||
<tr>
|
||||
<form asp-action="RedeliverWebhook"
|
||||
asp-route-storeId="@Model.StoreId"
|
||||
asp-route-invoiceId="@Model.Id"
|
||||
asp-route-deliveryId="@delivery.Id"
|
||||
method="post">
|
||||
<td>
|
||||
<span>
|
||||
@if (delivery.Success)
|
||||
{
|
||||
@if (delivery.Success)
|
||||
{
|
||||
<span class="fa fa-check text-success" title="Success"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
|
||||
}
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
@if (!delivery.Pruned)
|
||||
{
|
||||
<span>
|
||||
<a asp-action="WebhookDelivery"
|
||||
asp-route-invoiceId="@Model.Id"
|
||||
asp-route-deliveryId="@delivery.Id"
|
||||
class="delivery-content"
|
||||
target="_blank">
|
||||
@delivery.Id
|
||||
asp-route-invoiceId="@Model.Id"
|
||||
asp-route-deliveryId="@delivery.Id"
|
||||
class="delivery-content"
|
||||
target="_blank">
|
||||
@delivery.Id
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<span>@delivery.Type</span>
|
||||
|
@ -538,24 +552,26 @@
|
|||
</td>
|
||||
<td>
|
||||
<span>
|
||||
@delivery.Time.ToBrowserDate()
|
||||
@delivery.Time.ToBrowserDate()
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (!delivery.Pruned) {
|
||||
<button id="#redeliver-@delivery.Id"
|
||||
type="submit"
|
||||
class="btn btn-link p-0 redeliver">
|
||||
Redeliver
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</form>
|
||||
</tr>
|
||||
}
|
||||
</form>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
}
|
||||
@if ((Model.Refunds?.Count ?? 0) > 0)
|
||||
{
|
||||
|
@ -564,36 +580,36 @@
|
|||
<div class="table-responsive-xl">
|
||||
<table class="table table-hover mb-5">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pull Payment</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Pull Payment</th>
|
||||
<th>Amount</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var refund in Model.Refunds)
|
||||
{
|
||||
var blob = refund.PullPaymentData.GetBlob();
|
||||
<tr>
|
||||
@foreach (var refund in Model.Refunds)
|
||||
{
|
||||
var blob = refund.PullPaymentData.GetBlob();
|
||||
<tr>
|
||||
|
||||
<td>
|
||||
<span>
|
||||
<a asp-action="ViewPullPayment" asp-controller="UIPullPayment"
|
||||
asp-route-pullPaymentId="@refund.PullPaymentDataId"
|
||||
class="delivery-content"
|
||||
target="_blank">
|
||||
@refund.PullPaymentData.Id
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>@blob.Limit @blob.Currency</span>
|
||||
</td>
|
||||
<td>
|
||||
<span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<td>
|
||||
<span>
|
||||
<a asp-action="ViewPullPayment" asp-controller="UIPullPayment"
|
||||
asp-route-pullPaymentId="@refund.PullPaymentDataId"
|
||||
class="delivery-content"
|
||||
target="_blank">
|
||||
@refund.PullPaymentData.Id
|
||||
</a>
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>@blob.Limit @blob.Currency</span>
|
||||
</td>
|
||||
<td>
|
||||
<span> @refund.PullPaymentData.StartDate.ToBrowserDate() </span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@ -603,19 +619,19 @@
|
|||
<h3 class="mb-0">Events</h3>
|
||||
<table class="table table-hover mt-3 mb-4">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var evt in Model.Events)
|
||||
{
|
||||
<tr class="text-@evt.GetCssClass()">
|
||||
<td>@evt.Timestamp.ToBrowserDate()</td>
|
||||
<td>@evt.Message</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (var evt in Model.Events)
|
||||
{
|
||||
<tr class="text-@evt.GetCssClass()">
|
||||
<td>@evt.Timestamp.ToBrowserDate()</td>
|
||||
<td>@evt.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
{
|
||||
<span class="d-flex align-items-center fa fa-times text-danger" title="@delivery.ErrorMessage"></span>
|
||||
}
|
||||
@if (!delivery.Pruned) {
|
||||
<span class="ms-3">
|
||||
<a asp-action="WebhookDelivery"
|
||||
asp-route-storeId="@this.Context.GetRouteValue("storeId")"
|
||||
|
@ -113,6 +114,7 @@
|
|||
@delivery.Id
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
<span class="d-flex align-items-center">
|
||||
<strong class="d-flex align-items-center text-muted small">
|
||||
|
|
|
@ -397,6 +397,9 @@
|
|||
},
|
||||
"404": {
|
||||
"description": "The delivery does not exists."
|
||||
},
|
||||
"409": {
|
||||
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
|
@ -459,6 +462,9 @@
|
|||
},
|
||||
"404": {
|
||||
"description": "The delivery does not exists."
|
||||
},
|
||||
"409": {
|
||||
"description": "`webhookdelivery-pruned`: This webhook delivery has been pruned, so it can't be redelivered."
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
|
|
Loading…
Add table
Reference in a new issue