diff --git a/BTCPayServer.Client/BTCPayServerClient.LightningAddresses.cs b/BTCPayServer.Client/BTCPayServerClient.LightningAddresses.cs new file mode 100644 index 000000000..524c671ca --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.LightningAddresses.cs @@ -0,0 +1,48 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client +{ + public partial class BTCPayServerClient + { + public virtual async Task GetStoreLightningAddresses(string storeId, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/lightning-addresses", + method: HttpMethod.Get), token); + return await HandleResponse(response); + } + + public virtual async Task GetStoreLightningAddress(string storeId, string username, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/lightning-addresses/{username}", + method: HttpMethod.Get), token); + return await HandleResponse(response); + } + + public virtual async Task RemoveStoreLightningAddress(string storeId, string username, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/lightning-addresses/{username}", + method: HttpMethod.Delete), token); + await HandleResponse(response); + } + + public virtual async Task AddOrUpdateStoreLightningAddress(string storeId, + string username, LightningAddressData data, + CancellationToken token = default) + { + var response = await _httpClient.SendAsync( + CreateHttpRequest($"api/v1/stores/{storeId}/lightning-addresses/{username}", + method: HttpMethod.Post, bodyPayload: data), token); + + return await HandleResponse(response); + } + } +} diff --git a/BTCPayServer.Client/Models/LightningAddressData.cs b/BTCPayServer.Client/Models/LightningAddressData.cs new file mode 100644 index 000000000..b72ecdad9 --- /dev/null +++ b/BTCPayServer.Client/Models/LightningAddressData.cs @@ -0,0 +1,10 @@ +namespace BTCPayServer.Client.Models; + +public class LightningAddressData +{ + public string Username { get; set; } + public string? CurrencyCode { get; set; } + public decimal? Min { get; set; } + public decimal? Max { get; set; } + +} diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index f68079219..3f7c54e52 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -2961,6 +2961,50 @@ namespace BTCPayServer.Tests } + [Fact(Timeout =TestTimeout)] + [Trait("Integration", "Integration")] + public async Task StoreLightningAddressesAPITests() + { + using var tester = CreateServerTester(); + await tester.StartAsync(); + var admin = tester.NewAccount(); + await admin.GrantAccessAsync(true); + var adminClient = await admin.CreateClient(Policies.Unrestricted); + var store = await adminClient.GetStore(admin.StoreId); + + Assert.Empty(await adminClient.GetStorePaymentMethods(store.Id)); + var store2 = (await adminClient.CreateStore(new CreateStoreRequest() {Name = "test2"})).Id; + var address1 = Guid.NewGuid().ToString("n").Substring(0, 8); + var address2 = Guid.NewGuid().ToString("n").Substring(0, 8); + + Assert.Empty(await adminClient.GetStoreLightningAddresses(store.Id)); + Assert.Empty(await adminClient.GetStoreLightningAddresses(store2)); + await adminClient.AddOrUpdateStoreLightningAddress(store.Id, address1, new LightningAddressData()); + + await adminClient.AddOrUpdateStoreLightningAddress(store.Id, address1, new LightningAddressData() + { + Max = 1 + }); + await AssertAPIError("username-already-used", async () => + { + await adminClient.AddOrUpdateStoreLightningAddress(store2, address1, new LightningAddressData()); + }); + Assert.Equal(1,Assert.Single(await adminClient.GetStoreLightningAddresses(store.Id)).Max); + Assert.Empty(await adminClient.GetStoreLightningAddresses(store2)); + + await adminClient.AddOrUpdateStoreLightningAddress(store2, address2, new LightningAddressData()); + + Assert.Single(await adminClient.GetStoreLightningAddresses(store.Id)); + Assert.Single(await adminClient.GetStoreLightningAddresses(store2)); + await AssertHttpError(404, async () => + { + await adminClient.RemoveStoreLightningAddress(store2, address1); + }); + await adminClient.RemoveStoreLightningAddress(store2, address2); + + Assert.Empty(await adminClient.GetStoreLightningAddresses(store2)); + } + [Fact(Timeout = 60 * 2 * 1000)] [Trait("Integration", "Integration")] public async Task StoreUsersAPITest() diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoreLightningAddressesController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoreLightningAddressesController.cs new file mode 100644 index 000000000..7df7b09da --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoreLightningAddressesController.cs @@ -0,0 +1,96 @@ +#nullable enable +using System.Linq; +using System.Net; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Client; +using BTCPayServer.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using AuthenticationSchemes = BTCPayServer.Abstractions.Constants.AuthenticationSchemes; +using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData; + +namespace BTCPayServer.Controllers.Greenfield +{ + [ApiController] + [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + public class GreenfieldStoreLightningAddressesController : ControllerBase + { + private readonly LightningAddressService _lightningAddressService; + + public GreenfieldStoreLightningAddressesController( + LightningAddressService lightningAddressService) + { + _lightningAddressService = lightningAddressService; + } + + private LightningAddressData ToModel(BTCPayServer.Data.LightningAddressData data) + { + var blob = data.Blob.GetBlob(); + return new LightningAddressData() + { + Username = data.Username, Max = blob.Max, Min = blob.Min, CurrencyCode = blob.CurrencyCode + }; + } + + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/lightning-addresses")] + public async Task GetStoreLightningAddresses(string storeId) + { + return Ok((await _lightningAddressService.Get(new LightningAddressQuery() {StoreIds = new[] {storeId}})) + .Select(ToModel).ToArray()); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/lightning-addresses/{username}")] + public async Task RemoveStoreLightningAddress(string storeId, string username) + { + if (await _lightningAddressService.Remove(username, storeId)) + { + return Ok(); + } + return + this.CreateAPIError(404, "lightning-address-not-found", "The lightning address was not present."); + + } + + [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpGet("~/api/v1/stores/{storeId}/lightning-addresses/{username}")] + public async Task GetStoreLightningAddress(string storeId, string username) + { + var res = await _lightningAddressService.Get(new LightningAddressQuery() + { + Usernames = new[] {username}, StoreIds = new[] {storeId}, + }); + return res?.Any() is true ? Ok(ToModel(res.First())) : this.CreateAPIError(404, "lightning-address-not-found", "The lightning address was not present."); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/lightning-addresses/{username}")] + public async Task AddOrUpdateStoreLightningAddress( + string storeId, string username, LightningAddressData data) + { + if (data.Min <= 0) + { + ModelState.AddModelError(nameof(data.Min), "Minimum must be greater than 0 if provided."); + return this.CreateValidationError(ModelState); + } + + if (await _lightningAddressService.Set(new Data.LightningAddressData() + { + StoreDataId = storeId, + Username = username, + Blob = new LightningAddressDataBlob() + { + Max = data.Max, Min = data.Min, CurrencyCode = data.CurrencyCode + }.SerializeBlob() + })) + { + return await GetStoreLightningAddress(storeId, username); + } + + return this.CreateAPIError((int)HttpStatusCode.BadRequest, "username-already-used", + "The username is already in use by another store."); + } + } +} diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 1bcdfa746..ae48cfdef 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -26,6 +26,7 @@ using NBXplorer.Models; using Newtonsoft.Json.Linq; using InvoiceData = BTCPayServer.Client.Models.InvoiceData; using Language = BTCPayServer.Client.Models.Language; +using LightningAddressData = BTCPayServer.Client.Models.LightningAddressData; using NotificationData = BTCPayServer.Client.Models.NotificationData; using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData; using PayoutData = BTCPayServer.Client.Models.PayoutData; @@ -1232,5 +1233,27 @@ namespace BTCPayServer.Controllers.Greenfield { return GetFromActionResult(await GetController().GetStorePayout(storeId, payoutId)); } + + public override async Task GetStoreLightningAddresses(string storeId, + CancellationToken token = default) + { + return GetFromActionResult(await GetController().GetStoreLightningAddresses(storeId)); + } + + public override async Task GetStoreLightningAddress(string storeId, string username, CancellationToken token = default) + { + return GetFromActionResult(await GetController().GetStoreLightningAddress(storeId, username)); + } + + public override async Task AddOrUpdateStoreLightningAddress(string storeId, string username, LightningAddressData data, + CancellationToken token = default) + { + return GetFromActionResult(await GetController().AddOrUpdateStoreLightningAddress(storeId, username, data)); + } + + public override async Task RemoveStoreLightningAddress(string storeId, string username, CancellationToken token = default) + { + HandleActionResult(await GetController().RemoveStoreLightningAddress(storeId, username)); + } } } diff --git a/BTCPayServer/Controllers/LightningAddressService.cs b/BTCPayServer/Controllers/LightningAddressService.cs index bd218ea6f..437b5759c 100644 --- a/BTCPayServer/Controllers/LightningAddressService.cs +++ b/BTCPayServer/Controllers/LightningAddressService.cs @@ -60,8 +60,9 @@ public class LightningAddressService public async Task Set(LightningAddressData data) { + data.Username = NormalizeUsername(data.Username); await using var context = _applicationDbContextFactory.CreateContext(); - var result = (await GetCore(context, new LightningAddressQuery() { Usernames = new[] { data.Username } })) + var result = (await GetCore(context, new LightningAddressQuery() { Usernames = new[] { data.Username} })) .FirstOrDefault(); if (result is not null) { @@ -73,7 +74,6 @@ public class LightningAddressService context.Remove(result); } - data.Username = NormalizeUsername(data.Username); await context.AddAsync(data); await context.SaveChangesAsync(); _memoryCache.Remove(GetKey(data.Username)); @@ -82,6 +82,7 @@ public class LightningAddressService public async Task Remove(string username, string? storeId = null) { + username = NormalizeUsername(username); await using var context = _applicationDbContextFactory.CreateContext(); var x = (await GetCore(context, new LightningAddressQuery() { Usernames = new[] { username } })).FirstOrDefault(); if (x is null) diff --git a/BTCPayServer/Controllers/UILNURLController.cs b/BTCPayServer/Controllers/UILNURLController.cs index d838a2872..3690a98b0 100644 --- a/BTCPayServer/Controllers/UILNURLController.cs +++ b/BTCPayServer/Controllers/UILNURLController.cs @@ -30,6 +30,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Routing; using NBitcoin; using Newtonsoft.Json; +using LightningAddressData = BTCPayServer.Data.LightningAddressData; using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest; namespace BTCPayServer diff --git a/BTCPayServer/Hosting/MigrationStartupTask.cs b/BTCPayServer/Hosting/MigrationStartupTask.cs index 684ec073d..55c31a2ed 100644 --- a/BTCPayServer/Hosting/MigrationStartupTask.cs +++ b/BTCPayServer/Hosting/MigrationStartupTask.cs @@ -33,6 +33,7 @@ using NBXplorer; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using PeterO.Cbor; +using LightningAddressData = BTCPayServer.Data.LightningAddressData; using PayoutData = BTCPayServer.Data.PayoutData; using PullPaymentData = BTCPayServer.Data.PullPaymentData; using StoreData = BTCPayServer.Data.StoreData; diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-lightning-addresses.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-lightning-addresses.json new file mode 100644 index 000000000..b20a00aee --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores-lightning-addresses.json @@ -0,0 +1,252 @@ +{ + "paths": { + "/api/v1/stores/{storeId}/lightning-addresses": { + "get": { + "tags": [ + "Lightning address" + ], + "summary": "Get store configured lightning addresses", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + } + ], + "description": "Get store configured lightning addresses", + "operationId": "StoreLightningAddresses_GetStoreLightningAddresses", + "responses": { + "200": { + "description": "The lightning addresses configured in the store", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LightningAddressData" + } + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/stores/{storeId}/lightning-addresses/{username}": { + "get": { + "tags": [ + "Lightning address" + ], + "summary": "Get store configured lightning address", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "path", + "required": true, + "description": "The lightning address username", + "schema": { + "type": "string" + } + } + ], + "description": "Get store configured lightning address", + "operationId": "StoreLightningAddresses_GetStoreLightningAddress", + "responses": { + "200": { + "description": "The lightning address configured in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LightningAddressData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canviewstoresettings" + ], + "Basic": [] + } + ] + }, + "post": { + "tags": [ + "Lightning address" + ], + "summary": "Add or update store configured lightning address", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "path", + "required": true, + "description": "the lightning address username", + "schema": { + "type": "string" + } + } + ], + "description": "Add or update store configured lightning address", + "operationId": "StoreLightningAddresses_AddOrUpdateStoreLightningAddress", + "requestBody": { + "x-name": "request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LightningAddressData" + } + } + }, + "required": true, + "x-position": 1 + }, + "responses": { + "200": { + "description": "The lightning address configured in the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LightningAddressData" + } + } + } + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Lightning address" + ], + "summary": "Remove configured lightning address", + "parameters": [ + { + "name": "storeId", + "in": "path", + "required": true, + "description": "The store to fetch", + "schema": { + "type": "string" + } + }, + { + "name": "username", + "in": "path", + "required": true, + "description": "The lightning address username", + "schema": { + "type": "string" + } + } + ], + "description": "Remove store configured lightning address", + "operationId": "StoreLightningAddresses_RemoveStoreLightningAddress", + "responses": { + "200": { + "description": "Lightning address removed" + }, + "403": { + "description": "If you are authenticated but forbidden to view the specified store" + }, + "404": { + "description": "The key is not found for this store/wallet" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, + "components": { + "schemas": { + "LightningAddressData": { + "type": "object", + "additionalProperties": false, + "properties": { + "username": { + "type": "string", + "description": "The username of the lightning address" + }, + "currencyCode": { + "type": "string", + "nullable": true, + "description": "The currency to generate the invoices for this lightning address in. Leave null lto use the store default." + }, + "min": { + "type": "string", + "nullable": true, + "description": "The minimum amount in sats this ln address allows" + }, + "max": { + "type": "string", + "nullable": true, + "description": "The maximum amount in sats this ln address allows" + } + } + } + } + } + } +}