Compare commits

..

38 Commits

Author SHA1 Message Date
7d67f729c8 Use bech32 format for lnurl 2024-02-21 21:58:25 +09:00
f08608f766 Add lnurlpay to balance 2024-02-21 17:45:15 +09:00
2d370b8cea Fix conversion 2024-02-21 10:18:03 +09:00
2c480f57c2 Adding margin top on Balance check close button 2024-02-20 18:11:05 -06:00
b1c171a5d9 Pull Payment LNURLW have a paylink 2024-02-20 18:53:26 +09:00
692a13e0c8 Topup 2024-02-19 15:34:12 +09:00
d9b6e465c0 debug 2024-02-15 18:25:28 +09:00
a4485b5377 Boltcard Balance 2024-02-14 16:45:03 +09:00
4c0c2d2e94 fix 2024-02-09 16:33:57 +09:00
89062fcb10 debug 2024-02-09 12:35:00 +09:00
a2087ce722 fix 2024-02-09 12:28:58 +09:00
312997c063 Boltcard Factory plugin 2024-02-09 12:18:26 +09:00
9380d4ca48 Only show setup/reset when the page is fully loaded 2024-02-09 09:23:55 +09:00
d44ec19663 debug 2024-02-09 09:21:54 +09:00
12c871bfd8 debug 2024-02-09 09:21:54 +09:00
f86f858499 debug 2024-02-09 09:21:54 +09:00
7675dce000 Make test CanConfigureCheckout less flaky 2024-02-09 09:21:54 +09:00
b9ef41b8c3 Allow passing LNURLW to register boltcard 2024-02-09 09:21:53 +09:00
18fe420b74 If pull payment opened in mobile, use deeplink to setup card 2024-02-09 09:21:27 +09:00
b7be93c569 Update NTag424 lib 2024-02-08 19:12:14 +09:00
cd01a7b727 Improve performance of payout db queries 2024-02-08 16:44:03 +09:00
b96e73a002 Fix: Payouts state could turn cancelled even if payment was successful 2024-02-08 16:32:41 +09:00
0bf22ddf29 Do not require user approval by default ()
As discussed on Mattermost.
2024-02-06 17:04:18 +09:00
1c4dc382a8 Merge pull request from pavlenex/release-cycles-doc
Create RELEASE-CYCLES.md
2024-02-05 19:42:15 +05:00
71c5566f2b Dashboard: Tooltip for balance on a particular day ()
Closes .
2024-02-02 11:29:35 +01:00
6621859567 remove decimals for Colombian (COP) and Argentina's Peso (ARS) ()
* remove decimals for Colombian (COP) and Argentina's Peso (ARS)

* remove js currency hardcoding

* Fixes removal of columbia and argentina's peso

* Refactor

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2024-02-02 17:16:13 +09:00
6437967e60 Fix: Closing Balance in Dashboard was showing incorrect value () 2024-02-01 15:13:05 +09:00
c5a926c50c Fix Kraken rate for LTC 2024-02-01 14:45:59 +09:00
85ab691b68 bump version 2024-02-01 14:17:14 +09:00
4d3e0ab599 Changelog 2024-02-01 10:13:18 +09:00
02663a149e Fix Kraken API 2024-02-01 10:09:32 +09:00
a8fdc4798d Remove randomize RBF from wallet UI advanced settings ()
* Remove randomize RBF from wallet UI advanced settings

* remove support RBF and allow bump fee from wallet send model

* update psbt RBF
2024-01-31 21:04:19 +09:00
6290b0f3bf Admins can approve registered users ()
* Users list: Cleanups

* Policies: Flip registration settings

* Policies: Add RequireUserApproval setting

* Add approval to user

* Require approval on login and for API key

* API handling

* AccountController cleanups

* Test fix

* Apply suggestions from code review

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>

* Add missing imports

* Communicate login requirements to user on account creation

* Add login requirements to basic auth handler

* Cleanups and test fix

* Encapsulate approval logic in user service and log approval changes

* Send follow up "Account approved" email

Closes .

* Add notification for admins

* Fix creating a user via the admin view

* Update list: Unify flags into status column, add approve action

* Adjust "Resend email" wording

* Incorporate feedback from code review

* Remove duplicate test server policy reset

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2024-01-31 14:45:54 +09:00
411e0334d0 Bitnob rate provider ()
* Bitnob rate provider

* Add Bitnob as recommended exchange for NGN
2024-01-30 10:18:42 +09:00
b174977bc7 Store Email Settings: Improve configuration ()
* Store Email Settings: Improve configuration

This works with the existing settings and provides better guidance about the different store email cases. Closes .

* Split email and notification settings
2024-01-26 10:28:50 +01:00
2111b67e2c Update changelog 2024-01-25 21:03:27 +09:00
b96cfcd14d Apps: Allow authenticated, non-owner users permissioned access ()
Fixes . Before this, the app lookup was constrained by the user having at least `CanModifyStoreSettings` permissions. This changes it to require the user being associated with a store, leaving the fine-grained authorization checks up to the individual actions.
2024-01-25 21:00:33 +09:00
95bf60c252 Create RELEASE-CYCLES.md
This PR adds documentation around release cycles in BTCPay and tends to outline processes and ensures there's documented structure on roles and responsibilities. Feedback welcome.
2024-01-20 13:44:39 +01:00
107 changed files with 3824 additions and 603 deletions
BTCPayServer.Client
BTCPayServer.Data
BTCPayServer.Rating
BTCPayServer.Tests
BTCPayServer
BTCPayServer.csproj
Components/StoreWalletBalance
Controllers
Data
Events
Extensions
HostedServices
Hosting
Models
PayoutProcessors/Lightning
Plugins
Security
Services
Views
wwwroot
Build
Changelog.mdRELEASE-CYCLES.md

@ -41,6 +41,14 @@ namespace BTCPayServer.Client
return response.IsSuccessStatusCode;
}
public virtual async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/{idOrEmail}/approve", null,
new ApproveUserRequest { Approved = approved }, HttpMethod.Post), token);
await HandleResponse(response);
return response.IsSuccessStatusCode;
}
public virtual async Task<ApplicationUserData[]> GetUsers(CancellationToken token = default)
{
var response = await _httpClient.SendAsync(CreateHttpRequest($"api/v1/users/", null, HttpMethod.Get), token);

@ -25,6 +25,16 @@ namespace BTCPayServer.Client.Models
/// </summary>
public bool RequiresEmailConfirmation { get; set; }
/// <summary>
/// Whether the user was approved by an admin
/// </summary>
public bool Approved { get; set; }
/// <summary>
/// whether the user needed approval on account creation
/// </summary>
public bool RequiresApproval { get; set; }
/// <summary>
/// the roles of the user
/// </summary>

@ -0,0 +1,6 @@
namespace BTCPayServer.Client;
public class ApproveUserRequest
{
public bool Approved { get; set; }
}

@ -23,5 +23,7 @@ namespace BTCPayServer.Client.Models
public DateTimeOffset? StartsAt { get; set; }
public string[] PaymentMethods { get; set; }
public bool AutoApproveClaims { get; set; }
public string EmbeddedCSS { get; set; }
public string CustomCSSLink { get; set; }
}
}

@ -4,6 +4,7 @@ using System.Text;
using NBitcoin.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models
{
@ -14,11 +15,15 @@ namespace BTCPayServer.Client.Models
}
public class RegisterBoltcardRequest
{
[JsonProperty("LNURLW")]
public string LNURLW { get; set; }
[JsonConverter(typeof(HexJsonConverter))]
[JsonProperty("UID")]
public byte[] UID { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public OnExistingBehavior? OnExisting { get; set; }
[JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } = new Dictionary<string, JToken>();
}
public class RegisterBoltcardResponse
{

@ -11,6 +11,8 @@ namespace BTCPayServer.Data
public class ApplicationUser : IdentityUser, IHasBlob<UserBlob>
{
public bool RequiresEmailConfirmation { get; set; }
public bool RequiresApproval { get; set; }
public bool Approved { get; set; }
public List<StoredFile> StoredFiles { get; set; }
[Obsolete("U2F support has been replace with FIDO2")]
public List<U2FDevice> U2FDevices { get; set; }

@ -43,7 +43,7 @@ public class LightningAddressDataBlob
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
public string PullPaymentId { get; set; }
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}

@ -0,0 +1,39 @@
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240104155620_AddApprovalToApplicationUser")]
public partial class AddApprovalToApplicationUser : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "Approved",
table: "AspNetUsers",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "RequiresApproval",
table: "AspNetUsers",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Approved",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "RequiresApproval",
table: "AspNetUsers");
}
}
}

@ -112,6 +112,9 @@ namespace BTCPayServer.Migrations
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<bool>("Approved")
.HasColumnType("INTEGER");
b.Property<byte[]>("Blob")
.HasColumnType("BLOB");
@ -158,6 +161,9 @@ namespace BTCPayServer.Migrations
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresApproval")
.HasColumnType("INTEGER");
b.Property<bool>("RequiresEmailConfirmation")
.HasColumnType("INTEGER");

@ -1,4 +1,4 @@
[
[
{
"name":"Afghan Afghani",
"code":"AFN",
@ -58,7 +58,7 @@
{
"name":"Argentine Peso",
"code":"ARS",
"divisibility":2,
"divisibility":0,
"symbol":null,
"crypto":false
},
@ -289,7 +289,7 @@
{
"name":"Colombian Peso",
"code":"COP",
"divisibility":2,
"divisibility":0,
"symbol":null,
"crypto":false
},

@ -77,7 +77,15 @@ namespace BTCPayServer.Services.Rates
continue;
try
{
_CurrencyProviders.TryAdd(new RegionInfo(culture.LCID).ISOCurrencySymbol, culture);
var symbol = new RegionInfo(culture.LCID).ISOCurrencySymbol;
var c = symbol switch
{
// ARS and COP are officially 2 digits, but due to depreciation,
// nobody really use those anymore. (See https://github.com/btcpayserver/btcpayserver/issues/5708)
"ARS" or "COP" => ModifyCurrencyDecimalDigit(culture, 0),
_ => culture
};
_CurrencyProviders.TryAdd(symbol, c);
}
catch { }
}
@ -91,6 +99,15 @@ namespace BTCPayServer.Services.Rates
}
}
private CultureInfo ModifyCurrencyDecimalDigit(CultureInfo culture, int decimals)
{
var modifiedCulture = new CultureInfo(culture.Name);
NumberFormatInfo modifiedNumberFormat = (NumberFormatInfo)modifiedCulture.NumberFormat.Clone();
modifiedNumberFormat.CurrencyDecimalDigits = decimals;
modifiedCulture.NumberFormat = modifiedNumberFormat;
return modifiedCulture;
}
private void AddCurrency(Dictionary<string, IFormatProvider> currencyProviders, string code, int divisibility, string symbol)
{
var culture = new CultureInfo("en-US");

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Services.Rates;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Rating.Providers
{
public class BitnobRateProvider : IRateProvider
{
private readonly HttpClient _httpClient;
public BitnobRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public RateSourceInfo RateSourceInfo => new("bitnob", "Bitnob", "https://api.bitnob.co/api/v1/rates/bitcoin/price");
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
using var response = await _httpClient.GetAsync("https://api.bitnob.co/api/v1/rates/bitcoin/price", cancellationToken);
JObject jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
var dataObject = jobj["data"] as JObject;
if (dataObject == null)
{
return Array.Empty<PairRate>();
}
var pairRates = new List<PairRate>();
foreach (var property in dataObject.Properties())
{
string[] parts = property.Name.Split('_');
decimal value = property.Value.Value<decimal>();
pairRates.Add(new PairRate(new CurrencyPair("BTC", parts[1]), new BidAsk(value)));
}
return pairRates.ToArray();
}
}
}

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using ExchangeSharp;
using Microsoft.CodeAnalysis;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -16,7 +17,7 @@ namespace BTCPayServer.Services.Rates
// Make sure that only one request is sent to kraken in general
public class KrakenExchangeRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("kraken", "Kraken", "https://api.kraken.com/0/public/Ticker?pair=ATOMETH,ATOMEUR,ATOMUSD,ATOMXBT,BATETH,BATEUR,BATUSD,BATXBT,BCHEUR,BCHUSD,BCHXBT,DAIEUR,DAIUSD,DAIUSDT,DASHEUR,DASHUSD,DASHXBT,EOSETH,EOSXBT,ETHCHF,ETHDAI,ETHUSDC,ETHUSDT,GNOETH,GNOXBT,ICXETH,ICXEUR,ICXUSD,ICXXBT,LINKETH,LINKEUR,LINKUSD,LINKXBT,LSKETH,LSKEUR,LSKUSD,LSKXBT,NANOETH,NANOEUR,NANOUSD,NANOXBT,OMGETH,OMGEUR,OMGUSD,OMGXBT,PAXGETH,PAXGEUR,PAXGUSD,PAXGXBT,SCETH,SCEUR,SCUSD,SCXBT,USDCEUR,USDCUSD,USDCUSDT,USDTCAD,USDTEUR,USDTGBP,USDTZUSD,WAVESETH,WAVESEUR,WAVESUSD,WAVESXBT,XBTCHF,XBTDAI,XBTUSDC,XBTUSDT,XDGEUR,XDGUSD,XETCXETH,XETCXXBT,XETCZEUR,XETCZUSD,XETHXXBT,XETHZCAD,XETHZEUR,XETHZGBP,XETHZJPY,XETHZUSD,XLTCXXBT,XLTCZEUR,XLTCZUSD,XMLNXETH,XMLNXXBT,XMLNZEUR,XMLNZUSD,XREPXETH,XREPXXBT,XREPZEUR,XXBTZCAD,XXBTZEUR,XXBTZGBP,XXBTZJPY,XXBTZUSD,XXDGXXBT,XXLMXXBT,XXMRXXBT,XXMRZEUR,XXMRZUSD,XXRPXXBT,XXRPZEUR,XXRPZUSD,XZECXXBT,XZECZEUR,XZECZUSD");
public RateSourceInfo RateSourceInfo => new("kraken", "Kraken", "https://api.kraken.com/0/public/Ticker");
public HttpClient HttpClient
{
get
@ -31,39 +32,6 @@ namespace BTCPayServer.Services.Rates
HttpClient _LocalClient;
static readonly HttpClient _Client = new HttpClient();
// ExchangeSymbolToGlobalSymbol throws exception which would kill perf
readonly ConcurrentDictionary<string, string> notFoundSymbols = new ConcurrentDictionary<string, string>(new Dictionary<string, string>()
{
{"ADAXBT","ADAXBT"},
{ "BSVUSD","BSVUSD"},
{ "QTUMEUR","QTUMEUR"},
{ "QTUMXBT","QTUMXBT"},
{ "EOSUSD","EOSUSD"},
{ "XTZUSD","XTZUSD"},
{ "XREPZUSD","XREPZUSD"},
{ "ADAEUR","ADAEUR"},
{ "ADAUSD","ADAUSD"},
{ "GNOEUR","GNOEUR"},
{ "XTZETH","XTZETH"},
{ "XXRPZJPY","XXRPZJPY"},
{ "XXRPZCAD","XXRPZCAD"},
{ "XTZEUR","XTZEUR"},
{ "QTUMETH","QTUMETH"},
{ "XXLMZUSD","XXLMZUSD"},
{ "QTUMCAD","QTUMCAD"},
{ "QTUMUSD","QTUMUSD"},
{ "XTZXBT","XTZXBT"},
{ "GNOUSD","GNOUSD"},
{ "ADAETH","ADAETH"},
{ "ADACAD","ADACAD"},
{ "XTZCAD","XTZCAD"},
{ "BSVEUR","BSVEUR"},
{ "XZECZJPY","XZECZJPY"},
{ "XXLMZEUR","XXLMZEUR"},
{"EOSEUR","EOSEUR"},
{"BSVXBT","BSVXBT"}
});
string[] _Symbols = Array.Empty<string>();
DateTimeOffset? _LastSymbolUpdate = null;
readonly Dictionary<string, string> _TickerMapping = new Dictionary<string, string>()
@ -76,47 +44,57 @@ namespace BTCPayServer.Services.Rates
{ "ZEUR", "EUR" },
{ "ZJPY", "JPY" },
{ "ZCAD", "CAD" },
{ "ZGBP", "GBP" }
{ "ZGBP", "GBP" },
{ "XXMR", "XMR" },
{ "XETH", "ETH" },
{ "USDC", "USDC" }, // On A=A purpose
{ "XZEC", "ZEC" },
{ "XLTC", "LTC" },
{ "XXRP", "XRP" },
};
string Normalize(string ticker)
{
_TickerMapping.TryGetValue(ticker, out var normalized);
return normalized ?? ticker;
}
readonly ConcurrentDictionary<string, CurrencyPair> CachedCurrencyPairs = new ConcurrentDictionary<string, CurrencyPair>();
private CurrencyPair GetCurrencyPair(string symbol)
{
if (CachedCurrencyPairs.TryGetValue(symbol, out var pair))
return pair;
var found = _TickerMapping.Where(t => symbol.StartsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).FirstOrDefault();
if (found is not null)
{
pair = new CurrencyPair(found.PayTicker, Normalize(symbol.Substring(found.KrakenTicker.Length)));
}
if (pair is null)
{
found = _TickerMapping.Where(t => symbol.EndsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).FirstOrDefault();
if (found is not null)
pair = new CurrencyPair(Normalize(symbol.Substring(0, symbol.Length - found.KrakenTicker.Length)), found.PayTicker);
}
if (pair is null)
CurrencyPair.TryParse(symbol, out pair);
CachedCurrencyPairs.TryAdd(symbol, pair);
return pair;
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var result = new List<PairRate>();
var symbols = await GetSymbolsAsync(cancellationToken);
var helper = (ExchangeKrakenAPI)await ExchangeAPI.GetExchangeAPIAsync<ExchangeKrakenAPI>();
var normalizedPairsList = symbols.Where(s => !notFoundSymbols.ContainsKey(s)).Select(s => helper.NormalizeMarketSymbol(s)).ToList();
var csvPairsList = string.Join(",", normalizedPairsList);
JToken apiTickers = await MakeJsonRequestAsync<JToken>("/0/public/Ticker", null, new Dictionary<string, object> { { "pair", csvPairsList } }, cancellationToken: cancellationToken);
JToken apiTickers = await MakeJsonRequestAsync<JToken>("/0/public/Ticker", null, null, cancellationToken: cancellationToken);
foreach (string symbol in symbols)
{
var ticker = ConvertToExchangeTicker(symbol, apiTickers[symbol]);
if (ticker != null)
{
try
{
string global = null;
var mapped1 = _TickerMapping.Where(t => symbol.StartsWith(t.Key, StringComparison.OrdinalIgnoreCase))
.Select(t => new { KrakenTicker = t.Key, PayTicker = t.Value }).SingleOrDefault();
if (mapped1 != null)
{
var p2 = symbol.Substring(mapped1.KrakenTicker.Length);
if (_TickerMapping.TryGetValue(p2, out var mapped2))
p2 = mapped2;
global = $"{mapped1.PayTicker}_{p2}";
}
else
{
global = await helper.ExchangeMarketSymbolToGlobalMarketSymbolAsync(symbol);
}
if (CurrencyPair.TryParse(global, out var pair))
result.Add(new PairRate(pair, new BidAsk(ticker.Bid, ticker.Ask)));
else
notFoundSymbols.TryAdd(symbol, symbol);
}
catch (ArgumentException)
{
notFoundSymbols.TryAdd(symbol, symbol);
}
var pair = GetCurrencyPair(symbol);
if (pair is not null)
result.Add(new PairRate(pair, new BidAsk(ticker.Bid, ticker.Ask)));
}
}
return result.ToArray();

@ -24,7 +24,7 @@
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.15" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="119.0.6045.10500" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="121.0.6167.8500" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<PrivateAssets>all</PrivateAssets>

@ -245,6 +245,9 @@ namespace BTCPayServer.Tests
rateProvider.Providers.Add("kraken", kraken);
}
// reset test server policies
var settings = GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
TestLogs.LogInformation("Waiting site is operational...");
await WaitSiteIsOperational();

@ -360,10 +360,13 @@ namespace BTCPayServer.Tests
expirySeconds.SendKeys("5");
s.Driver.FindElement(By.Id("Expire")).Click();
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("00:0", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
TestUtils.Eventually(() =>
{
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
Assert.Contains("00:0", paymentInfo.Text);
Assert.DoesNotContain("Please send", paymentInfo.Text);
});
// Configure countdown timer
s.GoToHome();

@ -790,13 +790,15 @@ namespace BTCPayServer.Tests
(0.0005m, "0.0005 USD", "USD"), (0.001m, "0.001 USD", "USD"), (0.01m, "0.01 USD", "USD"),
(0.1m, "0.10 USD", "USD"), (0.1m, "0,10 EUR", "EUR"), (1000m, "1,000 JPY", "JPY"),
(1000.0001m, "1,000.00 INR", "INR"),
(0.0m, "0.00 USD", "USD")
(0.0m, "0.00 USD", "USD"), (1m, "1 COP", "COP"), (1m, "1 ARS", "ARS")
})
{
var actual = displayFormatter.Currency(test.Item1, test.Item3);
actual = actual.Replace("¥", "¥"); // Hack so JPY test pass on linux as well
Assert.Equal(test.Item2, actual);
}
Assert.Equal(0, CurrencyNameTable.Instance.GetNumberFormatInfo("ARS").CurrencyDecimalDigits);
Assert.Equal(0, CurrencyNameTable.Instance.GetNumberFormatInfo("COP").CurrencyDecimalDigits);
}
[Fact]

@ -13,6 +13,7 @@ using BTCPayServer.Controllers;
using BTCPayServer.Events;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
@ -24,6 +25,7 @@ using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@ -694,14 +696,10 @@ namespace BTCPayServer.Tests
// Try loading 1 user by email. Loading myself.
await AssertHttpError(403, async () => await badClient.GetUserByIdOrEmail(badUser.Email));
// Why is this line needed? I saw it in "CanDeleteUsersViaApi" as well. Is this part of the cleanup?
tester.Stores.Remove(adminUser.StoreId);
}
[Fact(Timeout = TestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanCreateUsersViaAPI()
@ -1119,6 +1117,35 @@ namespace BTCPayServer.Tests
OnExisting = OnExistingBehavior.KeepVersion
});
Assert.Equal(card2.Version, card3.Version);
var p = new byte[] { 0xc7 }.Concat(uid).Concat(new byte[8]).ToArray();
var card4 = await client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
});
Assert.Equal(card2.Version, card4.Version);
Assert.Equal(card2.K4, card4.K4);
// Can't define both properties
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// p is malformed
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
UID = uid,
LNURLW = card2.LNURLW + $"?p=lol"
}));
// p is invalid
p[0] = 0;
await AssertValidationError(["LNURLW"], () => client.RegisterBoltcard(test4.Id, new RegisterBoltcardRequest()
{
OnExisting = OnExistingBehavior.KeepVersion,
LNURLW = card2.LNURLW + $"?p={Encoders.Hex.EncodeData(AESKey.Parse(card2.K1).Encrypt(p))}"
}));
// Test with SATS denomination values
var testSats = await client.CreatePullPayment(storeId, new Client.Models.CreatePullPaymentRequest()
{
@ -3571,6 +3598,78 @@ namespace BTCPayServer.Tests
await newUserBasicClient.GetCurrentUser();
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
public async Task ApproveUserTests()
{
using var tester = CreateServerTester();
await tester.StartAsync();
var admin = tester.NewAccount();
await admin.GrantAccessAsync(true);
var adminClient = await admin.CreateClient(Policies.Unrestricted);
Assert.False((await adminClient.GetUserByIdOrEmail(admin.UserId)).RequiresApproval);
Assert.Empty(await adminClient.GetNotifications());
// require approval
var settings = tester.PayTester.GetService<SettingsRepository>();
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = true });
// new user needs approval
var unapprovedUser = tester.NewAccount();
await unapprovedUser.GrantAccessAsync();
var unapprovedUserBasicAuthClient = await unapprovedUser.CreateClient();
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
var unapprovedUserApiKeyClient = await unapprovedUser.CreateClient(Policies.Unrestricted);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).RequiresApproval);
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, true, CancellationToken.None));
Assert.True((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
Assert.True((await unapprovedUserApiKeyClient.GetCurrentUser()).Approved);
Assert.True((await unapprovedUserBasicAuthClient.GetCurrentUser()).Approved);
// un-approve
Assert.True(await adminClient.ApproveUser(unapprovedUser.UserId, false, CancellationToken.None));
Assert.False((await adminClient.GetUserByIdOrEmail(unapprovedUser.UserId)).Approved);
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserApiKeyClient.GetCurrentUser();
});
await AssertAPIError("unauthenticated", async () =>
{
await unapprovedUserBasicAuthClient.GetCurrentUser();
});
// reset policies to not require approval
await settings.UpdateSetting(new PoliciesSettings { LockSubscription = false, RequiresUserApproval = false });
// new user does not need approval
var newUser = tester.NewAccount();
await newUser.GrantAccessAsync();
var newUserBasicAuthClient = await newUser.CreateClient();
var newUserApiKeyClient = await newUser.CreateClient(Policies.Unrestricted);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserApiKeyClient.GetCurrentUser()).Approved);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).RequiresApproval);
Assert.False((await newUserBasicAuthClient.GetCurrentUser()).Approved);
Assert.Single(await adminClient.GetNotifications(false));
// try unapproving user which does not have the RequiresApproval flag
await AssertAPIError("invalid-state", async () =>
{
await adminClient.ApproveUser(newUser.UserId, false, CancellationToken.None);
});
}
[Fact(Timeout = 60 * 2 * 1000)]
[Trait("Integration", "Integration")]
[Trait("Lightning", "Lightning")]

@ -8,6 +8,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using BTCPayServer.Lightning.CLightning;
using BTCPayServer.Services;
using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores;
@ -17,6 +18,7 @@ using NBitcoin;
using NBitcoin.RPC;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI;
using Xunit;
@ -76,6 +78,7 @@ namespace BTCPayServer.Tests
// A bit less than test timeout
TimeSpan.FromSeconds(50));
}
ServerUri = Server.PayTester.ServerUri;
Driver.Manage().Window.Maximize();

@ -16,6 +16,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424;
@ -405,6 +406,148 @@ namespace BTCPayServer.Tests
Assert.Contains("/login", s.Driver.Url);
}
[Fact(Timeout = TestTimeout)]
public async Task CanRequireApprovalForNewAccounts()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
var settings = s.Server.PayTester.GetService<SettingsRepository>();
var policies = await settings.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
Assert.True(policies.EnableRegistration);
Assert.False(policies.RequiresUserApproval);
// Register admin and adapt policies
s.RegisterNewUser(true);
var admin = s.AsTestAccount();
s.GoToHome();
s.GoToServer(ServerNavPages.Policies);
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
s.Driver.FindElement(By.Id("SaveButton")).Click();
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
// Check user create view has approval checkbox
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("CreateUser")).Click();
Assert.False(s.Driver.FindElement(By.Id("Approved")).Selected);
// Ensure there is no unread notification yet
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
s.Logout();
// Register user and try to log in
s.GoToRegister();
s.RegisterNewUser();
s.Driver.AssertNoError();
Assert.Contains("Account created. The new account requires approval by an admin before you can log in", s.FindAlertMessage().Text);
Assert.Contains("/login", s.Driver.Url);
var unapproved = s.AsTestAccount();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
Assert.Contains("/login", s.Driver.Url);
// Login with admin
s.GoToLogin();
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
s.GoToHome();
// Check notification
TestUtils.Eventually(() => Assert.Equal("1", s.Driver.FindElement(By.Id("NotificationsBadge")).Text));
s.Driver.FindElement(By.Id("NotificationsHandle")).Click();
Assert.Matches($"New user {unapproved.RegisterDetails.Email} requires approval", s.Driver.FindElement(By.CssSelector("#NotificationsList .notification")).Text);
s.Driver.FindElement(By.Id("NotificationsMarkAllAsSeen")).Click();
// Reset approval policy
s.GoToServer(ServerNavPages.Policies);
Assert.True(s.Driver.FindElement(By.Id("EnableRegistration")).Selected);
Assert.True(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
s.Driver.FindElement(By.Id("RequiresUserApproval")).Click();
s.Driver.FindElement(By.Id("SaveButton")).Click();
Assert.Contains("Policies updated successfully", s.FindAlertMessage().Text);
Assert.False(s.Driver.FindElement(By.Id("RequiresUserApproval")).Selected);
// Check user create view does not have approval checkbox
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("CreateUser")).Click();
s.Driver.ElementDoesNotExist(By.Id("Approved"));
s.Logout();
// Still requires approval for user who registered before
s.GoToLogin();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
Assert.Contains("Your user account requires approval by an admin before you can log in", s.FindAlertMessage(StatusMessageModel.StatusSeverity.Warning).Text);
Assert.Contains("/login", s.Driver.Url);
// New user can register and gets in without approval
s.GoToRegister();
s.RegisterNewUser();
s.Driver.AssertNoError();
Assert.DoesNotContain("/login", s.Driver.Url);
var autoApproved = s.AsTestAccount();
s.CreateNewStore();
s.Logout();
// Login with admin and check list
s.GoToLogin();
s.LogIn(admin.RegisterDetails.Email, admin.RegisterDetails.Password);
s.GoToHome();
// No notification this time
s.Driver.ElementDoesNotExist(By.Id("NotificationsBadge"));
// Check users list
s.GoToServer(ServerNavPages.Users);
var rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.True(rows.Count >= 3);
// Check user which didn't require approval
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(autoApproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(autoApproved.RegisterDetails.Email, rows.First().Text);
s.Driver.ElementDoesNotExist(By.CssSelector("#UsersList tr:first-child .user-approved"));
// Edit view does not contain approve toggle
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
s.Driver.ElementDoesNotExist(By.Id("Approved"));
// Check user which still requires approval
s.GoToServer(ServerNavPages.Users);
s.Driver.FindElement(By.Id("SearchTerm")).Clear();
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(unapproved.RegisterDetails.Email);
s.Driver.FindElement(By.Id("SearchTerm")).SendKeys(Keys.Enter);
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Pending Approval", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
// Approve user
s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-edit")).Click();
s.Driver.FindElement(By.Id("Approved")).Click();
s.Driver.FindElement(By.Id("SaveUser")).Click();
Assert.Contains("User successfully updated", s.FindAlertMessage().Text);
// Check list again
s.GoToServer(ServerNavPages.Users);
Assert.Contains(unapproved.RegisterDetails.Email, s.Driver.FindElement(By.Id("SearchTerm")).GetAttribute("value"));
rows = s.Driver.FindElements(By.CssSelector("#UsersList tr"));
Assert.Single(rows);
Assert.Contains(unapproved.RegisterDetails.Email, rows.First().Text);
Assert.Contains("Active", s.Driver.FindElement(By.CssSelector("#UsersList tr:first-child .user-status")).Text);
// Finally, login user that needed approval
s.Logout();
s.GoToLogin();
s.LogIn(unapproved.RegisterDetails.Email, unapproved.RegisterDetails.Password);
s.Driver.AssertNoError();
Assert.DoesNotContain("/login", s.Driver.Url);
s.CreateNewStore();
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseSSHService()
{
@ -468,8 +611,16 @@ namespace BTCPayServer.Tests
s.RegisterNewUser(true);
s.CreateNewStore();
// Ensure empty server settings
s.Driver.Navigate().GoToUrl(s.Link("/server/emails"));
s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Password")).Clear();
s.Driver.FindElement(By.Id("Settings_From")).Clear();
s.Driver.FindElement(By.Id("Save")).Submit();
// Store Emails without server fallback
s.GoToStore(StoreNavPages.Emails);
s.Driver.ElementDoesNotExist(By.Id("UseCustomSMTP"));
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("You need to configure email settings before this feature works", s.Driver.PageSource);
@ -481,13 +632,16 @@ namespace BTCPayServer.Tests
s.FindAlertMessage();
}
CanSetupEmailCore(s);
// Store Emails with server fallback
s.GoToStore(StoreNavPages.Emails);
Assert.False(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
s.Driver.FindElement(By.Id("ConfigureEmailRules")).Click();
Assert.Contains("Emails will be sent with the email settings of the server", s.Driver.PageSource);
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
s.GoToStore(StoreNavPages.Emails);
s.Driver.FindElement(By.Id("UseCustomSMTP")).Click();
Thread.Sleep(250);
CanSetupEmailCore(s);
// Store Email Rules
@ -495,7 +649,6 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no rules yet.", s.Driver.PageSource);
Assert.DoesNotContain("id=\"SaveEmailRules\"", s.Driver.PageSource);
Assert.DoesNotContain("You need to configure email settings before this feature works", s.Driver.PageSource);
Assert.DoesNotContain("Emails will be sent with the email settings of the server", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateEmailRule")).Click();
var select = new SelectElement(s.Driver.FindElement(By.Id("Rules_0__Trigger")));
@ -506,6 +659,9 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.ClassName("note-editable")).SendKeys("Your invoice is settled");
s.Driver.FindElement(By.Id("SaveEmailRules")).Click();
Assert.Contains("Store email rules saved", s.FindAlertMessage().Text);
s.GoToStore(StoreNavPages.Emails);
Assert.True(s.Driver.FindElement(By.Id("UseCustomSMTP")).Selected);
}
[Fact(Timeout = TestTimeout)]
@ -1841,6 +1997,7 @@ namespace BTCPayServer.Tests
public async Task CanUsePullPaymentsViaUI()
{
using var s = CreateSeleniumTester();
s.Server.DeleteStore = false;
s.Server.ActivateLightning(LightningConnectionType.LndREST);
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
@ -1963,7 +2120,6 @@ namespace BTCPayServer.Tests
});
s.GoToHome();
//offline/external payout test
var newStore = s.CreateNewStore();
s.GenerateWallet("BTC", "", true, true);
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -2153,6 +2309,22 @@ namespace BTCPayServer.Tests
// Simulate a boltcard
{
// LNURL Withdraw support check with BTC denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
s.Driver.FindElement(By.Id("Name")).SendKeys("TopUpTest");
s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true);
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("100000");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
s.Driver.WaitForElement(By.Id("qr-code-data-input"));
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
var db = s.Server.PayTester.GetService<ApplicationDbContextFactory>();
var ppid = lnurl.AbsoluteUri.Split("/").Last();
var issuerKey = new IssuerKey(SettingsRepositoryExtensions.FixedKey());
@ -2167,6 +2339,25 @@ namespace BTCPayServer.Tests
// p and c should work so long as no bolt11 has been submitted
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
info = (LNURLWithdrawRequest)await LNURL.LNURL.FetchInformation(boltcardUrl, s.Server.PayTester.HttpClient);
Assert.NotNull(info.PayLink);
Assert.StartsWith("lnurlp://", info.PayLink.AbsoluteUri);
// Ignore certs issue
info.PayLink = new Uri(info.PayLink.AbsoluteUri.Replace("lnurlp://", "http://"), UriKind.Absolute);
var payReq = (LNURLPayRequest)await LNURL.LNURL.FetchInformation(info.PayLink, s.Server.PayTester.HttpClient);
var callback = await payReq.SendRequest(LightMoney.Satoshis(100), Network.RegTest, s.Server.PayTester.HttpClient);
Assert.NotNull(callback.Pr);
var pr = BOLT11PaymentRequest.Parse(callback.Pr, Network.RegTest);
Assert.Equal(LightMoney.Satoshis(100), pr.MinimumAmount);
var res = await s.Server.CustomerLightningD.Pay(callback.Pr);
Assert.Equal(PayResult.Ok, res.Result);
var ppService = s.Server.PayTester.GetService<PullPaymentHostedService>();
var serializer = s.Server.PayTester.GetService<BTCPayNetworkJsonSerializerSettings>();
await TestUtils.EventuallyAsync(async () =>
{
var pp = await ppService.GetPullPayment(ppid, true);
Assert.Contains(pp.Payouts.Select(p => p.GetBlob(serializer)), p => p.CryptoAmount == -LightMoney.Satoshis(100).ToUnit(LightMoneyUnit.BTC));
});
var fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "p=([A-F0-9]{32})", $"p={RandomBytes(16)}"));
await Assert.ThrowsAsync<LNUrlException>(() => LNURL.LNURL.FetchInformation(fakeBoltcardUrl, s.Server.PayTester.HttpClient));
fakeBoltcardUrl = new Uri(Regex.Replace(boltcardUrl.AbsoluteUri, "c=([A-F0-9]{16})", $"c={RandomBytes(8)}"));
@ -2332,10 +2523,27 @@ namespace BTCPayServer.Tests
s.Server.ActivateLightning();
await s.StartAsync();
await s.Server.EnsureChannelsSetup();
// Create users
var user = s.RegisterNewUser();
var userAccount = s.AsTestAccount();
s.GoToHome();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true);
// Setup store and associate user
s.CreateNewStore();
s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false);
s.GoToStore(StoreNavPages.Users);
s.Driver.FindElement(By.Id("Email")).Clear();
s.Driver.FindElement(By.Id("Email")).SendKeys(user);
new SelectElement(s.Driver.FindElement(By.Id("Role"))).SelectByValue("Guest");
s.Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("User added successfully", s.FindAlertMessage().Text);
// Setup POS
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
s.Driver.FindElement(By.Id("AppName")).SendKeys(Guid.NewGuid().ToString());
s.Driver.FindElement(By.Id("Create")).Click();
@ -2360,6 +2568,8 @@ namespace BTCPayServer.Tests
s.Driver.WaitForElement(By.ClassName("keypad"));
// basic checks
var keypadUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
@ -2405,6 +2615,19 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("1 222,21 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
// Guest user can access recent transactions
s.GoToHome();
s.Logout();
s.LogIn(user, userAccount.RegisterDetails.Password);
s.GoToUrl(keypadUrl);
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.GoToHome();
s.Logout();
// Unauthenticated user can't access recent transactions
s.GoToUrl(keypadUrl);
s.Driver.ElementDoesNotExist(By.Id("RecentTransactionsToggle"));
}
[Fact]
@ -3127,15 +3350,13 @@ retry:
private static void CanSetupEmailCore(SeleniumTester s)
{
s.Driver.ScrollTo(By.Id("QuickFillDropdownToggle"));
s.Driver.FindElement(By.Id("QuickFillDropdownToggle")).Click();
s.Driver.FindElement(By.CssSelector("#quick-fill .dropdown-menu .dropdown-item:first-child")).Click();
s.Driver.FindElement(By.Id("Settings_Login")).Clear();
s.Driver.FindElement(By.Id("Settings_Login")).SendKeys("test@gmail.com");
s.Driver.FindElement(By.CssSelector("button[value=\"Save\"]")).Submit();
s.FindAlertMessage();
s.Driver.FindElement(By.Id("Settings_Password")).Clear();
s.Driver.FindElement(By.Id("Settings_Password")).SendKeys("mypassword");
s.Driver.FindElement(By.Id("Settings_From")).Clear();
s.Driver.FindElement(By.Id("Settings_From")).SendKeys("Firstname Lastname <email@example.com>");
s.Driver.FindElement(By.Id("Save")).SendKeys(Keys.Enter);

@ -191,6 +191,12 @@ namespace BTCPayServer.Tests
// Ripio keeps changing their pair, so anything is fine...
Assert.NotEmpty(exchangeRates.ByExchange[name]);
}
else if (name == "bitnob")
{
Assert.Contains(exchangeRates.ByExchange[name],
e => e.CurrencyPair == new CurrencyPair("BTC", "NGN") &&
e.BidAsk.Bid > 1.0m); // 1 BTC will always be more than 1 NGN
}
else if (name == "cryptomarket")
{
Assert.Contains(exchangeRates.ByExchange[name],

@ -2325,17 +2325,21 @@ namespace BTCPayServer.Tests
using var tester = CreateServerTester(newDb: true);
await tester.StartAsync();
var f = tester.PayTester.GetService<ApplicationDbContextFactory>();
const string id = "BTCPayServer.Services.PoliciesSettings";
using (var ctx = f.CreateContext())
{
var setting = new SettingData() { Id = "BTCPayServer.Services.PoliciesSettings" };
setting.Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString();
// remove existing policies setting
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
if (setting != null) ctx.Settings.Remove(setting);
// create legacy policies setting that needs migration
setting = new SettingData { Id = id, Value = JObject.Parse("{\"RootAppId\": null, \"RootAppType\": 1, \"Experimental\": false, \"PluginSource\": null, \"LockSubscription\": false, \"DisableSSHService\": false, \"PluginPreReleases\": false, \"BlockExplorerLinks\": [],\"DomainToAppMapping\": [{\"AppId\": \"87kj5yKay8mB4UUZcJhZH5TqDKMD3CznjwLjiu1oYZXe\", \"Domain\": \"donate.nicolas-dorier.com\", \"AppType\": 0}], \"CheckForNewVersions\": false, \"AllowHotWalletForAll\": false, \"RequiresConfirmedEmail\": false, \"DiscourageSearchEngines\": false, \"DisableInstantNotifications\": false, \"DisableNonAdminCreateUserApi\": false, \"AllowHotWalletRPCImportForAll\": false, \"AllowLightningInternalNodeForAll\": false, \"DisableStoresToUseServerEmailSettings\": false}").ToString() };
ctx.Settings.Add(setting);
await ctx.SaveChangesAsync();
}
await RestartMigration(tester);
using (var ctx = f.CreateContext())
{
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == "BTCPayServer.Services.PoliciesSettings");
var setting = await ctx.Settings.FirstOrDefaultAsync(c => c.Id == id);
var o = JObject.Parse(setting.Value);
Assert.Equal("Crowdfund", o["RootAppType"].Value<string>());
o = (JObject)((JArray)o["DomainToAppMapping"])[0];

@ -22,6 +22,18 @@
<EmbeddedResource Remove="wwwroot\vendor\jquery-nice-select\**" />
<None Remove="Build\**" />
<None Remove="wwwroot\vendor\jquery-nice-select\**" />
<Content Update="Plugins\BoltcardBalance\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\UpdateBoltcardFactory.cshtml">
<Pack>false</Pack>
</Content>
<Content Update="Plugins\BoltcardFactory\Views\ViewBoltcardFactory.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Plugins\BoltcardTopUp\Views\ScanCard.cshtml">
<Pack>$(IncludeRazorContentInPack)</Pack>
</Content>
<Content Update="Views\UIStorePullPayments\NewPullPayment.cshtml">
<Pack>false</Pack>
</Content>
@ -46,11 +58,11 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.20" />
<PackageReference Include="BTCPayServer.NTag424" Version="1.0.22" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.5.3" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.5.4" />
<PackageReference Include="CsvHelper" Version="15.0.5" />
<PackageReference Include="Dapper" Version="2.1.28" />
<PackageReference Include="Fido2" Version="2.0.2" />

@ -64,13 +64,14 @@
const id = `StoreWalletBalance-${storeId}`;
const baseUrl = @Safe.Json(Url.Action("WalletHistogram", "UIWallets", new { walletId = Model.WalletId, type = WalletHistogramType.Week }));
const valueTransform = value => rate
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString()
: value
const chartOpts = {
fullWidth: true,
showArea: true,
axisY: {
labelInterpolationFnc: value => rate
? DashboardUtils.displayDefaultCurrency(value, rate, defaultCurrency, divisibility).toString()
: value
labelInterpolationFnc: valueTransform
}
};
@ -80,16 +81,22 @@
document.querySelectorAll(`#${id} .currency`).forEach(c => c.innerText = currency)
document.querySelectorAll(`#${id} [data-balance]`).forEach(c => {
const value = Number.parseFloat(c.dataset.balance);
c.innerText = rate
? DashboardUtils.displayDefaultCurrency(value, rate, currency, divisibility)
: value
c.innerText = valueTransform(value)
});
if (!series) return;
const min = Math.min(...series);
const max = Math.max(...series);
const low = Math.max(min - ((max - min) / 5), 0);
const renderOpts = Object.assign({}, chartOpts, { low });
const tooltip = Chartist.plugins.tooltip2({
template: '{{value}}',
offset: {
x: 0,
y: -16
},
valueTransformFunction: valueTransform
})
const renderOpts = Object.assign({}, chartOpts, { low, plugins: [tooltip] });
const chart = new Chartist.Line(`#${id} .ct-chart`, {
labels,
series: [series]

@ -1,6 +1,8 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics.CodeAnalysis;
using System.IO.IsolatedStorage;
using System.Linq;
using System.Text.RegularExpressions;
@ -12,6 +14,7 @@ using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Payments;
using BTCPayServer.Security;
@ -22,8 +25,11 @@ using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Org.BouncyCastle.Bcpg.OpenPgp;
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
namespace BTCPayServer.Controllers.Greenfield
@ -43,6 +49,7 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly IAuthorizationService _authorizationService;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly Logs _logs;
public GreenfieldPullPaymentController(PullPaymentHostedService pullPaymentService,
LinkGenerator linkGenerator,
@ -53,7 +60,7 @@ namespace BTCPayServer.Controllers.Greenfield
BTCPayNetworkProvider btcPayNetworkProvider,
IAuthorizationService authorizationService,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env)
BTCPayServerEnvironment env, Logs logs)
{
_pullPaymentService = pullPaymentService;
_linkGenerator = linkGenerator;
@ -65,6 +72,7 @@ namespace BTCPayServer.Controllers.Greenfield
_authorizationService = authorizationService;
_settingsRepository = settingsRepository;
_env = env;
_logs = logs;
}
[HttpGet("~/api/v1/stores/{storeId}/pull-payments")]
@ -153,20 +161,7 @@ namespace BTCPayServer.Controllers.Greenfield
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
var ppId = await _pullPaymentService.CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PaymentMethodIds = paymentMethods,
AutoApproveClaims = request.AutoApproveClaims
});
var ppId = await _pullPaymentService.CreatePullPayment(storeId, request);
var pp = await _pullPaymentService.GetPullPayment(ppId, false);
return this.Ok(CreatePullPaymentData(pp));
}
@ -200,13 +195,39 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost]
[Route("~/api/v1/pull-payments/{pullPaymentId}/boltcards")]
[AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request)
public async Task<IActionResult> RegisterBoltcard(string pullPaymentId, RegisterBoltcardRequest request, string? onExisting = null)
{
if (pullPaymentId is null)
return PullPaymentNotFound();
this._logs.PayServer.LogInformation($"RegisterBoltcard: onExisting queryParam: {onExisting}");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var pp = await _pullPaymentService.GetPullPayment(pullPaymentId, false);
if (pp is null)
return PullPaymentNotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
{
if (request.UID is not null)
{
ModelState.AddModelError(nameof(request.LNURLW), "You should pass either LNURLW or UID but not both");
return this.CreateValidationError(ModelState);
}
var p = ExtractP(request.LNURLW);
if (p is null)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
return this.CreateValidationError(ModelState);
}
request.UID = picc.Uid;
}
if (request?.UID is null || request.UID.Length != 7)
{
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
@ -217,15 +238,28 @@ namespace BTCPayServer.Controllers.Greenfield
return this.CreateAPIError(400, "lnurl-not-supported", "This pull payment currency should be BTC or SATS and accept lightning");
}
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// Passing onExisting as a query parameter is used by deeplink
request.OnExisting = onExisting switch
{
nameof(OnExistingBehavior.UpdateVersion) => OnExistingBehavior.UpdateVersion,
nameof(OnExistingBehavior.KeepVersion) => OnExistingBehavior.KeepVersion,
_ => request.OnExisting
};
this._logs.PayServer.LogInformation($"After");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(request)}");
var version = await _dbContextFactory.LinkBoltcardToPullPayment(pullPaymentId, issuerKey, request.UID, request.OnExisting);
this._logs.PayServer.LogInformation($"Version: " + version);
this._logs.PayServer.LogInformation($"ID: " + Encoders.Hex.EncodeData(issuerKey.GetId(request.UID)));
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, pullPaymentId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
return Ok(new RegisterBoltcardResponse()
var resp = new RegisterBoltcardResponse()
{
LNURLW = boltcardUrl,
Version = version,
@ -234,7 +268,25 @@ namespace BTCPayServer.Controllers.Greenfield
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
});
};
this._logs.PayServer.LogInformation($"Response");
this._logs.PayServer.LogInformation($"{JsonConvert.SerializeObject(resp)}");
return Ok(resp);
}
private string? ExtractP(string? url)
{
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
int num = uri.AbsoluteUri.IndexOf('?');
if (num == -1)
return null;
string input = uri.AbsoluteUri.Substring(num);
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
if (!match.Success)
return null;
return match.Groups[1].Value;
}
[HttpGet("~/api/v1/pull-payments/{pullPaymentId}")]

@ -518,10 +518,6 @@ namespace BTCPayServer.Controllers.Greenfield
Outputs = outputs,
AlwaysIncludeNonWitnessUTXO = true,
InputSelection = request.SelectedInputs?.Any() is true,
AllowFeeBump =
!request.RBF.HasValue ? WalletSendModel.ThreeStateBool.Maybe :
request.RBF.Value ? WalletSendModel.ThreeStateBool.Yes :
WalletSendModel.ThreeStateBool.No,
FeeSatoshiPerByte = request.FeeRate?.SatoshiPerByte,
NoChange = request.NoChange
},

@ -92,6 +92,26 @@ namespace BTCPayServer.Controllers.Greenfield
$"{(request.Locked ? "Locking" : "Unlocking")} user failed");
}
[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpPost("~/api/v1/users/{idOrEmail}/approve")]
public async Task<IActionResult> ApproveUser(string idOrEmail, ApproveUserRequest request)
{
var user = await _userManager.FindByIdOrEmail(idOrEmail);
if (user is null)
{
return this.UserNotFound();
}
var success = false;
if (user.RequiresApproval)
{
success = await _userService.SetUserApproval(user.Id, request.Approved, Request.GetAbsoluteRootUri());
}
return success ? Ok() : this.CreateAPIError("invalid-state",
$"{(request.Approved ? "Approving" : "Unapproving")} user failed");
}
[Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpGet("~/api/v1/users/")]
public async Task<ActionResult<ApplicationUserData[]>> GetUsers()
@ -163,7 +183,9 @@ namespace BTCPayServer.Controllers.Greenfield
UserName = request.Email,
Email = request.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = !anyAdmin && isAdmin // auto-approve first admin
};
var passwordValidation = await this._passwordValidator.ValidateAsync(_userManager, user, request.Password);
if (!passwordValidation.Succeeded)

@ -1084,6 +1084,13 @@ namespace BTCPayServer.Controllers.Greenfield
new LockUserRequest { Locked = disabled }));
}
public override async Task<bool> ApproveUser(string idOrEmail, bool approved, CancellationToken token = default)
{
return GetFromActionResult<bool>(
await GetController<GreenfieldUsersController>().ApproveUser(idOrEmail,
new ApproveUserRequest { Approved = approved }));
}
public override async Task<OnChainWalletTransactionData> PatchOnChainWalletTransaction(string storeId,
string cryptoCode, string transactionId,
PatchOnChainTransactionRequest request, bool force = false, CancellationToken token = default)

@ -2,7 +2,6 @@ using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Security.Claims;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
@ -109,6 +108,7 @@ namespace BTCPayServer.Controllers
{
if (User.Identity.IsAuthenticated && string.IsNullOrEmpty(returnUrl))
return RedirectToLocal();
// Clear the existing external cookie to ensure a clean login process
await HttpContext.SignOutAsync(IdentityConstants.ExternalScheme);
@ -118,15 +118,13 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
return View(nameof(Login), new LoginViewModel() { Email = email });
return View(nameof(Login), new LoginViewModel { Email = email });
}
[HttpPost("/login/code")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
[RateLimitsFilter(ZoneLimits.Login, Scope = RateLimitsScope.RemoteAddress)]
public async Task<IActionResult> LoginWithCode(string loginCode, string returnUrl = null)
{
if (!string.IsNullOrEmpty(loginCode))
@ -134,17 +132,26 @@ namespace BTCPayServer.Controllers
var userId = _userLoginCodeService.Verify(loginCode);
if (userId is null)
{
ModelState.AddModelError(string.Empty,
"Login code was invalid");
return await Login(returnUrl, null);
TempData[WellKnownTempData.ErrorMessage] = "Login code was invalid";
return await Login(returnUrl);
}
var user = await _userManager.FindByIdAsync(userId);
_logger.LogInformation("User with ID {UserId} logged in with a login code.", user.Id);
var user = await _userManager.FindByIdAsync(userId);
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return await Login(returnUrl);
}
_logger.LogInformation("User with ID {UserId} logged in with a login code", user!.Id);
await _signInManager.SignInAsync(user, false, "LoginCode");
return RedirectToLocal(returnUrl);
}
return await Login(returnUrl, null);
return await Login(returnUrl);
}
[HttpPost("/login")]
@ -161,24 +168,20 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
if (ModelState.IsValid)
{
// Require the user to have a confirmed email before they can log on.
// Require the user to pass basic checks (approval, confirmed email, not disabled) before they can log on
var user = await _userManager.FindByEmailAsync(model.Email);
if (user != null)
const string errorMessage = "Invalid login attempt.";
if (!UserService.TryCanLogin(user, out var message))
{
if (user.RequiresEmailConfirmation && !await _userManager.IsEmailConfirmedAsync(user))
TempData.SetStatusMessageModel(new StatusMessageModel
{
ModelState.AddModelError(string.Empty,
"You must have a confirmed email to log in.");
return View(model);
}
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var fido2Devices = await _fido2Service.HasCredentials(user.Id);
var fido2Devices = await _fido2Service.HasCredentials(user!.Id);
var lnurlAuthCredentials = await _lnurlAuthService.HasCredentials(user.Id);
if (!await _userManager.IsLockedOutAsync(user) && (fido2Devices || lnurlAuthCredentials))
{
@ -196,33 +199,30 @@ namespace BTCPayServer.Controllers
};
}
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = twoFModel,
LoginWithFido2ViewModel = fido2Devices ? await BuildFido2ViewModel(model.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = lnurlAuthCredentials ? await BuildLNURLAuthViewModel(model.RememberMe, user) : null,
});
}
else
{
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
await _userManager.AccessFailedAsync(user);
ModelState.AddModelError(string.Empty, errorMessage!);
return View(model);
}
var result = await _signInManager.PasswordSignInAsync(model.Email, model.Password, model.RememberMe, lockoutOnFailure: true);
if (result.Succeeded)
{
_logger.LogInformation($"User '{user.Id}' logged in.");
_logger.LogInformation("User {UserId} logged in", user.Id);
return RedirectToLocal(returnUrl);
}
if (result.RequiresTwoFactor)
{
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel()
LoginWith2FaViewModel = new LoginWith2faViewModel
{
RememberMe = model.RememberMe
}
@ -230,14 +230,12 @@ namespace BTCPayServer.Controllers
}
if (result.IsLockedOut)
{
_logger.LogWarning($"User '{user.Id}' account locked out.");
_logger.LogWarning("User {UserId} account locked out", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
ModelState.AddModelError(string.Empty, errorMessage);
return View(model);
}
// If we got this far, something failed, redisplay form
@ -253,7 +251,7 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithFido2ViewModel()
return new LoginWithFido2ViewModel
{
Data = r,
UserId = user.Id,
@ -263,7 +261,6 @@ namespace BTCPayServer.Controllers
return null;
}
private async Task<LoginWithLNURLAuthViewModel> BuildLNURLAuthViewModel(bool rememberMe, ApplicationUser user)
{
if (_btcPayServerEnvironment.IsSecure(HttpContext))
@ -273,15 +270,14 @@ namespace BTCPayServer.Controllers
{
return null;
}
return new LoginWithLNURLAuthViewModel()
return new LoginWithLNURLAuthViewModel
{
RememberMe = rememberMe,
UserId = user.Id,
LNURLEndpoint = new Uri(_linkGenerator.GetUriByAction(
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase))
action: nameof(UILNURLAuthController.LoginResponse),
controller: "UILNURLAuth",
values: new { userId = user.Id, action = "login", tag = "login", k1 = Encoders.Hex.EncodeData(r) }, Request.Scheme, Request.Host, Request.PathBase) ?? string.Empty)
};
}
return null;
@ -298,14 +294,18 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
var k1 = Encoders.Hex.DecodeData(viewModel.LNURLEndpoint.ParseQueryString().Get("k1"));
@ -313,34 +313,33 @@ namespace BTCPayServer.Controllers
storedk1.SequenceEqual(k1))
{
_lnurlAuthService.FinalLoginStore.TryRemove(viewModel.UserId, out _);
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Exception e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
return View("SecondaryLogin", new SecondaryLoginViewModel()
if (!string.IsNullOrEmpty(errorMessage))
{
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
ModelState.AddModelError(string.Empty, errorMessage);
}
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user!.Id) ? await BuildFido2ViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = viewModel,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpPost("/login/fido2")]
[AllowAnonymous]
[ValidateAntiForgeryToken]
@ -352,44 +351,50 @@ namespace BTCPayServer.Controllers
}
ViewData["ReturnUrl"] = returnUrl;
var errorMessage = "Invalid login attempt.";
var user = await _userManager.FindByIdAsync(viewModel.UserId);
if (user == null)
if (!UserService.TryCanLogin(user, out var message))
{
return NotFound();
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = user == null ? StatusMessageModel.StatusSeverity.Error : StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return RedirectToAction("Login");
}
var errorMessage = string.Empty;
try
{
if (await _fido2Service.CompleteLogin(viewModel.UserId, JObject.Parse(viewModel.Response).ToObject<AuthenticatorAssertionRawResponse>()))
{
await _signInManager.SignInAsync(user, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in.");
await _signInManager.SignInAsync(user!, viewModel.RememberMe, "FIDO2");
_logger.LogInformation("User logged in");
return RedirectToLocal(returnUrl);
}
errorMessage = "Invalid login attempt.";
}
catch (Fido2VerificationException e)
{
errorMessage = e.Message;
}
ModelState.AddModelError(string.Empty, errorMessage);
if (!string.IsNullOrEmpty(errorMessage))
{
ModelState.AddModelError(string.Empty, errorMessage);
}
viewModel.Response = null;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWithFido2ViewModel = viewModel,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user!.Id) ? await BuildLNURLAuthViewModel(viewModel.RememberMe, user) : null,
LoginWith2FaViewModel = !user.TwoFactorEnabled
? null
: new LoginWith2faViewModel()
: new LoginWith2faViewModel
{
RememberMe = viewModel.RememberMe
}
});
}
[HttpGet("/login/2fa")]
[AllowAnonymous]
public async Task<IActionResult> LoginWith2fa(bool rememberMe, string returnUrl = null)
@ -401,7 +406,6 @@ namespace BTCPayServer.Controllers
// Ensure the user has gone through the username & password screen first
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
@ -409,11 +413,11 @@ namespace BTCPayServer.Controllers
ViewData["ReturnUrl"] = returnUrl;
return View("SecondaryLogin", new SecondaryLoginViewModel()
return View("SecondaryLogin", new SecondaryLoginViewModel
{
LoginWith2FaViewModel = new LoginWith2faViewModel { RememberMe = rememberMe },
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
@ -437,32 +441,32 @@ namespace BTCPayServer.Controllers
{
throw new ApplicationException($"Unable to load user with ID '{_userManager.GetUserId(User)}'.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var authenticatorCode = model.TwoFactorCode.Replace(" ", string.Empty, StringComparison.InvariantCulture).Replace("-", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorAuthenticatorSignInAsync(authenticatorCode, rememberMe, model.RememberMachine);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with 2fa.", user.Id);
_logger.LogInformation("User with ID {UserId} logged in with 2fa", user.Id);
return RedirectToLocal(returnUrl);
}
else if (result.IsLockedOut)
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
_logger.LogWarning("Invalid authenticator code entered for user with ID {UserId}.", user.Id);
ModelState.AddModelError(string.Empty, "Invalid authenticator code.");
return View("SecondaryLogin", new SecondaryLoginViewModel()
{
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = (await _fido2Service.HasCredentials(user.Id)) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = (await _lnurlAuthService.HasCredentials(user.Id)) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
LoginWith2FaViewModel = model,
LoginWithFido2ViewModel = await _fido2Service.HasCredentials(user.Id) ? await BuildFido2ViewModel(rememberMe, user) : null,
LoginWithLNURLAuthViewModel = await _lnurlAuthService.HasCredentials(user.Id) ? await BuildLNURLAuthViewModel(rememberMe, user) : null,
});
}
[HttpGet("/login/recovery-code")]
@ -504,30 +508,35 @@ namespace BTCPayServer.Controllers
var user = await _signInManager.GetTwoFactorAuthenticationUserAsync();
if (user == null)
{
throw new ApplicationException($"Unable to load two-factor authentication user.");
throw new ApplicationException("Unable to load two-factor authentication user.");
}
if (!UserService.TryCanLogin(user, out var message))
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Message = message
});
return View(model);
}
var recoveryCode = model.RecoveryCode.Replace(" ", string.Empty, StringComparison.InvariantCulture);
var result = await _signInManager.TwoFactorRecoveryCodeSignInAsync(recoveryCode);
if (result.Succeeded)
{
_logger.LogInformation("User with ID {UserId} logged in with a recovery code.", user.Id);
_logger.LogInformation("User with ID {UserId} logged in with a recovery code", user.Id);
return RedirectToLocal(returnUrl);
}
if (result.IsLockedOut)
{
_logger.LogWarning("User with ID {UserId} account locked out.", user.Id);
_logger.LogWarning("User with ID {UserId} account locked out", user.Id);
return RedirectToAction(nameof(Lockout), new { user.LockoutEnd });
}
else
{
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
_logger.LogWarning("Invalid recovery code entered for user with ID {UserId}", user.Id);
ModelState.AddModelError(string.Empty, "Invalid recovery code entered.");
return View();
}
[HttpGet("/login/lockout")]
@ -540,7 +549,7 @@ namespace BTCPayServer.Controllers
[HttpGet("/register")]
[AllowAnonymous]
[RateLimitsFilter(ZoneLimits.Register, Scope = RateLimitsScope.RemoteAddress)]
public IActionResult Register(string returnUrl = null, bool logon = true)
public IActionResult Register(string returnUrl = null)
{
if (!CanLoginOrRegister())
{
@ -567,32 +576,36 @@ namespace BTCPayServer.Controllers
var policies = await _SettingsRepository.GetSettingAsync<PoliciesSettings>() ?? new PoliciesSettings();
if (policies.LockSubscription && !User.IsInRole(Roles.ServerAdmin))
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
if (ModelState.IsValid)
{
var anyAdmin = (await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin)).Any();
var isFirstAdmin = !anyAdmin || (model.IsAdmin && _Options.CheatMode);
var user = new ApplicationUser
{
UserName = model.Email,
Email = model.Email,
RequiresEmailConfirmation = policies.RequiresConfirmedEmail,
Created = DateTimeOffset.UtcNow
RequiresApproval = policies.RequiresUserApproval,
Created = DateTimeOffset.UtcNow,
Approved = isFirstAdmin // auto-approve first admin
};
var result = await _userManager.CreateAsync(user, model.Password);
if (result.Succeeded)
{
var admin = await _userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
if (admin.Count == 0 || (model.IsAdmin && _Options.CheatMode))
if (isFirstAdmin)
{
await _RoleManager.CreateAsync(new IdentityRole(Roles.ServerAdmin));
await _userManager.AddToRoleAsync(user, Roles.ServerAdmin);
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>();
var settings = await _SettingsRepository.GetSettingAsync<ThemeSettings>() ?? new ThemeSettings();
settings.FirstRun = false;
await _SettingsRepository.UpdateSetting<ThemeSettings>(settings);
await _SettingsRepository.UpdateSetting(settings);
await _SettingsRepository.FirstAdminRegistered(policies, _Options.UpdateUrl != null, _Options.DisableRegistration, Logs);
RegisteredAdmin = true;
}
_eventAggregator.Publish(new UserRegisteredEvent()
_eventAggregator.Publish(new UserRegisteredEvent
{
RequestUri = Request.GetAbsoluteRootUri(),
User = user,
@ -600,19 +613,29 @@ namespace BTCPayServer.Controllers
});
RegisteredUserId = user.Id;
if (!policies.RequiresConfirmedEmail)
TempData[WellKnownTempData.SuccessMessage] = "Account created.";
if (policies.RequiresConfirmedEmail)
{
if (logon)
await _signInManager.SignInAsync(user, isPersistent: false);
TempData[WellKnownTempData.SuccessMessage] += " Please confirm your email.";
}
if (policies.RequiresUserApproval)
{
TempData[WellKnownTempData.SuccessMessage] += " The new account requires approval by an admin before you can log in.";
}
if (policies.RequiresConfirmedEmail || policies.RequiresUserApproval)
{
return RedirectToAction(nameof(Login));
}
if (logon)
{
await _signInManager.SignInAsync(user, isPersistent: false);
return RedirectToLocal(returnUrl);
}
else
{
TempData[WellKnownTempData.SuccessMessage] = "Account created, please confirm your email";
return View();
}
}
AddErrors(result);
else
{
AddErrors(result);
}
}
// If we got this far, something failed, redisplay form
@ -628,8 +651,8 @@ namespace BTCPayServer.Controllers
{
await _signInManager.SignOutAsync();
HttpContext.DeleteUserPrefsCookie();
_logger.LogInformation("User logged out.");
return RedirectToAction(nameof(UIAccountController.Login));
_logger.LogInformation("User logged out");
return RedirectToAction(nameof(Login));
}
[HttpGet("/register/confirm-email")]
@ -650,7 +673,7 @@ namespace BTCPayServer.Controllers
if (!await _userManager.HasPasswordAsync(user))
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Info,
Message = "Your email has been confirmed but you still need to set your password."
@ -660,7 +683,7 @@ namespace BTCPayServer.Controllers
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Your email has been confirmed."
@ -687,12 +710,12 @@ namespace BTCPayServer.Controllers
if (ModelState.IsValid)
{
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null || (user.RequiresEmailConfirmation && !(await _userManager.IsEmailConfirmedAsync(user))))
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist or is not confirmed
return RedirectToAction(nameof(ForgotPasswordConfirmation));
}
_eventAggregator.Publish(new UserPasswordResetRequestedEvent()
_eventAggregator.Publish(new UserPasswordResetRequestedEvent
{
User = user,
RequestUri = Request.GetAbsoluteRootUri()
@ -740,16 +763,16 @@ namespace BTCPayServer.Controllers
return View(model);
}
var user = await _userManager.FindByEmailAsync(model.Email);
if (user == null)
if (!UserService.TryCanLogin(user, out _))
{
// Don't reveal that the user does not exist
return RedirectToAction(nameof(Login));
}
var result = await _userManager.ResetPasswordAsync(user, model.Code, model.Password);
var result = await _userManager.ResetPasswordAsync(user!, model.Code, model.Password);
if (result.Succeeded)
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = "Password successfully set."
@ -800,7 +823,7 @@ namespace BTCPayServer.Controllers
private void SetInsecureFlags()
{
TempData.SetStatusMessageModel(new StatusMessageModel()
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Error,
Message = "You cannot login over an insecure connection. Please use HTTPS or Tor."

@ -14,11 +14,20 @@ using System.Threading;
using System;
using NBitcoin.DataEncoders;
using System.Text.Json.Serialization;
using BTCPayServer.HostedServices;
using BTCPayServer.Services.Stores;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Lightning;
using System.Reflection.Metadata;
namespace BTCPayServer.Controllers;
public class UIBoltcardController : Controller
{
private readonly PullPaymentHostedService _ppService;
private readonly StoreRepository _storeRepository;
public class BoltcardSettings
{
[JsonConverter(typeof(NBitcoin.JsonConverters.HexJsonConverter))]
@ -28,11 +37,15 @@ public class UIBoltcardController : Controller
UILNURLController lnUrlController,
SettingsRepository settingsRepository,
ApplicationDbContextFactory contextFactory,
PullPaymentHostedService ppService,
StoreRepository storeRepository,
BTCPayServerEnvironment env)
{
LNURLController = lnUrlController;
SettingsRepository = settingsRepository;
ContextFactory = contextFactory;
_ppService = ppService;
_storeRepository = storeRepository;
Env = env;
}
@ -41,6 +54,64 @@ public class UIBoltcardController : Controller
public ApplicationDbContextFactory ContextFactory { get; }
public BTCPayServerEnvironment Env { get; }
[AllowAnonymous]
[HttpGet("~/boltcard/pay")]
public async Task<IActionResult> GetPayRequest([FromQuery] string? p, [FromQuery] long? amount = null)
{
var issuerKey = await SettingsRepository.GetIssuerKey(Env);
var piccData = issuerKey.TryDecrypt(p);
if (piccData is null)
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Invalid PICCData" });
piccData = new BoltcardPICCData(piccData.Uid, int.MaxValue - 10); // do not check the counter
var registration = await ContextFactory.GetBoltcardRegistration(issuerKey, piccData, false);
var pp = await _ppService.GetPullPayment(registration!.PullPaymentId, false);
var store = await _storeRepository.FindStore(pp.StoreId);
var lnUrlMetadata = new Dictionary<string, string>();
lnUrlMetadata.Add("text/plain", "Boltcard Top-Up");
var payRequest = new LNURLPayRequest
{
Tag = "payRequest",
MinSendable = LightMoney.Satoshis(1.0m),
MaxSendable = LightMoney.FromUnit(6.12m, LightMoneyUnit.BTC),
Callback = new Uri(GetPayLink(p, Request.Scheme), UriKind.Absolute),
CommentAllowed = 0
};
payRequest.Metadata = Newtonsoft.Json.JsonConvert.SerializeObject(lnUrlMetadata.Select(kv => new[] { kv.Key, kv.Value }));
if (amount is null)
return Ok(payRequest);
var cryptoCode = "BTC";
var currency = "BTC";
var invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.BTC);
if (pp.GetBlob().Currency == "SATS")
{
currency = "SATS";
invoiceAmount = LightMoney.FromUnit(amount.Value, LightMoneyUnit.MilliSatoshi).ToUnit(LightMoneyUnit.Satoshi);
}
LNURLController.ControllerContext.HttpContext = HttpContext;
var result = await LNURLController.GetLNURLRequest(
cryptoCode,
store,
store.GetStoreBlob(),
new CreateInvoiceRequest()
{
Currency = currency,
Amount = invoiceAmount
},
payRequest,
lnUrlMetadata,
[PullPaymentHostedService.GetInternalTag(pp.Id)]);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest2)
return result;
payRequest = payRequest2;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
return await LNURLController.GetLNURLForInvoice(invoiceId, cryptoCode, amount.Value, null);
}
[AllowAnonymous]
[HttpGet("~/boltcard")]
public async Task<IActionResult> GetWithdrawRequest([FromQuery] string? p, [FromQuery] string? c, [FromQuery] string? pr, [FromQuery] string? k1, CancellationToken cancellationToken)
@ -65,6 +136,16 @@ public class UIBoltcardController : Controller
if (!cardKey.CheckSunMac(c, piccData))
return BadRequest(new LNUrlStatusResponse { Status = "ERROR", Reason = "Replayed or expired query" });
LNURLController.ControllerContext.HttpContext = HttpContext;
return await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
var res = await LNURLController.GetLNURLForPullPayment("BTC", registration.PullPaymentId, pr, $"{p}-{c}", cancellationToken);
if (res is not OkObjectResult ok || ok.Value is not LNURLWithdrawRequest withdrawRequest)
return res;
var paylink = GetPayLink(p, "lnurlp");
withdrawRequest.PayLink = new Uri(paylink, UriKind.Absolute);
return res;
}
private string GetPayLink(string? p, string scheme)
{
return Url.Action(nameof(GetPayRequest), "UIBoltcard", new { p }, scheme)!;
}
}

@ -26,6 +26,7 @@ using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.PaymentRequests;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using LNURL;
@ -436,6 +437,13 @@ namespace BTCPayServer
var store = await _storeRepository.FindStore(lightningAddressSettings.StoreDataId);
if (store is null)
return NotFound("Unknown username");
List<string> additionalTags = new List<string>();
if (blob?.PullPaymentId is not null)
{
var pp = await _pullPaymentHostedService.GetPullPayment(blob.PullPaymentId, false);
if (pp != null)
additionalTags.Add(PullPaymentHostedService.GetInternalTag(blob.PullPaymentId));
}
var result = await GetLNURLRequest(
cryptoCode,
store,
@ -453,7 +461,7 @@ namespace BTCPayServer
new Dictionary<string, string>
{
{ "text/identifier", $"{username}@{Request.Host}" }
});
}, additionalTags);
if (result is not OkObjectResult ok || ok.Value is not LNURLPayRequest payRequest)
return result;
var invoiceId = payRequest.Callback.AbsoluteUri.Split('/').Last();
@ -495,7 +503,7 @@ namespace BTCPayServer
});
}
private async Task<IActionResult> GetLNURLRequest(
internal async Task<IActionResult> GetLNURLRequest(
string cryptoCode,
Data.StoreData store,
Data.StoreBlob blob,

@ -11,6 +11,7 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Lightning;
@ -127,13 +128,27 @@ namespace BTCPayServer.Controllers
if (_pullPaymentHostedService.SupportsLNURL(blob))
{
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
var url = Url.Action(nameof(UILNURLController.GetLNURLForPullPayment), "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
vm.LnurlEndpoint = url != null ? new Uri(url) : null;
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.UpdateVersion)}";
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(vm, OnExistingBehavior.KeepVersion)}";
}
return View(nameof(ViewPullPayment), vm);
}
private string GetBoltcardDeeplinkUrl(ViewPullPaymentModel vm, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(GreenfieldPullPaymentController.RegisterBoltcard), "GreenfieldPullPayment",
new
{
pullPaymentId = vm.Id,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl);
return registerUrl;
}
[HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> EditPullPayment(string storeId, string pullPaymentId)
@ -261,7 +276,8 @@ namespace BTCPayServer.Controllers
Destination = destination,
PullPaymentId = pullPaymentId,
Value = vm.ClaimedAmount,
PaymentMethodId = paymentMethodId
PaymentMethodId = paymentMethodId,
StoreId = pp.StoreId
});
if (result.Result != ClaimRequest.ClaimResult.Ok)

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
@ -8,25 +7,21 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Models.ServerViewModels;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using MimeKit;
namespace BTCPayServer.Controllers
{
public partial class UIServerController
{
[Route("server/users")]
[HttpGet("server/users")]
public async Task<IActionResult> ListUsers(
[FromServices] RoleManager<IdentityRole> roleManager,
UsersViewModel model,
string sortOrder = null
)
UsersViewModel model,
string sortOrder = null)
{
model = this.ParseListQuery(model ?? new UsersViewModel());
@ -64,7 +59,8 @@ namespace BTCPayServer.Controllers
Name = u.UserName,
Email = u.Email,
Id = u.Id,
Verified = u.EmailConfirmed || !u.RequiresEmailConfirmation,
EmailConfirmed = u.RequiresEmailConfirmation ? u.EmailConfirmed : null,
Approved = u.RequiresApproval ? u.Approved : null,
Created = u.Created,
Roles = u.UserRoles.Select(role => role.RoleId),
Disabled = u.LockoutEnabled && u.LockoutEnd != null && DateTimeOffset.UtcNow < u.LockoutEnd.Value.UtcDateTime
@ -74,44 +70,67 @@ namespace BTCPayServer.Controllers
return View(model);
}
[Route("server/users/{userId}")]
[HttpGet("server/users/{userId}")]
public new async Task<IActionResult> User(string userId)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
var roles = await _UserManager.GetRolesAsync(user);
var userVM = new UsersViewModel.UserViewModel
var model = new UsersViewModel.UserViewModel
{
Id = user.Id,
Email = user.Email,
Verified = user.EmailConfirmed || !user.RequiresEmailConfirmation,
EmailConfirmed = user.RequiresEmailConfirmation ? user.EmailConfirmed : null,
Approved = user.RequiresApproval ? user.Approved : null,
IsAdmin = Roles.HasServerAdmin(roles)
};
return View(userVM);
return View(model);
}
[Route("server/users/{userId}")]
[HttpPost]
[HttpPost("server/users/{userId}")]
public new async Task<IActionResult> User(string userId, UsersViewModel.UserViewModel viewModel)
{
var user = await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
bool? propertiesChanged = null;
bool? adminStatusChanged = null;
bool? approvalStatusChanged = null;
if (user.RequiresApproval && viewModel.Approved.HasValue)
{
approvalStatusChanged = await _userService.SetUserApproval(user.Id, viewModel.Approved.Value, Request.GetAbsoluteRootUri());
}
if (user.RequiresEmailConfirmation && viewModel.EmailConfirmed.HasValue && user.EmailConfirmed != viewModel.EmailConfirmed)
{
user.EmailConfirmed = viewModel.EmailConfirmed.Value;
propertiesChanged = true;
}
var admins = await _UserManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var roles = await _UserManager.GetRolesAsync(user);
var wasAdmin = Roles.HasServerAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = "This is the only Admin, so their role can't be removed until another Admin is added.";
return View(viewModel); // return
return View(viewModel);
}
if (viewModel.IsAdmin != wasAdmin)
{
var success = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
if (success)
adminStatusChanged = await _userService.SetAdminUser(user.Id, viewModel.IsAdmin);
}
if (propertiesChanged is true)
{
propertiesChanged = await _UserManager.UpdateAsync(user) is { Succeeded: true };
}
if (propertiesChanged.HasValue || adminStatusChanged.HasValue || approvalStatusChanged.HasValue)
{
if (propertiesChanged is not false && adminStatusChanged is not false && approvalStatusChanged is not false)
{
TempData[WellKnownTempData.SuccessMessage] = "User successfully updated";
}
@ -121,23 +140,22 @@ namespace BTCPayServer.Controllers
}
}
return RedirectToAction(nameof(User), new { userId = userId });
return RedirectToAction(nameof(User), new { userId });
}
[Route("server/users/new")]
[HttpGet]
[HttpGet("server/users/new")]
public IActionResult CreateUser()
{
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
return View();
}
[Route("server/users/new")]
[HttpPost]
[HttpPost("server/users/new")]
public async Task<IActionResult> CreateUser(RegisterFromAdminViewModel model)
{
var requiresConfirmedEmail = _policiesSettings.RequiresConfirmedEmail;
ViewData["AllowRequestEmailConfirmation"] = requiresConfirmedEmail;
ViewData["AllowRequestApproval"] = _policiesSettings.RequiresUserApproval;
ViewData["AllowRequestEmailConfirmation"] = _policiesSettings.RequiresConfirmedEmail;
if (!_Options.CheatMode)
model.IsAdmin = false;
if (ModelState.IsValid)
@ -148,7 +166,9 @@ namespace BTCPayServer.Controllers
UserName = model.Email,
Email = model.Email,
EmailConfirmed = model.EmailConfirmed,
RequiresEmailConfirmation = requiresConfirmedEmail,
RequiresEmailConfirmation = _policiesSettings.RequiresConfirmedEmail,
RequiresApproval = _policiesSettings.RequiresUserApproval,
Approved = model.Approved,
Created = DateTimeOffset.UtcNow
};
@ -223,7 +243,6 @@ namespace BTCPayServer.Controllers
{
if (await _userService.IsUserTheOnlyOneAdmin(user))
{
// return
return View("Confirm", new ConfirmModel("Delete admin",
$"Unable to proceed: As the user <strong>{Html.Encode(user.Email)}</strong> is the last enabled admin, it cannot be removed."));
}
@ -281,6 +300,29 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUser(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
return View("Confirm", new ConfirmModel($"{(approved ? "Approve" : "Unapprove")} user", $"The user <strong>{Html.Encode(user.Email)}</strong> will be {(approved ? "approved" : "unapproved")}. Are you sure?", (approved ? "Approve" : "Unapprove")));
}
[HttpPost("server/users/{userId}/approve")]
public async Task<IActionResult> ApproveUserPost(string userId, bool approved)
{
var user = userId == null ? null : await _UserManager.FindByIdAsync(userId);
if (user == null)
return NotFound();
await _userService.SetUserApproval(userId, approved, Request.GetAbsoluteRootUri());
TempData[WellKnownTempData.SuccessMessage] = $"User {(approved ? "approved" : "unapproved")}";
return RedirectToAction(nameof(ListUsers));
}
[HttpGet("server/users/{userId}/verification-email")]
public async Task<IActionResult> SendVerificationEmail(string userId)
{
@ -332,5 +374,8 @@ namespace BTCPayServer.Controllers
[Display(Name = "Email confirmed?")]
public bool EmailConfirmed { get; set; }
[Display(Name = "User approved?")]
public bool Approved { get; set; }
}
}

@ -27,22 +27,20 @@ namespace BTCPayServer.Controllers
return NotFound();
var blob = store.GetStoreBlob();
var storeSetupComplete = blob.EmailSettings?.IsComplete() is true;
if (!storeSetupComplete && !TempData.HasStatusMessage())
if (blob.EmailSettings?.IsComplete() is not true && !TempData.HasStatusMessage())
{
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var hasServerFallback = await IsSetupComplete(emailSender?.FallbackSender);
var message = hasServerFallback
? "Emails will be sent with the email settings of the server"
: "You need to configure email settings before this feature works";
TempData.SetStatusMessageModel(new StatusMessageModel
if (!await IsSetupComplete(emailSender?.FallbackSender))
{
Severity = hasServerFallback ? StatusMessageModel.StatusSeverity.Info : StatusMessageModel.StatusSeverity.Warning,
Html = $"{message}. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Warning,
Html = $"You need to configure email settings before this feature works. <a class='alert-link' href='{Url.Action("StoreEmailSettings", new { storeId })}'>Configure store email settings</a>."
});
}
}
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? new List<StoreEmailRule>() };
var vm = new StoreEmailRuleViewModel { Rules = blob.EmailRules ?? [] };
return View(vm);
}
@ -172,13 +170,20 @@ namespace BTCPayServer.Controllers
}
[HttpGet("{storeId}/email-settings")]
public IActionResult StoreEmailSettings()
public async Task<IActionResult> StoreEmailSettings(string storeId)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var data = store.GetStoreBlob().EmailSettings ?? new EmailSettings();
return View(new EmailsViewModel(data));
var blob = store.GetStoreBlob();
var data = blob.EmailSettings ?? new EmailSettings();
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
var vm = new EmailsViewModel(data, fallbackSettings);
return View(vm);
}
[HttpPost("{storeId}/email-settings")]
@ -187,7 +192,13 @@ namespace BTCPayServer.Controllers
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var emailSender = await _emailSenderFactory.GetEmailSender(store.Id) as StoreEmailSender;
var fallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender
? await storeSender.FallbackSender.GetEmailSettings()
: null;
model.FallbackSettings = fallbackSettings;
if (command == "Test")
{
try
@ -230,7 +241,7 @@ namespace BTCPayServer.Controllers
return View(model);
}
var storeBlob = store.GetStoreBlob();
if (new EmailsViewModel(storeBlob.EmailSettings).PasswordSet && storeBlob.EmailSettings != null)
if (storeBlob.EmailSettings != null && new EmailsViewModel(storeBlob.EmailSettings, fallbackSettings).PasswordSet)
{
model.Settings.Password = storeBlob.EmailSettings.Password;
}

@ -42,15 +42,7 @@ namespace BTCPayServer.Controllers
psbtDestination.Amount = Money.Coins(transactionOutput.Amount.Value);
psbtDestination.SubstractFees = transactionOutput.SubtractFeesFromOutput;
}
if (network.SupportRBF)
{
if (sendModel.AllowFeeBump is WalletSendModel.ThreeStateBool.Yes)
psbtRequest.RBF = true;
if (sendModel.AllowFeeBump is WalletSendModel.ThreeStateBool.No)
psbtRequest.RBF = false;
}
psbtRequest.RBF = network.SupportRBF ? true : null;
psbtRequest.AlwaysIncludeNonWitnessUTXO = sendModel.AlwaysIncludeNonWitnessUTXO;
psbtRequest.FeePreference = new FeePreference();

@ -513,8 +513,6 @@ namespace BTCPayServer.Controllers
recommendedFees.Select(tuple => tuple.GetAwaiter().GetResult()).Where(option => option != null).ToList();
model.FeeSatoshiPerByte = recommendedFees[1].GetAwaiter().GetResult()?.FeeRate;
model.SupportRBF = network.SupportRBF;
model.CryptoDivisibility = network.Divisibility;
using (CancellationTokenSource cts = new CancellationTokenSource())
{
@ -570,7 +568,6 @@ namespace BTCPayServer.Controllers
if (network == null || network.ReadonlyWallet)
return NotFound();
vm.SupportRBF = network.SupportRBF;
vm.NBXSeedAvailable = await GetSeed(walletId, network) != null;
if (!string.IsNullOrEmpty(bip21))
{

@ -202,7 +202,8 @@ namespace BTCPayServer.Data
{ "JPY", "bitbank" },
{ "TRY", "btcturk" },
{ "UGX", "yadio"},
{ "RSD", "bitpay"}
{ "RSD", "bitpay"},
{ "NGN", "bitnob"}
};
public string GetRecommendedExchange() =>

@ -0,0 +1,12 @@
using System;
using BTCPayServer.Data;
namespace BTCPayServer.Events
{
public class UserApprovedEvent
{
public ApplicationUser User { get; set; }
public bool Approved { get; set; }
public Uri RequestUri { get; set; }
}
}

@ -1,11 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using BTCPayServer.Configuration;
using BTCPayServer.Logging;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using Microsoft.Extensions.Logging;

@ -23,6 +23,12 @@ namespace BTCPayServer.Services
$"Please confirm your account by clicking this link: <a href='{HtmlEncoder.Default.Encode(link)}'>link</a>");
}
public static void SendApprovalConfirmation(this IEmailSender emailSender, MailboxAddress address, string link)
{
emailSender.SendEmail(address, "Your account has been approved",
$"Your account has been approved and you can now <a href='{HtmlEncoder.Default.Encode(link)}'>login here</a>");
}
public static void SendSetPasswordConfirmation(this IEmailSender emailSender, MailboxAddress address, string link, bool newPassword)
{
var subject = $"{(newPassword ? "Set" : "Update")} Password";

@ -29,6 +29,11 @@ namespace Microsoft.AspNetCore.Mvc
new { userId, code }, scheme, host, pathbase);
}
public static string LoginLink(this LinkGenerator urlHelper, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(nameof(UIAccountController.Login), "UIAccount", null , scheme, host, pathbase);
}
public static string ResetPasswordCallbackLink(this LinkGenerator urlHelper, string userId, string code, string scheme, HostString host, string pathbase)
{
return urlHelper.GetUriByAction(

@ -7,11 +7,13 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Rating;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Rates;
@ -47,7 +49,7 @@ namespace BTCPayServer.HostedServices
public class PullPaymentHostedService : BaseAsyncService
{
private readonly string[] _lnurlSupportedCurrencies = { "BTC", "SATS" };
public class CancelRequest
{
public CancelRequest(string pullPaymentId)
@ -108,6 +110,25 @@ namespace BTCPayServer.HostedServices
}
}
public Task<string> CreatePullPayment(string storeId, CreatePullPaymentRequest request)
{
return CreatePullPayment(new CreatePullPayment()
{
StartsAt = request.StartsAt,
ExpiresAt = request.ExpiresAt,
Period = request.Period,
BOLT11Expiration = request.BOLT11Expiration,
Name = request.Name,
Description = request.Description,
Amount = request.Amount,
Currency = request.Currency,
StoreId = storeId,
PaymentMethodIds = request.PaymentMethods.Select(p => PaymentMethodId.Parse(p)).ToArray(),
AutoApproveClaims = request.AutoApproveClaims,
EmbeddedCSS = request.EmbeddedCSS,
CustomCSSLink = request.CustomCSSLink
});
}
public async Task<string> CreatePullPayment(CreatePullPayment create)
{
ArgumentNullException.ThrowIfNull(create);
@ -173,7 +194,15 @@ namespace BTCPayServer.HostedServices
var query = ctx.Payouts.AsQueryable();
if (payoutQuery.States is not null)
{
query = query.Where(data => payoutQuery.States.Contains(data.State));
if (payoutQuery.States.Length == 1)
{
var state = payoutQuery.States[0];
query = query.Where(data => data.State == state);
}
else
{
query = query.Where(data => payoutQuery.States.Contains(data.State));
}
}
if (payoutQuery.PullPayments is not null)
@ -196,12 +225,28 @@ namespace BTCPayServer.HostedServices
if (payoutQuery.PaymentMethods is not null)
{
query = query.Where(data => payoutQuery.PaymentMethods.Contains(data.PaymentMethodId));
if (payoutQuery.PaymentMethods.Length == 1)
{
var pm = payoutQuery.PaymentMethods[0];
query = query.Where(data => pm == data.PaymentMethodId);
}
else
{
query = query.Where(data => payoutQuery.PaymentMethods.Contains(data.PaymentMethodId));
}
}
if (payoutQuery.Stores is not null)
{
query = query.Where(data => payoutQuery.Stores.Contains(data.StoreDataId));
if (payoutQuery.Stores.Length == 1)
{
var store = payoutQuery.Stores[0];
query = query.Where(data => store == data.StoreDataId);
}
else
{
query = query.Where(data => payoutQuery.Stores.Contains(data.StoreDataId));
}
}
if (payoutQuery.IncludeStoreData)
{
@ -239,7 +284,7 @@ namespace BTCPayServer.HostedServices
return await query.FirstOrDefaultAsync(data => data.Id == pullPaymentId);
}
record TopUpRequest(string PullPaymentId, InvoiceEntity InvoiceEntity);
class PayoutRequest
{
public PayoutRequest(TaskCompletionSource<ClaimRequest.ClaimResponse> completionSource,
@ -249,6 +294,8 @@ namespace BTCPayServer.HostedServices
ArgumentNullException.ThrowIfNull(completionSource);
Completion = completionSource;
ClaimRequest = request;
if (request.StoreId is null)
throw new ArgumentNullException(nameof(request.StoreId));
}
public TaskCompletionSource<ClaimRequest.ClaimResponse> Completion { get; set; }
@ -299,10 +346,20 @@ namespace BTCPayServer.HostedServices
{
payoutHandler.StartBackgroundCheck(Subscribe);
}
_eventAggregator.Subscribe<Events.InvoiceEvent>(TopUpInvoice);
return new[] { Loop() };
}
private void TopUpInvoice(InvoiceEvent evt)
{
if (evt.EventCode == InvoiceEventCode.Completed)
{
foreach (var pullPaymentId in evt.Invoice.GetInternalTags("PULLPAY#"))
{
_Channel.Writer.TryWrite(new TopUpRequest(pullPaymentId, evt.Invoice));
}
}
}
private void Subscribe(params Type[] events)
{
foreach (Type @event in events)
@ -315,6 +372,10 @@ namespace BTCPayServer.HostedServices
{
await foreach (var o in _Channel.Reader.ReadAllAsync())
{
if (o is TopUpRequest topUp)
{
await HandleTopUp(topUp);
}
if (o is PayoutRequest req)
{
await HandleCreatePayout(req);
@ -349,10 +410,40 @@ namespace BTCPayServer.HostedServices
}
}
private async Task HandleTopUp(TopUpRequest topUp)
{
var pp = await this.GetPullPayment(topUp.PullPaymentId, false);
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = pp.Id,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = pp.StoreId
};
var rate = topUp.InvoiceEntity.Rates["BTC"];
var cryptoAmount = Math.Round(topUp.InvoiceEntity.PaidAmount.Net / rate, 11);
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -topUp.InvoiceEntity.PaidAmount.Net,
Destination = null,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
}
public bool SupportsLNURL(PullPaymentBlob blob)
{
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
var pms = blob.SupportedPaymentMethods.FirstOrDefault(id =>
id.PaymentType == LightningPaymentType.Instance &&
_networkProvider.DefaultNetwork.CryptoCode == id.CryptoCode);
return pms is not null && _lnurlSupportedCurrencies.Contains(blob.Currency);
}
@ -609,7 +700,7 @@ namespace BTCPayServer.HostedServices
{
Amount = claimed,
Destination = req.ClaimRequest.Destination.ToString(),
Metadata = req.ClaimRequest.Metadata?? new JObject(),
Metadata = req.ClaimRequest.Metadata ?? new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
@ -802,6 +893,10 @@ namespace BTCPayServer.HostedServices
return time;
}
public static string GetInternalTag(string id)
{
return $"PULLPAY#{id}";
}
class InternalPayoutPaidRequest
{
@ -856,25 +951,25 @@ namespace BTCPayServer.HostedServices
{
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
null when destination.Amount is null => (null, null),
null when destination.Amount != null => (null,destination.Amount),
not null when destination.Amount is null => (null,amount),
null when destination.Amount != null => (null, destination.Amount),
not null when destination.Amount is null => (null, amount),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
destination.IsExplicitAmountMinimum &&
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
amount < destination.Amount =>
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount", null),
not null when destination.Amount != null && amount != destination.Amount &&
!destination.IsExplicitAmountMinimum =>
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
_ => (null, amount)
};
}
public static string GetErrorMessage(ClaimResult result)
{
switch (result)

@ -6,6 +6,8 @@ using BTCPayServer.Events;
using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Mails;
using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
@ -19,20 +21,27 @@ namespace BTCPayServer.HostedServices
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly EmailSenderFactory _emailSenderFactory;
private readonly NotificationSender _notificationSender;
private readonly LinkGenerator _generator;
public UserEventHostedService(EventAggregator eventAggregator, UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory, LinkGenerator generator, Logs logs) : base(eventAggregator, logs)
public UserEventHostedService(
EventAggregator eventAggregator,
UserManager<ApplicationUser> userManager,
EmailSenderFactory emailSenderFactory,
NotificationSender notificationSender,
LinkGenerator generator,
Logs logs) : base(eventAggregator, logs)
{
_userManager = userManager;
_emailSenderFactory = emailSenderFactory;
_notificationSender = notificationSender;
_generator = generator;
}
protected override void SubscribeToEvents()
{
Subscribe<UserRegisteredEvent>();
Subscribe<UserApprovedEvent>();
Subscribe<UserPasswordResetRequestedEvent>();
}
@ -40,30 +49,39 @@ namespace BTCPayServer.HostedServices
{
string code;
string callbackUrl;
Uri uri;
HostString host;
ApplicationUser user;
MailboxAddress address;
IEmailSender emailSender;
UserPasswordResetRequestedEvent userPasswordResetRequestedEvent;
switch (evt)
{
case UserRegisteredEvent userRegisteredEvent:
user = userRegisteredEvent.User;
Logs.PayServer.LogInformation(
$"A new user just registered {userRegisteredEvent.User.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
if (!userRegisteredEvent.User.EmailConfirmed && userRegisteredEvent.User.RequiresEmailConfirmation)
$"A new user just registered {user.Email} {(userRegisteredEvent.Admin ? "(admin)" : "")}");
if (user.RequiresApproval && !user.Approved)
{
code = await _userManager.GenerateEmailConfirmationTokenAsync(userRegisteredEvent.User);
callbackUrl = _generator.EmailConfirmationLink(userRegisteredEvent.User.Id, code,
userRegisteredEvent.RequestUri.Scheme,
new HostString(userRegisteredEvent.RequestUri.Host, userRegisteredEvent.RequestUri.Port),
userRegisteredEvent.RequestUri.PathAndQuery);
await _notificationSender.SendNotification(new AdminScope(), new NewUserRequiresApprovalNotification(user));
}
if (!user.EmailConfirmed && user.RequiresEmailConfirmation)
{
uri = userRegisteredEvent.RequestUri;
host = new HostString(uri.Host, uri.Port);
code = await _userManager.GenerateEmailConfirmationTokenAsync(user);
callbackUrl = _generator.EmailConfirmationLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
userRegisteredEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
address = userRegisteredEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender()).SendEmailConfirmation(address, callbackUrl);
address = user.GetMailboxAddress();
emailSender = await _emailSenderFactory.GetEmailSender();
emailSender.SendEmailConfirmation(address, callbackUrl);
}
else if (!await _userManager.HasPasswordAsync(userRegisteredEvent.User))
{
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent()
userPasswordResetRequestedEvent = new UserPasswordResetRequestedEvent
{
CallbackUrlGenerated = userRegisteredEvent.CallbackUrlGenerated,
User = userRegisteredEvent.User,
User = user,
RequestUri = userRegisteredEvent.RequestUri
};
goto passwordSetter;
@ -72,22 +90,33 @@ namespace BTCPayServer.HostedServices
{
userRegisteredEvent.CallbackUrlGenerated?.SetResult(null);
}
break;
case UserApprovedEvent userApprovedEvent:
if (userApprovedEvent.Approved)
{
uri = userApprovedEvent.RequestUri;
host = new HostString(uri.Host, uri.Port);
address = userApprovedEvent.User.GetMailboxAddress();
callbackUrl = _generator.LoginLink(uri.Scheme, host, uri.PathAndQuery);
emailSender = await _emailSenderFactory.GetEmailSender();
emailSender.SendApprovalConfirmation(address, callbackUrl);
}
break;
case UserPasswordResetRequestedEvent userPasswordResetRequestedEvent2:
userPasswordResetRequestedEvent = userPasswordResetRequestedEvent2;
passwordSetter:
code = await _userManager.GeneratePasswordResetTokenAsync(userPasswordResetRequestedEvent.User);
var newPassword = await _userManager.HasPasswordAsync(userPasswordResetRequestedEvent.User);
callbackUrl = _generator.ResetPasswordCallbackLink(userPasswordResetRequestedEvent.User.Id, code,
userPasswordResetRequestedEvent.RequestUri.Scheme,
new HostString(userPasswordResetRequestedEvent.RequestUri.Host,
userPasswordResetRequestedEvent.RequestUri.Port),
userPasswordResetRequestedEvent.RequestUri.PathAndQuery);
uri = userPasswordResetRequestedEvent.RequestUri;
host = new HostString(uri.Host, uri.Port);
user = userPasswordResetRequestedEvent.User;
code = await _userManager.GeneratePasswordResetTokenAsync(user);
var newPassword = await _userManager.HasPasswordAsync(user);
callbackUrl = _generator.ResetPasswordCallbackLink(user.Id, code, uri.Scheme, host, uri.PathAndQuery);
userPasswordResetRequestedEvent.CallbackUrlGenerated?.SetResult(new Uri(callbackUrl));
address = userPasswordResetRequestedEvent.User.GetMailboxAddress();
(await _emailSenderFactory.GetEmailSender())
.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
address = user.GetMailboxAddress();
emailSender = await _emailSenderFactory.GetEmailSender();
emailSender.SendSetPasswordConfirmation(address, callbackUrl, newPassword);
break;
}
}

@ -436,8 +436,8 @@ namespace BTCPayServer.Hosting
services.AddScoped<IAuthorizationHandler, BitpayAuthorizationHandler>();
services.AddSingleton<INotificationHandler, NewVersionNotification.Handler>();
services.AddSingleton<INotificationHandler, NewUserRequiresApprovalNotification.Handler>();
services.AddSingleton<INotificationHandler, PluginUpdateNotification.Handler>();
services.AddSingleton<INotificationHandler, InvoiceEventNotification.Handler>();
services.AddSingleton<INotificationHandler, PayoutNotification.Handler>();
services.AddSingleton<INotificationHandler, ExternalPayoutTransactionNotification.Handler>();
@ -551,6 +551,7 @@ namespace BTCPayServer.Hosting
services.AddRateProvider<ByllsRateProvider>();
services.AddRateProvider<BudaRateProvider>();
services.AddRateProvider<BitbankRateProvider>();
services.AddRateProvider<BitnobRateProvider>();
services.AddRateProvider<BitpayRateProvider>();
services.AddRateProvider<RipioExchangeProvider>();
services.AddRateProvider<CryptoMarketExchangeRateProvider>();

@ -6,25 +6,27 @@ namespace BTCPayServer.Models.ServerViewModels
{
public class EmailsViewModel
{
public EmailSettings Settings { get; set; }
public EmailSettings FallbackSettings { get; set; }
public bool PasswordSet { get; set; }
[MailboxAddress]
[Display(Name = "Test Email")]
public string TestEmail { get; set; }
public EmailsViewModel()
{
}
public EmailsViewModel(EmailSettings settings)
public EmailsViewModel(EmailSettings settings, EmailSettings fallbackSettings = null)
{
Settings = settings;
FallbackSettings = fallbackSettings;
PasswordSet = !string.IsNullOrEmpty(settings?.Password);
}
public EmailSettings Settings
{
get; set;
}
public bool PasswordSet { get; set; }
[MailboxAddressAttribute]
[Display(Name = "Test Email")]
public string TestEmail
{
get; set;
}
public bool IsSetup() => Settings?.IsComplete() is true;
public bool IsFallbackSetup() => FallbackSettings?.IsComplete() is true;
public bool UsesFallback() => IsFallbackSetup() && Settings == FallbackSettings;
}
}

@ -11,7 +11,8 @@ namespace BTCPayServer.Models.ServerViewModels
public string Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
public bool Verified { get; set; }
public bool? EmailConfirmed { get; set; }
public bool? Approved { get; set; }
public bool Disabled { get; set; }
public bool IsAdmin { get; set; }
public DateTimeOffset? Created { get; set; }

@ -71,6 +71,9 @@ namespace BTCPayServer.Models
public PaymentMethodId[] PaymentMethods { get; set; }
public string SetupDeepLink { get; set; }
public string ResetDeepLink { get; set; }
public string HubPath { get; set; }
public string ResetIn { get; set; }
public string Email { get; set; }

@ -55,11 +55,8 @@ namespace BTCPayServer.Models.WalletViewModels
public int CryptoDivisibility { get; set; }
public string Fiat { get; set; }
public string RateError { get; set; }
public bool SupportRBF { get; set; }
[Display(Name = "Always include non-witness UTXO if available")]
public bool AlwaysIncludeNonWitnessUTXO { get; set; }
[Display(Name = "Allow fee increase (RBF)")]
public ThreeStateBool AllowFeeBump { get; set; }
public bool NBXSeedAvailable { get; set; }
[Display(Name = "PayJoin BIP21")]

@ -88,11 +88,11 @@ public class LightningAutomatedPayoutProcessor : BaseAutomatedPayoutProcessor<Li
{
continue;
}
failed = await TrypayBolt(client, blob, payoutData,
failed = !await TrypayBolt(client, blob, payoutData,
lnurlResult.Item1);
break;
case BoltInvoiceClaimDestination item1:
failed = await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
failed = !await TrypayBolt(client, blob, payoutData, item1.PaymentRequest);
break;
}
}

@ -0,0 +1,35 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins.BoltcardBalance
{
public class BoltcardBalancePlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardBalance/Views";
public const string AppType = "BoltcardBalance";
public override string Identifier => "BTCPayServer.Plugins.BoltcardBalance";
public override string Name => "BoltcardBalance";
public override string Description => "Add ability to check the history and balance of a Boltcard";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
base.Execute(services);
}
}
}

@ -0,0 +1,111 @@
using System;
using System.Linq;
using System.Reflection.Metadata;
using System.Threading.Tasks;
using AngleSharp.Dom;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardBalance.ViewModels;
using BTCPayServer.Plugins.BoltcardFactory;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Plugins.BoltcardBalance.Controllers
{
[AutoValidateAntiforgeryToken]
public class UIBoltcardBalanceController : Controller
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly BTCPayNetworkJsonSerializerSettings _serializerSettings;
public UIBoltcardBalanceController(
ApplicationDbContextFactory dbContextFactory,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
BTCPayNetworkJsonSerializerSettings serializerSettings)
{
_dbContextFactory = dbContextFactory;
_settingsRepository = settingsRepository;
_env = env;
_serializerSettings = serializerSettings;
}
[HttpGet("boltcards/balance")]
public async Task<IActionResult> ScanCard([FromQuery] string p = null, [FromQuery] string c = null)
{
if (p is null || c is null)
{
return View($"{BoltcardBalancePlugin.ViewsDirectory}/ScanCard.cshtml");
}
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BalanceViewModel()
//{
// AmountDue = 10000m,
// Currency = "SATS",
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }]
//});
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
var boltData = issuerKey.TryDecrypt(p);
if (boltData?.Uid is null)
return NotFound();
var id = issuerKey.GetId(boltData.Uid);
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true);
if (registration is null)
return NotFound();
return await GetBalanceView(registration.PullPaymentId, p);
}
[NonAction]
public async Task<IActionResult> GetBalanceView(string ppId, string p)
{
using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(ppId);
if (pp is null)
return NotFound();
var blob = pp.GetBlob();
var payouts = (await ctx.Payouts.GetPayoutInPeriod(pp)
.OrderByDescending(o => o.Date)
.ToListAsync())
.Select(o => new
{
Entity = o,
Blob = o.GetBlob(_serializerSettings)
});
var totalPaid = payouts.Where(p => p.Entity.State != PayoutState.Cancelled).Select(p => p.Blob.Amount).Sum();
var bech32LNUrl = new Uri(Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, Request.Scheme), UriKind.Absolute);
bech32LNUrl = LNURL.LNURL.EncodeUri(bech32LNUrl, "payRequest", true);
var vm = new BalanceViewModel()
{
Currency = blob.Currency,
AmountDue = blob.Limit - totalPaid,
LNUrlBech32 = bech32LNUrl.AbsoluteUri,
LNUrlPay = Url.Action(nameof(UIBoltcardController.GetPayRequest), "UIBoltcard", new { p }, "lnurlp")
};
foreach (var payout in payouts)
{
vm.Transactions.Add(new BalanceViewModel.Transaction()
{
Date = payout.Entity.Date,
Balance = -payout.Blob.Amount,
Status = payout.Entity.State
});
}
vm.Transactions.Add(new BalanceViewModel.Transaction()
{
Date = pp.StartDate,
Balance = blob.Limit,
Status = PayoutState.Completed
});
return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", vm);
}
}
}

@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Plugins.BoltcardBalance.ViewModels
{
public class BalanceViewModel
{
public class Transaction
{
public DateTimeOffset Date { get; set; }
public bool Positive => Balance >= 0;
public decimal Balance { get; set; }
public PayoutState Status { get; internal set; }
}
public string Currency { get; set; }
public decimal AmountDue { get; set; }
public List<Transaction> Transactions { get; set; } = new List<Transaction>();
public string LNUrlBech32 { get; set; }
public string LNUrlPay { get; set; }
}
}

@ -0,0 +1,73 @@
@using BTCPayServer.Plugins.BoltcardBalance.ViewModels
@using BTCPayServer.Services
@inject DisplayFormatter DisplayFormatter
@model BalanceViewModel
@{
Layout = null;
}
<div class="col col-12 col-lg-12 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<nav id="wizard-navbar">
@if (this.ViewData["NoCancelWizard"] is not true)
{
<button type="button" class="btn btn-secondary only-for-js mt-4" id="lnurlwithdraw-button">
<span class="fa fa-qrcode fa-2x" title="Deposit"></span>
</button>
<a href="#" id="CancelWizard" class="cancel mt-4">
<vc:icon symbol="close" />
</a>
}
</nav>
<div class="d-flex justify-content-center">
<div class="d-flex flex-column justify-content-center align-items-center">
<dl class="mb-0 mt-md-4">
<div class="d-flex d-print-inline-block flex-column mb-4">
<dt class="h4 fw-semibold text-nowrap text-primary text-print-default order-2 order-sm-1 mb-1">@DisplayFormatter.Currency(Model.AmountDue, Model.Currency)</dt>
</div>
</dl>
@* <div class="lnurl-pay d-none">
<vc:qr-code data="@Model.LNUrlPay" />
</div>
<div class="lnurl-pay d-flex gap-3 mt-3 mt-sm-0 d-none">
<a class="btn btn-primary" target="_blank" href="@Model.LNUrlPay">Deposit from Wallet... (LNURLPay)</a>
</div> *@
<div class="lnurl-pay d-none">
<vc:qr-code data="@Model.LNUrlBech32" />
</div>
<div class="lnurl-pay d-flex gap-3 mt-3 mt-sm-0 d-none">
<a class="btn btn-primary" target="_blank" href="@Model.LNUrlBech32">Deposit from Wallet...</a>
</div>
</div>
</div>
</div>
</div>
@if (Model.Transactions.Any())
{
<div class="col col-12 col-lg-12 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th class="date-col">Date</th>
<th class="amount-col">Amount</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
<tr>
<td class="date-col">@tx.Date.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)</td>
<td class="amount-col">
<span data-sensitive class="text-@(tx.Positive ? "success" : "danger")">@DisplayFormatter.Currency(tx.Balance, Model.Currency, DisplayFormatter.CurrencyFormat.Code)</span>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}

@ -0,0 +1,14 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
<li class="nav-item">
<a asp-area="" asp-controller="UIBoltcardBalance" asp-action="ScanCard" class="nav-link">
<vc:icon symbol="pay-button" />
<span>Boltcard Balance</span>
</a>
</li>

@ -0,0 +1,161 @@
@{
ViewData["Title"] = "Boltcard Balances";
ViewData["ShowFooter"] = false;
Layout = "/Views/Shared/_LayoutWizard.cshtml";
}
@section PageHeadContent
{
<style>
.amount-col {
text-align: right;
white-space: nowrap;
}
</style>
}
<header class="text-center">
<h1>Consult balance</h1>
<p class="lead text-secondary mt-3" id="explanation">Scan your card for consulting the balance</p>
</header>
<div id="body" class="my-4">
<div id="actions" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="start-scan-btn" class="btn btn-primary" href="#">Ask permission...</a>
</div>
</div>
<div id="qr" class="d-flex flex-column align-items-center justify-content-center d-none">
<div class="d-inline-flex flex-column" style="width:256px">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
</div>
</div>
<p class="text-secondary">NFC not supported in this device</p>
</div>
<div id="scanning-btn" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="scanning-btn-link" class="action-button" style="font-size: 50px;" ></a>
</div>
</div>
<div id="balance" class="row">
<div id="balance-table"></div>
</div>
</div>
<script>
(function () {
var permissionGranted = false;
var ndef = null;
var abortController = null;
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function showBalance(lnurlw) {
setState("Submitting");
await delay(1000);
var url = window.location.href.replace("#", "");
url = url.split("?")[0] + "?" + lnurlw.split("?")[1];
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
document.getElementById("balance-table").innerHTML = this.responseText;
document.getElementById("CancelWizard").addEventListener("click", function (e) {
e.preventDefault();
setState("WaitingForCard");
document.getElementById("balance-table").innerHTML = "";
});
document.getElementById("lnurlwithdraw-button").addEventListener("click", function (e) {
var el = document.getElementsByClassName("lnurl-pay");
for (var i = 0; i < el.length; i++) {
if (el[i].classList.contains("d-none"))
el[i].classList.remove("d-none");
else
el[i].classList.add("d-none");
}
});
setState("ShowBalance");
}
else {
setState("WaitingForCard");
}
};
xhttp.open('GET', url, true);
xhttp.send(new FormData());
}
async function startScan() {
if (!('NDEFReader' in window)) {
return;
}
ndef = new NDEFReader();
abortController = new AbortController();
abortController.signal.onabort = () => setState("WaitingForCard");
await ndef.scan({ signal: abortController.signal })
setState("WaitingForCard");
ndef.onreading = async ({ message }) => {
const record = message.records[0];
const textDecoder = new TextDecoder('utf-8');
const decoded = textDecoder.decode(record.data);
await showBalance(decoded);
};
}
function setState(state)
{
document.getElementById("actions").classList.add("d-none");
document.getElementById("qr").classList.add("d-none");
document.getElementById("scanning-btn").classList.add("d-none");
document.getElementById("balance").classList.add("d-none");
if (state === "NFCNotSupported")
{
document.getElementById("qr").classList.remove("d-none");
}
else if (state === "WaitingForPermission")
{
document.getElementById("actions").classList.remove("d-none");
}
else if (state === "WaitingForCard")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
}
else if (state == "Submitting")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-spinner\"></i>"
}
else if (state == "ShowBalance") {
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
document.getElementById("balance").classList.remove("d-none");
}
}
document.addEventListener("DOMContentLoaded", async () => {
var nfcSupported = 'NDEFReader' in window;
if (!nfcSupported) {
setState("NFCNotSupported");
}
else {
setState("WaitingForPermission");
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
if (granted)
{
setState("WaitingForCard");
startScan();
}
}
delegate('click', "#start-scan-btn", startScan);
//showBalance("lnurl://ewfw?p=test&c=test");
});
})();
</script>

@ -0,0 +1,74 @@
#nullable enable
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Plugins.BoltcardFactory.Controllers;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins.BoltcardFactory
{
public class BoltcardFactoryPlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardFactory/Views";
public const string AppType = "BoltcardFactory";
public override string Identifier => "BTCPayServer.Plugins.BoltcardFactory";
public override string Name => "BoltcardFactory";
public override string Description => "Allow the creation of a consequential number of Boltcards in an efficient way";
internal class BoltcardFactoryAppType : AppBaseType
{
private readonly LinkGenerator _linkGenerator;
private readonly IOptions<BTCPayServerOptions> _btcPayServerOptions;
public BoltcardFactoryAppType(
LinkGenerator linkGenerator,
IOptions<BTCPayServerOptions> btcPayServerOptions)
{
Type = AppType;
Description = "Boltcard Factories";
_linkGenerator = linkGenerator;
_btcPayServerOptions = btcPayServerOptions;
}
public override Task<string> ConfigureLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.UpdateBoltcardFactory),
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
}
public override Task<object?> GetInfo(AppData appData)
{
return Task.FromResult<object?>(null);
}
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
{
appData.SetSettings(new CreatePullPaymentRequest());
return Task.CompletedTask;
}
public override Task<string> ViewLink(AppData app)
{
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(UIBoltcardFactoryController.ViewBoltcardFactory),
"UIBoltcardFactory", new { appId = app.Id }, _btcPayServerOptions.Value.RootPath)!);
}
}
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
services.AddSingleton<AppBaseType, BoltcardFactoryAppType>();
base.Execute(services);
}
}
}

@ -0,0 +1,150 @@
#nullable enable
using System;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
{
[ApiController]
[Route("apps")]
public class APIBoltcardFactoryController : ControllerBase
{
private readonly ILogger<APIBoltcardFactoryController> _logger;
private readonly AppService _appService;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly PullPaymentHostedService _ppService;
public APIBoltcardFactoryController(
ILogger<APIBoltcardFactoryController> logger,
AppService appService,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
ApplicationDbContextFactory dbContextFactory,
PullPaymentHostedService ppService)
{
_logger = logger;
_appService = appService;
_settingsRepository = settingsRepository;
_env = env;
_dbContextFactory = dbContextFactory;
_ppService = ppService;
}
[HttpPost("{appId}/boltcards")]
[AllowAnonymous]
public async Task<IActionResult> RegisterBoltcard(string appId, RegisterBoltcardRequest? request, string? onExisting = null)
{
var app = await _appService.GetApp(appId, BoltcardFactoryPlugin.AppType);
if (app is null)
return NotFound();
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
// LNURLW is used by deeplinks
if (request?.LNURLW is not null)
{
if (request.UID is not null)
{
_logger.LogInformation("You should pass either LNURLW or UID but not both");
ModelState.AddModelError(nameof(request.LNURLW), "You should pass either LNURLW or UID but not both");
return this.CreateValidationError(ModelState);
}
var p = ExtractP(request.LNURLW);
if (p is null)
{
_logger.LogInformation("The LNURLW should contains a 'p=' parameter");
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW should contains a 'p=' parameter");
return this.CreateValidationError(ModelState);
}
if (issuerKey.TryDecrypt(p) is not BoltcardPICCData picc)
{
_logger.LogInformation("The LNURLW 'p=' parameter cannot be decrypted");
ModelState.AddModelError(nameof(request.LNURLW), "The LNURLW 'p=' parameter cannot be decrypted");
return this.CreateValidationError(ModelState);
}
request.UID = picc.Uid;
}
if (request?.UID is null || request.UID.Length != 7)
{
_logger.LogInformation("The UID is required and should be 7 bytes");
ModelState.AddModelError(nameof(request.UID), "The UID is required and should be 7 bytes");
return this.CreateValidationError(ModelState);
}
// Passing onExisting as a query parameter is used by deeplink
request.OnExisting = onExisting switch
{
nameof(OnExistingBehavior.UpdateVersion) => OnExistingBehavior.UpdateVersion,
nameof(OnExistingBehavior.KeepVersion) => OnExistingBehavior.KeepVersion,
_ => request.OnExisting
};
int version;
string ppId;
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, request.UID);
if (request.OnExisting == OnExistingBehavior.UpdateVersion)
{
var req = app.GetSettings<CreatePullPaymentRequest>();
ppId = await _ppService.CreatePullPayment(app.StoreDataId, req);
version = await _dbContextFactory.LinkBoltcardToPullPayment(ppId, issuerKey, request.UID, request.OnExisting);
}
// If it's a reset, do not create a new pull payment
else
{
if (registration?.PullPaymentId is null)
{
_logger.LogInformation("This card isn't registered");
ModelState.AddModelError(nameof(request.UID), "This card isn't registered");
return this.CreateValidationError(ModelState);
}
ppId = registration.PullPaymentId;
version = registration.Version;
}
var keys = issuerKey.CreatePullPaymentCardKey(request.UID, version, ppId).DeriveBoltcardKeys(issuerKey);
var boltcardUrl = Url.Action(nameof(UIBoltcardController.GetWithdrawRequest), "UIBoltcard");
boltcardUrl = Request.GetAbsoluteUri(boltcardUrl);
boltcardUrl = Regex.Replace(boltcardUrl, "^https?://", "lnurlw://");
var resp = new RegisterBoltcardResponse()
{
LNURLW = boltcardUrl,
Version = version,
K0 = Encoders.Hex.EncodeData(keys.AppMasterKey.ToBytes()).ToUpperInvariant(),
K1 = Encoders.Hex.EncodeData(keys.EncryptionKey.ToBytes()).ToUpperInvariant(),
K2 = Encoders.Hex.EncodeData(keys.AuthenticationKey.ToBytes()).ToUpperInvariant(),
K3 = Encoders.Hex.EncodeData(keys.K3.ToBytes()).ToUpperInvariant(),
K4 = Encoders.Hex.EncodeData(keys.K4.ToBytes()).ToUpperInvariant(),
};
return Ok(resp);
}
private string? ExtractP(string? url)
{
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var uri))
return null;
int num = uri.AbsoluteUri.IndexOf('?');
if (num == -1)
return null;
string input = uri.AbsoluteUri.Substring(num);
Match match = Regex.Match(input, "p=([a-f0-9A-F]{32})");
if (!match.Success)
return null;
return match.Groups[1].Value;
}
}
}

@ -0,0 +1,220 @@
#nullable enable
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Filters;
using BTCPayServer.Models.WalletViewModels;
using BTCPayServer.Plugins.PointOfSale;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.Rendering;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Rates;
using System.Text.RegularExpressions;
using System;
using BTCPayServer.Services.Stores;
using BTCPayServer.Payments;
using Microsoft.AspNetCore.Identity;
using BTCPayServer.Client.Models;
using Org.BouncyCastle.Ocsp;
using BTCPayServer.NTag424;
using NBitcoin.DataEncoders;
using Newtonsoft.Json;
using BTCPayServer.Services;
using BTCPayServer.HostedServices;
using System.Threading;
using BTCPayServer.Plugins.BoltcardFactory.ViewModels;
using BTCPayServer.Controllers.Greenfield;
using BTCPayServer.Models;
using Microsoft.Extensions.Logging;
namespace BTCPayServer.Plugins.BoltcardFactory.Controllers
{
[AutoValidateAntiforgeryToken]
[Route("apps")]
public class UIBoltcardFactoryController : Controller
{
private readonly IEnumerable<IPayoutHandler> _payoutHandlers;
private readonly CurrencyNameTable _currencies;
private readonly AppService _appService;
private readonly StoreRepository _storeRepository;
private readonly CurrencyNameTable _currencyNameTable;
private readonly IAuthorizationService _authorizationService;
public UIBoltcardFactoryController(
IEnumerable<IPayoutHandler> payoutHandlers,
CurrencyNameTable currencies,
AppService appService,
StoreRepository storeRepository,
CurrencyNameTable currencyNameTable,
IAuthorizationService authorizationService)
{
_payoutHandlers = payoutHandlers;
_currencies = currencies;
_appService = appService;
_storeRepository = storeRepository;
_currencyNameTable = currencyNameTable;
_authorizationService = authorizationService;
}
public Data.StoreData CurrentStore => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData();
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpGet("{appId}/settings/boltcardfactory")]
public async Task<IActionResult> UpdateBoltcardFactory(string appId)
{
if (CurrentStore is null || GetCurrentApp() is null)
return NotFound();
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
if (!paymentMethods.Any())
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Message = "You must enable at least one payment method before creating a pull payment.",
Severity = StatusMessageModel.StatusSeverity.Error
});
return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = CurrentStore.Id });
}
var req = GetCurrentApp().GetSettings<CreatePullPaymentRequest>();
return base.View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
}
private static NewPullPaymentModel CreateViewModel(List<PaymentMethodId> paymentMethods, CreatePullPaymentRequest req)
{
return new NewPullPaymentModel
{
Name = req.Name,
Currency = req.Currency,
Amount = req.Amount,
AutoApproveClaims = req.AutoApproveClaims,
Description = req.Description,
PaymentMethods = req.PaymentMethods,
BOLT11Expiration = req.BOLT11Expiration?.TotalDays is double v ? (long)v : 30,
EmbeddedCSS = req.EmbeddedCSS,
CustomCSSLink = req.CustomCSSLink,
PaymentMethodItems =
paymentMethods.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true))
};
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[HttpPost("{appId}/settings/boltcardfactory")]
public async Task<IActionResult> UpdateBoltcardFactory(string appId, NewPullPaymentModel model)
{
if (CurrentStore is null)
return NotFound();
var storeId = CurrentStore.Id;
var paymentMethodOptions = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
model.PaymentMethodItems =
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), true));
model.Name ??= string.Empty;
model.Currency = model.Currency?.ToUpperInvariant()?.Trim() ?? String.Empty;
model.PaymentMethods ??= new List<string>();
if (!model.PaymentMethods.Any())
{
// Since we assign all payment methods to be selected by default above we need to update
// them here to reflect user's selection so that they can correct their mistake
model.PaymentMethodItems =
paymentMethodOptions.Select(id => new SelectListItem(id.ToPrettyString(), id.ToString(), false));
ModelState.AddModelError(nameof(model.PaymentMethods), "You need at least one payment method");
}
if (_currencyNameTable.GetCurrencyData(model.Currency, false) is null)
{
ModelState.AddModelError(nameof(model.Currency), "Invalid currency");
}
if (model.Amount <= 0.0m)
{
ModelState.AddModelError(nameof(model.Amount), "The amount should be more than zero");
}
if (model.Name.Length > 50)
{
ModelState.AddModelError(nameof(model.Name), "The name should be maximum 50 characters.");
}
var selectedPaymentMethodIds = model.PaymentMethods.Select(PaymentMethodId.Parse).ToArray();
if (!selectedPaymentMethodIds.All(id => selectedPaymentMethodIds.Contains(id)))
{
ModelState.AddModelError(nameof(model.Name), "Not all payment methods are supported");
}
if (!ModelState.IsValid)
return View(model);
model.AutoApproveClaims = model.AutoApproveClaims && (await
_authorizationService.AuthorizeAsync(User, CurrentStore.Id, Policies.CanCreatePullPayments)).Succeeded;
var req = new CreatePullPaymentRequest()
{
Name = model.Name,
Description = model.Description,
Currency = model.Currency,
CustomCSSLink = model.CustomCSSLink,
Amount = model.Amount,
AutoApproveClaims = model.AutoApproveClaims,
EmbeddedCSS = model.EmbeddedCSS,
BOLT11Expiration = TimeSpan.FromDays(model.BOLT11Expiration),
PaymentMethods = model.PaymentMethods.ToArray()
};
var app = GetCurrentApp();
app.SetSettings(req);
await _appService.UpdateOrCreateApp(app);
var paymentMethods = await _payoutHandlers.GetSupportedPaymentMethods(CurrentStore);
this.TempData.SetStatusMessageModel(new StatusMessageModel()
{
Message = "Pull payment request created",
Severity = StatusMessageModel.StatusSeverity.Success
});
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/UpdateBoltcardFactory.cshtml", CreateViewModel(paymentMethods, req));
}
private async Task<string?> GetStoreDefaultCurrentIfEmpty(string storeId, string? currency)
{
if (string.IsNullOrWhiteSpace(currency))
{
currency = (await _storeRepository.FindStore(storeId))?.GetStoreBlob()?.DefaultCurrency;
}
return currency?.Trim().ToUpperInvariant();
}
private int[] ListSplit(string list, string separator = ",")
{
if (string.IsNullOrEmpty(list))
{
return Array.Empty<int>();
}
// Remove all characters except numeric and comma
Regex charsToDestroy = new Regex(@"[^\d|\" + separator + "]");
list = charsToDestroy.Replace(list, "");
return list.Split(separator, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse).ToArray();
}
[HttpGet("/apps/{appId}/boltcardfactory")]
[DomainMappingConstraint(BoltcardFactoryPlugin.AppType)]
[AllowAnonymous]
public IActionResult ViewBoltcardFactory(string appId)
{
var vm = new ViewBoltcardFactoryViewModel();
vm.SetupDeepLink = $"boltcard://program?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.UpdateVersion)}";
vm.ResetDeepLink = $"boltcard://reset?url={GetBoltcardDeeplinkUrl(appId, OnExistingBehavior.KeepVersion)}";
return View($"{BoltcardFactoryPlugin.ViewsDirectory}/ViewBoltcardFactory.cshtml", vm);
}
private string GetBoltcardDeeplinkUrl(string appId, OnExistingBehavior onExisting)
{
var registerUrl = Url.Action(nameof(APIBoltcardFactoryController.RegisterBoltcard), "APIBoltcardFactory",
new
{
appId = appId,
onExisting = onExisting.ToString()
}, Request.Scheme, Request.Host.ToString());
registerUrl = Uri.EscapeDataString(registerUrl!);
return registerUrl;
}
}
}

@ -0,0 +1,7 @@
namespace BTCPayServer.Plugins.BoltcardFactory.ViewModels;
public class ViewBoltcardFactoryViewModel
{
public string SetupDeepLink { get; set; }
public string ResetDeepLink { get; set; }
}

@ -0,0 +1,37 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Plugins.PointOfSale
@using BTCPayServer.Services.Apps
@inject AppService AppService;
@model BTCPayServer.Components.MainNav.MainNavViewModel
@{
var store = Context.GetStoreData();
}
@if (store != null)
{
var appType = AppService.GetAppType(BoltcardFactoryPlugin.AppType)!;
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type" class="nav-link @ViewData.IsActivePage(AppsNavPages.Create, appType.Type)" id="@($"StoreNav-Create{appType.Type}")">
<vc:icon symbol="pointofsale" />
<span>@appType.Description</span>
</a>
</li>
@foreach (var app in Model.Apps.Where(app => app.AppType == appType.Type))
{
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="UpdateBoltcardFactory" asp-route-appId="@app.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, app.Id)" id="@($"StoreNav-App-{app.Id}")">
<span>@app.AppName</span>
</a>
</li>
<li class="nav-item nav-item-sub" not-permission="@Policies.CanModifyStoreSettings">
<a asp-area="" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@app.Id" class="nav-link">
<span>@app.AppName</span>
</a>
</li>
}
}

@ -0,0 +1,130 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Client
@using BTCPayServer.Views.Stores
@model BTCPayServer.Models.WalletViewModels.NewPullPaymentModel
@{
ViewData["Title"] = "Update Boltcard Factory";
Layout = "/Views/Shared/_Layout.cshtml";
}
@section PageHeadContent {
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true"/>
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
}
<form method="post" asp-route-appId="@Context.GetRouteValue("appId")" asp-action="UpdateBoltcardFactory">
<div class="sticky-header d-flex align-items-center justify-content-between">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a class="btn btn-secondary" asp-controller="UIBoltcardFactory" asp-action="ViewBoltcardFactory" asp-route-appId="@Context.GetRouteValue("appId")" id="ViewApp" target="_blank">View</a>
<input type="submit" value="Save" class="btn btn-primary order-sm-1" id="Save" />
</div>
</div>
<partial name="_StatusMessage"/>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label asp-for="Name" class="form-label"></label>
<input asp-for="Name" class="form-control"/>
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="row">
<div class="form-group col-8">
<label asp-for="Amount" class="form-label" data-required></label>
<input asp-for="Amount" class="form-control" inputmode="decimal"/>
<span asp-validation-for="Amount" class="text-danger"></span>
</div>
<div class="form-group col-4">
<label asp-for="Currency" class="form-label"></label>
<input asp-for="Currency" class="form-control w-auto" currency-selection />
<span asp-validation-for="Currency" class="text-danger"></span>
</div>
<div class="form-group col-12" permission="@Policies.CanCreatePullPayments">
<div class="form-check ">
<input asp-for="AutoApproveClaims" type="checkbox" class="form-check-input"/>
<label asp-for="AutoApproveClaims" class="form-check-label"></label>
<span asp-validation-for="AutoApproveClaims" class="text-danger"></span>
</div>
</div>
</div>
<div class="form-group mb-4">
<label asp-for="PaymentMethods" class="form-label"></label>
@foreach (var item in Model.PaymentMethodItems)
{
<div class="form-check mb-2">
<label class="form-label">
<input name="PaymentMethods" class="form-check-input" type="checkbox" value="@item.Value" @(item.Selected ? "checked" : "")>
@item.Text
</label>
</div>
}
<span asp-validation-for="PaymentMethods" class="text-danger mt-0"></span>
</div>
</div>
<div class="col-lg-9">
<div class="form-group mb-4">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control richtext"></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<h4 class="mt-5 mb-2">Additional Options</h4>
<div class="form-group">
<div class="accordion" id="additional">
<div class="accordion-item">
<h2 class="accordion-header" id="additional-custom-css-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-custom-css" aria-expanded="false" aria-controls="additional-custom-css">
Custom CSS
<vc:icon symbol="caret-down"/>
</button>
</h2>
<div id="additional-custom-css" class="accordion-collapse collapse" aria-labelledby="additional-custom-css-header">
<div class="accordion-body">
<div class="form-group">
<label asp-for="CustomCSSLink" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#2-bootstrap-themes" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
<input asp-for="CustomCSSLink" class="form-control"/>
<span asp-validation-for="CustomCSSLink" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="EmbeddedCSS" class="form-label"></label>
<textarea asp-for="EmbeddedCSS" rows="10" cols="40" class="form-control"></textarea>
<span asp-validation-for="EmbeddedCSS" class="text-danger"></span>
</div>
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="additional-lightning-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-lightning" aria-expanded="false" aria-controls="additional-lightning">
Lightning network settings
<vc:icon symbol="caret-down"/>
</button>
</h2>
<div id="additional-lightning" class="accordion-collapse collapse" aria-labelledby="additional-lightning-header">
<div class="accordion-body">
<div class="form-group">
<label asp-for="BOLT11Expiration" class="form-label"></label>
<div class="input-group">
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</form>

@ -0,0 +1,44 @@
@model ViewBoltcardFactoryViewModel
@{
ViewData["Title"] = "Boltcard factory";
Layout = "/Views/Shared/_LayoutWizard.cshtml";
}
<header class="text-center">
<h1>Program Boltcards</h1>
<p class="lead text-secondary mt-3" id="explanation">Using Boltcard NFC Programmer</p>
</header>
<div id="body" class="my-4">
<div id="actions" class="d-flex align-items-center justify-content-center" style="visibility:hidden">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a class="btn btn-primary" href="@Model.SetupDeepLink" target="_blank">Setup</a>
<a class="btn btn-danger" href="@Model.ResetDeepLink" target="_blank">Reset</a>
</div>
</div>
<div id="qr" class="d-flex align-items-center justify-content-center">
<div class="d-inline-flex flex-column" style="width:256px" style="visibility:hidden">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", () => {
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
document.getElementById("actions").style.visibility = "visible";
document.getElementById("qr").style.visibility = "hidden";
}
else {
document.getElementById("actions").style.visibility = "hidden";
document.getElementById("qr").style.visibility = "visible";
document.getElementById("explanation").innerText = "Scan the QR code with your mobile device";
}
});
</script>

@ -0,0 +1 @@
@using BTCPayServer.Plugins.BoltcardFactory.ViewModels;

@ -0,0 +1,22 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Services.Apps;
using Microsoft.Extensions.DependencyInjection;
using static BTCPayServer.Plugins.BoltcardFactory.BoltcardFactoryPlugin;
namespace BTCPayServer.Plugins.BoltcardTopUp;
public class BoltcardTopUpPlugin : BaseBTCPayServerPlugin
{
public const string ViewsDirectory = "/Plugins/BoltcardTopUp/Views";
public override string Identifier => "BTCPayServer.Plugins.BoltcardTopUp";
public override string Name => "BoltcardTopUp";
public override string Description => "Add the ability to Top-Up a Boltcard";
public override void Execute(IServiceCollection services)
{
services.AddSingleton<IUIExtension>(new UIExtension($"{ViewsDirectory}/NavExtension.cshtml", "header-nav"));
base.Execute(services);
}
}

@ -0,0 +1,207 @@
using BTCPayServer.Client;
using BTCPayServer.Filters;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Authorization;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using System;
using Microsoft.AspNetCore.Mvc;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Data;
using BTCPayServer.Services.Rates;
using BTCPayServer.ModelBinders;
using BTCPayServer.Plugins.BoltcardBalance;
using System.Collections.Specialized;
using BTCPayServer.Client.Models;
using BTCPayServer.NTag424;
using BTCPayServer.Services;
using NBitcoin.DataEncoders;
using NBitcoin;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using Org.BouncyCastle.Ocsp;
using System.Security.Claims;
using BTCPayServer.Payments;
using BTCPayServer.Plugins.BoltcardBalance.Controllers;
using BTCPayServer.HostedServices;
namespace BTCPayServer.Plugins.BoltcardTopUp.Controllers
{
public class UIBoltcardTopUpController : Controller
{
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly SettingsRepository _settingsRepository;
private readonly BTCPayServerEnvironment _env;
private readonly BTCPayNetworkJsonSerializerSettings _jsonSerializerSettings;
private readonly RateFetcher _rateFetcher;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly UIBoltcardBalanceController _boltcardBalanceController;
private readonly PullPaymentHostedService _ppService;
public UIBoltcardTopUpController(
ApplicationDbContextFactory dbContextFactory,
SettingsRepository settingsRepository,
BTCPayServerEnvironment env,
BTCPayNetworkJsonSerializerSettings jsonSerializerSettings,
RateFetcher rateFetcher,
BTCPayNetworkProvider networkProvider,
UIBoltcardBalanceController boltcardBalanceController,
PullPaymentHostedService ppService,
CurrencyNameTable currencies)
{
_dbContextFactory = dbContextFactory;
_settingsRepository = settingsRepository;
_env = env;
_jsonSerializerSettings = jsonSerializerSettings;
_rateFetcher = rateFetcher;
_networkProvider = networkProvider;
_boltcardBalanceController = boltcardBalanceController;
_ppService = ppService;
Currencies = currencies;
}
public CurrencyNameTable Currencies { get; }
[HttpGet("~/stores/{storeId}/boltcards/top-up")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public async Task<IActionResult> Keypad(string storeId, string currency = null)
{
var settings = new PointOfSaleSettings
{
Title = "Boltcards Top-Up"
};
currency ??= this.HttpContext.GetStoreData().GetStoreBlob().DefaultCurrency;
var numberFormatInfo = Currencies.GetNumberFormatInfo(currency);
double step = Math.Pow(10, -numberFormatInfo.CurrencyDecimalDigits);
//var store = new Data.StoreData();
//var storeBlob = new StoreBlob();
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/Keypad.cshtml", new ViewPointOfSaleViewModel
{
Title = settings.Title,
//StoreName = store.StoreName,
//BrandColor = storeBlob.BrandColor,
//CssFileId = storeBlob.CssFileId,
//LogoFileId = storeBlob.LogoFileId,
Step = step.ToString(CultureInfo.InvariantCulture),
//ViewType = BTCPayServer.Plugins.PointOfSale.PosViewType.Light,
//ShowCustomAmount = settings.ShowCustomAmount,
//ShowDiscount = settings.ShowDiscount,
//ShowSearch = settings.ShowSearch,
//ShowCategories = settings.ShowCategories,
//EnableTips = settings.EnableTips,
//CurrencyCode = settings.Currency,
//CurrencySymbol = numberFormatInfo.CurrencySymbol,
CurrencyCode = currency,
CurrencyInfo = new ViewPointOfSaleViewModel.CurrencyInfoData
{
CurrencySymbol = string.IsNullOrEmpty(numberFormatInfo.CurrencySymbol) ? settings.Currency : numberFormatInfo.CurrencySymbol,
Divisibility = numberFormatInfo.CurrencyDecimalDigits,
DecimalSeparator = numberFormatInfo.CurrencyDecimalSeparator,
ThousandSeparator = numberFormatInfo.NumberGroupSeparator,
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
},
//Items = AppService.Parse(settings.Template, false),
//ButtonText = settings.ButtonText,
//CustomButtonText = settings.CustomButtonText,
//CustomTipText = settings.CustomTipText,
//CustomTipPercentages = settings.CustomTipPercentages,
//CustomCSSLink = settings.CustomCSSLink,
//CustomLogoLink = storeBlob.CustomLogo,
//AppId = "vouchers",
StoreId = storeId,
//Description = settings.Description,
//EmbeddedCSS = settings.EmbeddedCSS,
//RequiresRefundEmail = settings.RequiresRefundEmail
});
}
[HttpPost("~/stores/{storeId}/boltcards/top-up")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public IActionResult Keypad(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
{
return RedirectToAction(nameof(ScanCard),
new
{
storeId = storeId,
amount = amount,
currency = currency
});
}
[HttpGet("~/stores/{storeId}/boltcards/top-up/scan")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
[AutoValidateAntiforgeryToken]
public async Task<IActionResult> ScanCard(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency)
{
return View($"{BoltcardTopUpPlugin.ViewsDirectory}/ScanCard.cshtml");
}
[HttpPost("~/stores/{storeId}/boltcards/top-up/scan")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[XFrameOptions(XFrameOptionsAttribute.XFrameOptions.Unset)]
public async Task<IActionResult> ScanCard(string storeId,
[ModelBinder(typeof(InvariantDecimalModelBinder))] decimal amount, string currency, string p, string c)
{
//return View($"{BoltcardBalancePlugin.ViewsDirectory}/BalanceView.cshtml", new BoltcardBalance.ViewModels.BalanceViewModel()
//{
// AmountDue = 10000m,
// Currency = "SATS",
// Transactions = [new() { Date = DateTimeOffset.UtcNow, Balance = -3.0m }, new() { Date = DateTimeOffset.UtcNow, Balance = -5.0m }]
//});
var issuerKey = await _settingsRepository.GetIssuerKey(_env);
var boltData = issuerKey.TryDecrypt(p);
if (boltData?.Uid is null)
return NotFound();
var id = issuerKey.GetId(boltData.Uid);
var registration = await _dbContextFactory.GetBoltcardRegistration(issuerKey, boltData, true);
if (registration is null)
return NotFound();
var pp = await _ppService.GetPullPayment(registration.PullPaymentId, false);
var rules = this.HttpContext.GetStoreData().GetStoreBlob().GetRateRules(_networkProvider);
var rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair("BTC", currency), rules, default);
var cryptoAmount = Math.Round(amount / rateResult.BidAsk.Bid, 11);
var ppCurrency = pp.GetBlob().Currency;
rateResult = await _rateFetcher.FetchRate(new Rating.CurrencyPair(ppCurrency, currency), rules, default);
var ppAmount = Math.Round(amount / rateResult.BidAsk.Bid, Currencies.GetNumberFormatInfo(ppCurrency).CurrencyDecimalDigits);
using var ctx = _dbContextFactory.CreateContext();
var payout = new Data.PayoutData()
{
Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)),
Date = DateTimeOffset.UtcNow,
State = PayoutState.Completed,
PullPaymentDataId = registration.PullPaymentId,
PaymentMethodId = new PaymentMethodId("BTC", PaymentTypes.LightningLike).ToString(),
Destination = null,
StoreDataId = storeId
};
var payoutBlob = new PayoutBlob()
{
CryptoAmount = -cryptoAmount,
Amount = -ppAmount,
Destination = null,
Metadata = new JObject(),
};
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
await ctx.Payouts.AddAsync(payout);
await ctx.SaveChangesAsync();
_boltcardBalanceController.ViewData["NoCancelWizard"] = true;
return await _boltcardBalanceController.GetBalanceView(registration.PullPaymentId, p);
}
}
}

@ -0,0 +1,48 @@
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{
Layout = "PointOfSale/Public/_Layout";
Csp.UnsafeEval();
}
@section PageHeadContent {
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
}
@section PageFootContent {
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/pos/common.js" asp-append-version="true"></script>
<script src="~/pos/keypad.js" asp-append-version="true"></script>
}
<div id="PosKeypad" class="public-page-wrap">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(Model.Title, null as StoreBrandingViewModel)" />
<form id="app" method="post"
asp-route-storeId="@Model.StoreId"
asp-antiforgery="true" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
<input type="hidden" name="posdata" v-model="posdata" id="posdata">
<input type="hidden" name="amount" v-model="totalNumeric">
<input type="hidden" name="currency" v-model="currencyCode">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
<div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
</div>
<div class="keypad">
<button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">{{ k }}</button>
</div>
<button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading" id="pay-button">
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<template v-else>Top-Up Card</template>
</button>
</form>
<footer class="store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
</a>
</footer>
</div>

@ -0,0 +1,20 @@
@using BTCPayServer.Client
@using BTCPayServer.Plugins.BoltcardFactory
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer
@{
var storeId = Context.GetStoreData()?.Id;
}
@if (storeId != null)
{
<li class="nav-item">
<a asp-area="" asp-controller="UIBoltcardTopUp" asp-action="Keypad" asp-route-storeId="@storeId" class="nav-link">
<vc:icon symbol="pay-button" />
<span>Boltcard Top-Up</span>
</a>
</li>
}

@ -0,0 +1,150 @@
@{
ViewData["Title"] = "Boltcard TopUps";
ViewData["ShowFooter"] = false;
Layout = "/Views/Shared/_LayoutWizard.cshtml";
}
@section PageHeadContent
{
<style>
.amount-col {
text-align: right;
white-space: nowrap;
}
</style>
}
<header class="text-center">
<h1>Boltcard Top-Up</h1>
<p class="lead text-secondary mt-3" id="explanation">Scan your card to top it up</p>
</header>
<div id="body" class="my-4">
<div id="actions" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="start-scan-btn" class="btn btn-primary" href="#">Ask permission...</a>
</div>
</div>
<div id="qr" class="d-flex flex-column align-items-center justify-content-center d-none">
<div class="d-inline-flex flex-column">
<div class="qr-container mb-2">
<vc:qr-code data="@Context.Request.GetCurrentUrl()" />
</div>
</div>
<p class="text-secondary">NFC not supported in this device</p>
</div>
<div id="scanning-btn" class="d-flex align-items-center justify-content-center d-none">
<div class="d-flex gap-3 mt-3 mt-sm-0">
<a id="scanning-btn-link" class="action-button" style="font-size: 50px;" ></a>
</div>
</div>
<div id="balance" class="row">
<div id="balance-table"></div>
</div>
</div>
<script>
(function () {
var permissionGranted = false;
var ndef = null;
var abortController = null;
var scanned = false;
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function showBalance(lnurlw) {
setState("Submitting");
await delay(1000);
var url = window.location.href.replace("#", "");
url = url.split("?")[0] + "?" + lnurlw.split("?")[1] + "&" + url.split("?")[1];
// url = "https://testnet.demo.btcpayserver.org/boltcards/balance?p=...&c=..."
scanned = true;
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function () {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
document.getElementById("balance-table").innerHTML = this.responseText;
setState("ShowBalance");
}
else {
scanned = false;
setState("WaitingForCard");
}
};
xhttp.open('POST', url, true);
xhttp.send(new FormData());
}
async function startScan() {
if (!('NDEFReader' in window)) {
return;
}
ndef = new NDEFReader();
abortController = new AbortController();
abortController.signal.onabort = () => setState("WaitingForCard");
await ndef.scan({ signal: abortController.signal })
setState("WaitingForCard");
ndef.onreading = async ({ message }) => {
const record = message.records[0];
const textDecoder = new TextDecoder('utf-8');
const decoded = textDecoder.decode(record.data);
await showBalance(decoded);
};
}
function setState(state)
{
document.getElementById("actions").classList.add("d-none");
document.getElementById("qr").classList.add("d-none");
document.getElementById("scanning-btn").classList.add("d-none");
document.getElementById("balance").classList.add("d-none");
if (state === "NFCNotSupported")
{
document.getElementById("qr").classList.remove("d-none");
}
else if (state === "WaitingForPermission")
{
document.getElementById("actions").classList.remove("d-none");
}
else if (state === "WaitingForCard")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-wifi\"></i>";
}
else if (state == "Submitting")
{
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-spinner\"></i>"
}
else if (state == "ShowBalance") {
document.getElementById("explanation").classList.add("d-none");
document.getElementById("scanning-btn").classList.remove("d-none");
document.getElementById("scanning-btn-link").innerHTML = "<i class=\"fa fa-bitcoin\"></i>";
document.getElementById("balance").classList.remove("d-none");
}
}
document.addEventListener("DOMContentLoaded", async () => {
var nfcSupported = 'NDEFReader' in window;
if (!nfcSupported) {
setState("NFCNotSupported");
//setState("ShowBalance");
}
else {
setState("WaitingForPermission");
var granted = (await navigator.permissions.query({ name: 'nfc' })).state === 'granted';
if (granted)
{
setState("WaitingForCard");
startScan();
}
}
delegate('click', "#start-scan-btn", startScan);
//showBalance("lnurl://ewfw?p=test&c=test");
});
})();
</script>

@ -92,6 +92,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
EmbeddedCSS = settings.EmbeddedCSS,
CustomCSSLink = settings.CustomCSSLink
};
// Check if the currency is COP or ARS (exclude decimal places)
return View($"PointOfSale/Public/{viewType}", new ViewPointOfSaleViewModel
{

@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
@ -74,10 +73,10 @@ namespace BTCPayServer.Security
// resolve from app
if (routeData.Values.TryGetValue("appId", out var vAppId) && vAppId is string appId)
{
app = await _appService.GetAppDataIfOwner(userId, appId);
app = await _appService.GetAppData(userId, appId);
if (storeId == null)
{
storeId = app?.StoreDataId ?? String.Empty;
storeId = app?.StoreDataId ?? string.Empty;
}
else if (app?.StoreDataId != storeId)
{
@ -90,7 +89,7 @@ namespace BTCPayServer.Security
paymentRequest = await _paymentRequestRepository.FindPaymentRequest(payReqId, userId);
if (storeId == null)
{
storeId = paymentRequest?.StoreDataId ?? String.Empty;
storeId = paymentRequest?.StoreDataId ?? string.Empty;
}
else if (paymentRequest?.StoreDataId != storeId)
{
@ -103,7 +102,7 @@ namespace BTCPayServer.Security
invoice = await _invoiceRepository.GetInvoice(invoiceId);
if (storeId == null)
{
storeId = invoice?.StoreId ?? String.Empty;
storeId = invoice?.StoreId ?? string.Empty;
}
else if (invoice?.StoreId != storeId)
{

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;
@ -58,14 +59,12 @@ namespace BTCPayServer.Security.Greenfield
return AuthenticateResult.NoResult();
var key = await _apiKeyRepository.GetKey(apiKey, true);
if (key == null || await _userManager.IsLockedOutAsync(key.User))
if (!UserService.TryCanLogin(key?.User, out var error))
{
return AuthenticateResult.Fail("ApiKey authentication failed");
return AuthenticateResult.Fail($"ApiKey authentication failed: {error}");
}
List<Claim> claims = new List<Claim>();
claims.Add(new Claim(_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId));
var claims = new List<Claim> { new (_identityOptions.CurrentValue.ClaimsIdentity.UserIdClaimType, key.UserId) };
claims.AddRange((await _userManager.GetRolesAsync(key.User)).Select(s => new Claim(_identityOptions.CurrentValue.ClaimsIdentity.RoleClaimType, s)));
claims.AddRange(Permission.ToPermissions(key.GetBlob()?.Permissions ?? Array.Empty<string>()).Select(permission =>
new Claim(GreenfieldConstants.ClaimTypes.Permission, permission.ToString())));

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Threading.Tasks;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@ -66,6 +67,10 @@ namespace BTCPayServer.Security.Greenfield
.FirstOrDefaultAsync(applicationUser =>
applicationUser.NormalizedUserName == _userManager.NormalizeName(username));
if (!UserService.TryCanLogin(user, out var error))
{
return AuthenticateResult.Fail($"Basic authentication failed: {error}");
}
if (user.Fido2Credentials.Any())
{
return AuthenticateResult.Fail("Cannot use Basic authentication with multi-factor is enabled.");

@ -371,10 +371,26 @@ namespace BTCPayServer.Services.Apps
return null;
await using var ctx = _ContextFactory.CreateContext();
var app = await ctx.UserStore
.Include(store => store.StoreRole)
.Where(us => us.ApplicationUserId == userId && us.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
.SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId))
.FirstOrDefaultAsync();
.Include(store => store.StoreRole)
.Where(us => us.ApplicationUserId == userId && us.StoreRole.Permissions.Contains(Policies.CanModifyStoreSettings))
.SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId))
.FirstOrDefaultAsync();
if (app == null)
return null;
if (type != null && type != app.AppType)
return null;
return app;
}
public async Task<AppData?> GetAppData(string userId, string appId, string? type = null)
{
if (userId == null || appId == null)
return null;
await using var ctx = _ContextFactory.CreateContext();
var app = await ctx.UserStore
.Where(us => us.ApplicationUserId == userId && us.StoreData != null && us.StoreData.UserStores.Any(u => u.ApplicationUserId == userId))
.SelectMany(us => us.StoreData.Apps.Where(a => a.Id == appId))
.FirstOrDefaultAsync();
if (app == null)
return null;
if (type != null && type != app.AppType)

@ -0,0 +1,57 @@
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Configuration;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Services.Notifications.Blobs;
internal class NewUserRequiresApprovalNotification : BaseNotification
{
private const string TYPE = "newuserrequiresapproval";
public string UserId { get; set; }
public string UserEmail { get; set; }
public override string Identifier => TYPE;
public override string NotificationType => TYPE;
public NewUserRequiresApprovalNotification()
{
}
public NewUserRequiresApprovalNotification(ApplicationUser user)
{
UserId = user.Id;
UserEmail = user.Email;
}
internal class Handler : NotificationHandler<NewUserRequiresApprovalNotification>
{
private readonly LinkGenerator _linkGenerator;
private readonly BTCPayServerOptions _options;
public Handler(LinkGenerator linkGenerator, BTCPayServerOptions options)
{
_linkGenerator = linkGenerator;
_options = options;
}
public override string NotificationType => TYPE;
public override (string identifier, string name)[] Meta
{
get
{
return [(TYPE, "New user requires approval")];
}
}
protected override void FillViewModel(NewUserRequiresApprovalNotification notification, NotificationViewModel vm)
{
vm.Identifier = notification.Identifier;
vm.Type = notification.NotificationType;
vm.Body = $"New user {notification.UserEmail} requires approval.";
vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIServerController.User),
"UIServer",
new { userId = notification.UserId }, _options.RootPath);
}
}
}

@ -1,6 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Apps;
using BTCPayServer.Validation;
using Newtonsoft.Json;
@ -14,6 +14,18 @@ namespace BTCPayServer.Services
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Disable new user registration on the server")]
public bool LockSubscription { get; set; }
[JsonIgnore]
[Display(Name = "Enable new user registration on the server")]
public bool EnableRegistration
{
get => !LockSubscription;
set { LockSubscription = !value; }
}
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Require new users to be approved by an admin after registration")]
public bool RequiresUserApproval { get; set; }
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
[Display(Name = "Discourage search engines from indexing this site")]
@ -30,8 +42,24 @@ namespace BTCPayServer.Services
public bool DisableInstantNotifications { get; set; }
[Display(Name = "Disable stores from using the server's email settings as backup")]
public bool DisableStoresToUseServerEmailSettings { get; set; }
[JsonIgnore]
[Display(Name = "Allow stores to use the server's SMTP email settings as a default")]
public bool EnableStoresToUseServerEmailSettings
{
get => !DisableStoresToUseServerEmailSettings;
set { DisableStoresToUseServerEmailSettings = !value; }
}
[Display(Name = "Disable non-admins access to the user creation API endpoint")]
public bool DisableNonAdminCreateUserApi { get; set; }
[JsonIgnore]
[Display(Name = "Non-admins can access the user creation API endpoint")]
public bool EnableNonAdminCreateUserApi
{
get => !DisableNonAdminCreateUserApi;
set { DisableNonAdminCreateUserApi = !value; }
}
public const string DefaultPluginSource = "https://plugin-builder.btcpayserver.org";
[UriAttribute]

@ -1,10 +1,13 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.Services.Stores;
using BTCPayServer.Storage.Services;
using Microsoft.AspNetCore.Identity;
@ -20,6 +23,7 @@ namespace BTCPayServer.Services
private readonly StoredFileRepository _storedFileRepository;
private readonly FileService _fileService;
private readonly StoreRepository _storeRepository;
private readonly EventAggregator _eventAggregator;
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly ILogger<UserService> _logger;
@ -27,6 +31,7 @@ namespace BTCPayServer.Services
IServiceProvider serviceProvider,
StoredFileRepository storedFileRepository,
FileService fileService,
EventAggregator eventAggregator,
StoreRepository storeRepository,
ApplicationDbContextFactory applicationDbContextFactory,
ILogger<UserService> logger)
@ -34,6 +39,7 @@ namespace BTCPayServer.Services
_serviceProvider = serviceProvider;
_storedFileRepository = storedFileRepository;
_fileService = fileService;
_eventAggregator = eventAggregator;
_storeRepository = storeRepository;
_applicationDbContextFactory = applicationDbContextFactory;
_logger = logger;
@ -46,26 +52,89 @@ namespace BTCPayServer.Services
(userRole, role) => role.Name).ToArray()))).ToListAsync();
}
public static ApplicationUserData FromModel(ApplicationUser data, string?[] roles)
{
return new ApplicationUserData()
return new ApplicationUserData
{
Id = data.Id,
Email = data.Email,
EmailConfirmed = data.EmailConfirmed,
RequiresEmailConfirmation = data.RequiresEmailConfirmation,
Approved = data.Approved,
RequiresApproval = data.RequiresApproval,
Created = data.Created,
Roles = roles,
Disabled = data.LockoutEnabled && data.LockoutEnd is not null && DateTimeOffset.UtcNow < data.LockoutEnd.Value.UtcDateTime
};
}
private bool IsDisabled(ApplicationUser user)
private static bool IsEmailConfirmed(ApplicationUser user)
{
return user.EmailConfirmed || !user.RequiresEmailConfirmation;
}
private static bool IsApproved(ApplicationUser user)
{
return user.Approved || !user.RequiresApproval;
}
private static bool IsDisabled(ApplicationUser user)
{
return user.LockoutEnabled && user.LockoutEnd is not null &&
DateTimeOffset.UtcNow < user.LockoutEnd.Value.UtcDateTime;
}
public static bool TryCanLogin([NotNullWhen(true)] ApplicationUser? user, [MaybeNullWhen(true)] out string error)
{
error = null;
if (user == null)
{
error = "Invalid login attempt.";
return false;
}
if (!IsEmailConfirmed(user))
{
error = "You must have a confirmed email to log in.";
return false;
}
if (!IsApproved(user))
{
error = "Your user account requires approval by an admin before you can log in.";
return false;
}
if (IsDisabled(user))
{
error = "Your user account is currently disabled.";
return false;
}
return true;
}
public async Task<bool> SetUserApproval(string userId, bool approved, Uri requestUri)
{
using var scope = _serviceProvider.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(userId);
if (user is null || !user.RequiresApproval || user.Approved == approved)
{
return false;
}
user.Approved = approved;
var succeeded = await userManager.UpdateAsync(user) is { Succeeded: true };
if (succeeded)
{
_logger.LogInformation("User {UserId} is now {Status}", user.Id, approved ? "approved" : "unapproved");
_eventAggregator.Publish(new UserApprovedEvent { User = user, Approved = approved, RequestUri = requestUri });
}
else
{
_logger.LogError("Failed to {Action} user {UserId}", approved ? "approve" : "unapprove", user.Id);
}
return succeeded;
}
public async Task<bool?> ToggleUser(string userId, DateTimeOffset? lockedOutDeadline)
{
using var scope = _serviceProvider.CreateScope();
@ -163,7 +232,6 @@ namespace BTCPayServer.Services
}
}
public async Task<bool> IsUserTheOnlyOneAdmin(ApplicationUser user)
{
using var scope = _serviceProvider.CreateScope();
@ -175,7 +243,7 @@ namespace BTCPayServer.Services
}
var adminUsers = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var enabledAdminUsers = adminUsers
.Where(applicationUser => !IsDisabled(applicationUser))
.Where(applicationUser => !IsDisabled(applicationUser) && IsApproved(applicationUser))
.Select(applicationUser => applicationUser.Id).ToList();
return enabledAdminUsers.Count == 1 && enabledAdminUsers.Contains(user.Id);

@ -487,6 +487,7 @@ namespace BTCPayServer.Services
public static WalletObjectData NewWalletObjectData(WalletObjectId id, JObject? data = null)
{
return new WalletObjectData()
{
WalletId = id.WalletId.ToString(),

@ -49,7 +49,7 @@
<div class="inputWithIcon _copyInput">
<input type="text" class="checkoutTextbox" v-bind:value="srvModel.invoiceBitcoinUrl" readonly="readonly"/>
<img v-bind:src="srvModel.cryptoImage" v-if="hasPayjoin"/>
<i class="fa fa-user-secret" v-else/>
<i class="fa fa-user-secret" v-else/>
</div>
</div>
</nav>

@ -1,27 +1,5 @@
@model BTCPayServer.Models.ServerViewModels.EmailsViewModel
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mt-n1 mb-4">
<h3 class="mb-0">Email Server</h3>
<div class="d-flex">
<div class="dropdown only-for-js" id="quick-fill">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle">
Quick Fill
</button>
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
</div>
</div>
</div>
</div>
</div>
</div>
<form method="post" autocomplete="off">
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
@ -30,8 +8,22 @@
<div asp-validation-summary="All"></div>
}
<div class="form-group">
<label asp-for="Settings.Server" class="form-label">SMTP Server</label>
<input asp-for="Settings.Server" data-fill="server" class="form-control"/>
<div class="d-flex flex-wrap gap-2 align-items-center justify-content-between">
<label asp-for="Settings.Server" class="form-label">SMTP Server</label>
<div class="dropdown only-for-js mt-n2" id="quick-fill">
<button class="btn btn-link p-0 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" id="QuickFillDropdownToggle">
Quick Fill
</button>
<div class="dropdown-menu" aria-labelledby="QuickFillDropdownToggle">
<a class="dropdown-item" href="" data-server="smtp.gmail.com" data-port="587">Gmail.com</a>
<a class="dropdown-item" href="" data-server="mail.yahoo.com" data-port="587">Yahoo.com</a>
<a class="dropdown-item" href="" data-server="smtp.mailgun.org" data-port="587">Mailgun</a>
<a class="dropdown-item" href="" data-server="smtp.office365.com" data-port="587">Office365</a>
<a class="dropdown-item" href="" data-server="smtp.sendgrid.net" data-port="587">SendGrid</a>
</div>
</div>
</div>
<input asp-for="Settings.Server" data-fill="server" class="form-control" />
<span asp-validation-for="Settings.Server" class="text-danger"></span>
</div>
<div class="form-group">
@ -53,7 +45,6 @@
<div class="form-group">
@if (!Model.PasswordSet)
{
<label asp-for="Settings.Password" class="form-label"></label>
<input asp-for="Settings.Password" type="password" class="form-control"/>
<span asp-validation-for="Settings.Password" class="text-danger"></span>

@ -18,8 +18,11 @@
@RenderBody()
</div>
</section>
<partial name="_Footer"/>
<partial name="LayoutFoot" />
@await RenderSectionAsync("PageFootContent", false)
@if (ViewData["ShowFooter"] is not false)
{
<partial name="_Footer"/>
}
<partial name="LayoutFoot" />
@await RenderSectionAsync("PageFootContent", false)
</body>
</html>

@ -12,6 +12,7 @@
@await RenderSectionAsync("PageFootContent", false)
}
<nav id="wizard-navbar">
@await RenderSectionAsync("Navbar", false)
</nav>

@ -2,8 +2,8 @@
@using BTCPayServer.Abstractions.Contracts
@model (string Title, StoreBrandingViewModel StoreBranding)
@{
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding.LogoFileId)
var logoUrl = !string.IsNullOrEmpty(Model.StoreBranding?.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.StoreBranding?.LogoFileId)
: null;
}
<header class="store-header" v-pre>

@ -10,7 +10,7 @@
</p>
<form asp-action="ForgotPassword" method="post">
<div asp-validation-summary="All"></div>
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" />

@ -1,5 +1,4 @@
@using BTCPayServer.Abstractions.Extensions
@model DateTimeOffset?
@model DateTimeOffset?
@{
ViewData["Title"] = "Account disabled";
Layout = "_LayoutSignedOut";

@ -9,16 +9,16 @@
<form asp-route-returnurl="@ViewData["ReturnUrl"]" method="post" id="login-form" asp-action="Login">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)">
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" required autofocus/>
<input asp-for="Email" class="form-control" required autofocus />
<span asp-validation-for="Email" class="text-danger"></span>
</div>
<div class="form-group">
<div class="d-flex justify-content-between">
<label asp-for="Password" class="form-label"></label>
<a asp-action="ForgotPassword" >Forgot password?</a>
<a asp-action="ForgotPassword" tabindex="-1">Forgot password?</a>
</div>
<div class="input-group d-flex">
<input asp-for="Password" class="form-control" required />

@ -8,7 +8,7 @@
<form asp-route-returnUrl="@ViewData["ReturnUrl"]" asp-route-logon="true" method="post">
<fieldset disabled="@(ViewData.ContainsKey("disabled") ? "disabled" : null)" >
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<div class="form-group">
<label asp-for="Email" class="form-label"></label>
<input asp-for="Email" class="form-control" required autofocus />

@ -13,9 +13,9 @@
<partial name="_ValidationScriptsPartial" />
}
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null&& Model.LoginWithLNURLAuthViewModel != null)
@if (Model.LoginWith2FaViewModel != null && Model.LoginWithFido2ViewModel != null && Model.LoginWithLNURLAuthViewModel != null)
{
<div asp-validation-summary="ModelOnly"></div>
<div asp-validation-summary="ModelOnly" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
}
else if (Model.LoginWith2FaViewModel == null && Model.LoginWithFido2ViewModel == null && Model.LoginWithLNURLAuthViewModel == null)
{

@ -5,7 +5,7 @@
}
<form method="post" asp-action="SetPassword">
<div asp-validation-summary="All"></div>
<div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
<input asp-for="Code" type="hidden"/>
<input asp-for="EmailSetInternally" type="hidden"/>
@if (Model.EmailSetInternally)

@ -30,11 +30,6 @@
<partial name="LayoutHead"/>
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet"/>
<link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true"/>
<style>
.no-marker > ul {
list-style-type: none;
}
</style>
</head>
<body class="min-vh-100">
<div id="app" class="d-flex flex-column min-vh-100 pb-l">
@ -207,15 +202,15 @@
</p>
@if (Model.LnurlEndpoint is not null)
{
<p>
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard
</a>
<span>&nbsp;|&nbsp;</span>
<a asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
Reset Boltcard
</a>
</p>
<p id="BoltcardActions" style="visibility:hidden">
<a id="SetupBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="configure-boltcard">
Setup Boltcard
</a>
<span>&nbsp;|&nbsp;</span>
<a id="ResetBoltcard" asp-action="SetupBoltcard" asp-controller="UIPullPayment" asp-route-pullPaymentId="@Model.Id" asp-route-command="reset-boltcard">
Reset Boltcard
</a>
</p>
}
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by <partial name="_StoreFooterLogo" />
@ -231,6 +226,15 @@
<script src="~/vendor/ur-registry/urlib.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
var isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) {
document.getElementById("SetupBoltcard").setAttribute('target', '_blank');
document.getElementById("SetupBoltcard").setAttribute('href', @Safe.Json(@Model.SetupDeepLink));
document.getElementById("ResetBoltcard").setAttribute('target', '_blank');
document.getElementById("ResetBoltcard").setAttribute('href', @Safe.Json(@Model.ResetDeepLink));
}
document.getElementById("BoltcardActions").style.visibility = "visible";
window.qrApp = initQRShow({});
delegate('click', 'button[page-qr]', event => {
qrApp.title = "Pull Payment QR";

@ -36,8 +36,6 @@
<span asp-validation-for="IsAdmin" class="text-danger"></span>
</div>
}
@if (ViewData["AllowRequestEmailConfirmation"] is true)
{
<div class="form-group form-check">
@ -46,8 +44,16 @@
<span asp-validation-for="EmailConfirmed" class="text-danger"></span>
</div>
}
@if (ViewData["AllowRequestApproval"] is true)
{
<div class="form-group form-check">
<input asp-for="Approved" type="checkbox" class="form-check-input"/>
<label asp-for="Approved" class="form-check-label"></label>
<span asp-validation-for="Approved" class="text-danger"></span>
</div>
}
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
<button id="Save" type="submit" class="btn btn-primary mt-2" name="command" value="Save">Create account</button>
</form>
</div>
</div>

@ -3,6 +3,7 @@
ViewData.SetActivePage(ServerNavPages.Emails, "Emails");
}
<h3 class="mb-4">Email Server</h3>
<partial name="EmailsBody" model="Model" />
@section PageFootContent {

@ -3,16 +3,12 @@
@{
ViewData.SetActivePage(ServerNavPages.Users);
var nextUserEmailSortOrder = (string)ViewData["NextUserEmailSortOrder"];
String userEmailSortOrder = null;
switch (nextUserEmailSortOrder)
var userEmailSortOrder = nextUserEmailSortOrder switch
{
case "asc":
userEmailSortOrder = "desc";
break;
case "desc":
userEmailSortOrder = "asc";
break;
}
"asc" => "desc",
"desc" => "asc",
_ => null
};
var sortIconClass = "fa-sort";
if (userEmailSortOrder != null)
@ -20,8 +16,8 @@
sortIconClass = $"fa-sort-alpha-{userEmailSortOrder}";
}
var sortByDesc = "Sort by descending...";
var sortByAsc = "Sort by ascending...";
const string sortByDesc = "Sort by descending...";
const string sortByAsc = "Sort by ascending...";
}
<div class="d-flex align-items-center justify-content-between mb-3">
@ -31,14 +27,8 @@
</a>
</div>
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px">
<div class="input-group">
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
<button type="submit" class="btn btn-secondary" title="Search by email">
<span class="fa fa-search"></span> Search
</button>
</div>
<span asp-validation-for="SearchTerm" class="text-danger"></span>
<form asp-action="ListUsers" asp-route-sortOrder="@(userEmailSortOrder)" style="max-width:640px" method="get">
<input asp-for="SearchTerm" class="form-control" placeholder="Search by email..." />
</form>
<div class="table-responsive">
@ -53,58 +43,52 @@
title="@(nextUserEmailSortOrder == "desc" ? sortByAsc : sortByDesc)"
>
Email
<span class="fa @(sortIconClass)" />
<span class="fa @(sortIconClass)"></span>
</a>
</th>
<th >Created</th>
<th class="text-center">Verified</th>
<th class="text-center">Enabled</th>
<th class="text-end">Actions</th>
<th>Created</th>
<th>Status</th>
<th class="actions-col"></th>
</tr>
</thead>
<tbody>
<tbody id="UsersList">
@foreach (var user in Model.Users)
{
var status = user switch
{
{ Disabled: true } => ("Disabled", "danger"),
{ Approved: false } => ("Pending Approval", "warning"),
{ EmailConfirmed: false } => ("Pending Email Verification", "warning"),
_ => ("Active", "success")
};
<tr>
<td class="d-flex align-items-center">
<span class="me-2">@user.Email</span>
<td class="d-flex align-items-center gap-2">
<span class="user-email">@user.Email</span>
@foreach (var role in user.Roles)
{
<span class="badge bg-info">@Model.Roles[role]</span>
}
</td>
<td>@user.Created?.ToBrowserDate()</td>
<td class="text-center">
@if (user.Verified)
{
<span class="text-success fa fa-check"></span>
}
else
{
<span class="text-danger fa fa-times"></span>
}
<td>
<span class="user-status badge bg-@status.Item2">@status.Item1</span>
</td>
<td class="text-center">
@if (!user.Disabled)
{
<span class="text-success fa fa-check" title="User is enabled"></span>
}
else
{
<span class="text-danger fa fa-times" title="User is disabled"></span>
}
</td>
<td class="text-end">
@if (!user.Verified && !user.Disabled) {
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>.">Resend verification email</a>
<span>-</span>
}
<a asp-action="User" asp-route-userId="@user.Id">Edit</a> <span> - </span> <a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
- <a asp-action="ToggleUser"
asp-route-enable="@user.Disabled"
asp-route-userId="@user.Id">
@(user.Disabled ? "Enable" : "Disable")
</a>
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
@if (user is { EmailConfirmed: false, Disabled: false }) {
<a asp-action="SendVerificationEmail" asp-route-userId="@user.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Send verification email" data-description="This will send a verification email to <strong>@Html.Encode(user.Email)</strong>." data-confirm="Send">Resend email</a>
}
else if (user is { Approved: false, Disabled: false })
{
<a asp-action="ApproveUser" asp-route-userId="@user.Id" asp-route-approved="true" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-title="Approve user" data-description="This will approve the user <strong>@Html.Encode(user.Email)</strong>." data-confirm="Approve">Approve</a>
}
else
{
<a asp-action="ToggleUser" asp-route-userId="@user.Id" asp-route-enable="@user.Disabled">@(user.Disabled ? "Enable" : "Disable")</a>
}
<a asp-action="User" asp-route-userId="@user.Id" class="user-edit">Edit</a>
<a asp-action="DeleteUser" asp-route-userId="@user.Id">Remove</a>
</div>
</td>
</tr>
}

@ -11,15 +11,10 @@
@section PageHeadContent {
<style>
#AllowLightningInternalNodeForAll ~ .info-note,
#AllowHotWalletRPCImportForAll ~ .info-note,
#AllowHotWalletForAll ~ .info-note,
#DisableNonAdminCreateUserApi:checked ~ .info-note,
#LockSubscription:checked ~ .info-note { display: none; }
#AllowLightningInternalNodeForAll:checked ~ .info-note,
#AllowHotWalletRPCImportForAll:checked ~ .info-note,
#AllowHotWalletForAll:checked ~ .info-note { display: inline-flex; }
input[type="checkbox"] ~ .info-note,
input[type="checkbox"] ~ .subsettings { display: none; }
input[type="checkbox"]:checked ~ .info-note { display: flex; max-width: 44em; }
input[type="checkbox"]:checked ~ .subsettings { display: block; }
</style>
}
@ -31,10 +26,59 @@
}
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="col-xl-10 col-xxl-constrain">
<form method="post" class="d-flex flex-column">
<div class="form-group mb-5">
<h4 class="mb-3">Existing User Settings</h4>
<h4 class="mb-3">Registration Settings</h4>
<div class="form-check my-3">
<input asp-for="EnableRegistration" type="checkbox" class="form-check-input"/>
<label asp-for="EnableRegistration" class="form-check-label"></label>
<span asp-validation-for="EnableRegistration" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
<div class="subsettings">
<div class="form-check my-3">
@{
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
}
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
@if (!isEmailConfigured)
{
<div class="mb-2">
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
</div>
}
</div>
<div class="form-check my-3">
<input asp-for="RequiresUserApproval" type="checkbox" class="form-check-input"/>
<label asp-for="RequiresUserApproval" class="form-check-label"></label>
<span asp-validation-for="RequiresUserApproval" class="text-danger"></span>
</div>
<div class="form-check my-3">
<input asp-for="EnableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
<label asp-for="EnableNonAdminCreateUserApi" class="form-check-label"></label>
<span asp-validation-for="EnableNonAdminCreateUserApi" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
</div>
</div>
</div>
</div>
<div class="form-group mb-5">
<h4 class="mb-3">User Settings</h4>
<div class="form-check my-3">
<input asp-for="AllowLightningInternalNodeForAll" type="checkbox" class="form-check-input"/>
<label asp-for="AllowLightningInternalNodeForAll" class="form-check-label"></label>
@ -71,45 +115,14 @@
</div>
<div class="form-group mb-5">
<h4 class="mb-3">New User Settings</h4>
<h4 class="mb-3">Email Settings</h4>
<div class="form-check my-3">
@{
var emailSettings = (await _SettingsRepository.GetSettingAsync<EmailSettings>()) ?? new EmailSettings();
/* The "|| Model.RequiresConfirmedEmail" check is for the case when a user had checked
the checkbox without first configuring the e-mail settings so that they can uncheck it. */
var isEmailConfigured = emailSettings.IsComplete() || Model.RequiresConfirmedEmail;
}
<input asp-for="RequiresConfirmedEmail" type="checkbox" class="form-check-input" disabled="@(isEmailConfigured ? null : "disabled")"/>
<label asp-for="RequiresConfirmedEmail" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/FAQ/ServerSettings/#how-to-allow-registration-on-my-btcpay-server" target="_blank" rel="noreferrer noopener">
<input asp-for="EnableStoresToUseServerEmailSettings" type="checkbox" class="form-check-input"/>
<label asp-for="EnableStoresToUseServerEmailSettings" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="RequiresConfirmedEmail" class="text-danger"></span>
@if (!isEmailConfigured)
{
<div class="mb-2">
<span class="text-secondary">Your email server has not been configured. <a asp-controller="UIServer" asp-action="Emails">Please configure it first.</a></span>
</div>
}
</div>
<div class="form-check my-3">
<input asp-for="LockSubscription" type="checkbox" class="form-check-input"/>
<label asp-for="LockSubscription" class="form-check-label"></label>
<span asp-validation-for="LockSubscription" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Enabling public user registration means anyone can register to your server and may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
</div>
<div class="form-check my-3">
<input asp-for="DisableNonAdminCreateUserApi" type="checkbox" class="form-check-input"/>
<label asp-for="DisableNonAdminCreateUserApi" class="form-check-label"></label>
<span asp-validation-for="DisableNonAdminCreateUserApi" class="text-danger"></span>
<div class="info-note mt-2 text-warning" role="alert">
<vc:icon symbol="warning"/>
Caution: Allowing non-admins to have access to API endpoints may expose your BTCPay Server instance to potential security risks from unknown users.
</div>
<span asp-validation-for="EnableStoresToUseServerEmailSettings" class="text-danger"></span>
</div>
</div>
@ -123,14 +136,6 @@
</a>
<span asp-validation-for="DisableInstantNotifications" class="text-danger"></span>
</div>
<div class="form-check my-3">
<input asp-for="DisableStoresToUseServerEmailSettings" type="checkbox" class="form-check-input"/>
<label asp-for="DisableStoresToUseServerEmailSettings" class="form-check-label"></label>
<a href="https://docs.btcpayserver.org/Notifications/#server-emails" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<span asp-validation-for="DisableStoresToUseServerEmailSettings" class="text-danger"></span>
</div>
</div>
<div class="form-group mb-5">
@ -177,7 +182,7 @@
<h4 class="mt-5">Customization Settings</h4>
<div class="form-group mb-5">
<label asp-for="RootAppId" class="form-label"></label>
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select w-auto"></select>
<select asp-for="RootAppId" asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.RootAppId))" class="form-select"></select>
@if (!Model.DomainToAppMapping.Any())
{
<button id="AddDomainButton" type="submit" name="command" value="add-domain" class="btn btn-link px-0">Map specific domains to specific apps</button>
@ -210,7 +215,7 @@
<label asp-for="DomainToAppMapping[index].AppId" class="form-label"></label>
<select asp-for="DomainToAppMapping[index].AppId"
asp-items="@(new SelectList(ViewBag.AppsList, nameof(SelectListItem.Value), nameof(SelectListItem.Text), Model.DomainToAppMapping[index].AppId))"
class="form-select w-auto">
class="form-select">
</select>
<span asp-validation-for="DomainToAppMapping[index].AppId" class="text-danger"></span>
</div>

@ -5,14 +5,26 @@
<h3 class="mb-4">@ViewData["Title"]</h3>
<div class="row">
<div class="col-md-8">
<form method="post">
<div class="form-group form-check mb-4">
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label">Is admin</label>
</div>
<button name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
</form>
<form method="post">
<div class="form-check my-3">
<input asp-for="IsAdmin" type="checkbox" class="form-check-input" />
<label asp-for="IsAdmin" class="form-check-label">User is admin</label>
</div>
</div>
@if (Model.Approved.HasValue)
{
<div class="form-check my-3">
<input id="Approved" name="Approved" type="checkbox" value="true" class="form-check-input" @(Model.Approved.Value ? "checked" : "") />
<label for="Approved" class="form-check-label">User is approved</label>
</div>
<input name="Approved" type="hidden" value="false">
}
@if (Model.EmailConfirmed.HasValue)
{
<div class="form-check my-3">
<input id="EmailConfirmed" name="EmailConfirmed" value="true" type="checkbox" class="form-check-input" @(Model.EmailConfirmed.Value ? "checked" : "") />
<label for="EmailConfirmed" class="form-check-label">Email address is confirmed</label>
</div>
<input name="EmailConfirmed" type="hidden" value="false">
}
<button name="command" type="submit" class="btn btn-primary mt-3" value="Save" id="SaveUser">Save</button>
</form>

@ -24,7 +24,9 @@
{
/* include chart library inline so that it instantly renders */
<link rel="stylesheet" href="~/vendor/chartist/chartist.css" asp-append-version="true">
<link rel="stylesheet" href="~/vendor/chartist/chartist-plugin-tooltip.css" asp-append-version="true">
<script src="~/vendor/chartist/chartist.min.js" asp-append-version="true"></script>
<script src="~/vendor/chartist/chartist-plugin-tooltip.js" asp-append-version="true"></script>
<script>
const DashboardUtils = {
toDefaultCurrency(amount, rate) {

@ -2,6 +2,7 @@
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.Emails, "Emails", Context.GetStoreData().Id);
var hasCustomSettings = Model.IsSetup() && !Model.UsesFallback();
}
<div class="row mb-4">
@ -19,7 +20,25 @@
</div>
</div>
<partial name="EmailsBody" model="Model" />
<h3 class="mb-4">Email Server</h3>
@if (Model.IsFallbackSetup())
{
<label class="d-flex align-items-center mb-4">
<input type="checkbox" id="UseCustomSMTP" checked="@hasCustomSettings" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#SmtpSettings" aria-expanded="@hasCustomSettings" aria-controls="SmtpSettings" />
<div>
<span>Use custom SMTP settings for this store</span>
<div class="form-text">Otherwise, the server's SMTP settings will be used to send emails.</div>
</div>
</label>
<div class="checkout-settings collapse @(hasCustomSettings ? "show" : "")" id="SmtpSettings">
<partial name="EmailsBody" model="Model" />
</div>
}
else
{
<partial name="EmailsBody" model="Model" />
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial" />

@ -117,8 +117,7 @@
<code>{Payout.Metadata}*</code>
</td>
</tr>
<tr><td colspan="2">* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code>
</td></tr>
<tr><td colspan="2">* These fields are JSON objects. You can access properties within them using <a href="https://www.newtonsoft.com/json/help/html/SelectToken.htm#SelectTokenJSONPath" rel="noreferrer noopener" target="_blank">this syntax</a>. One example is <code>{Invoice.Metadata.itemCode}</code></td></tr>
</table>
</div>
</div>

@ -33,7 +33,7 @@
</select>
</div>
<div class="ms-3">
<button type="submit" role="button" class="btn btn-primary">Add User</button>
<button type="submit" role="button" class="btn btn-primary" id="AddUser">Add User</button>
</div>
</div>
</form>

@ -271,20 +271,7 @@
<label asp-for="AlwaysIncludeNonWitnessUTXO" class="form-check-label"></label>
</div>
</div>
@if (Model.SupportRBF)
{
<div class="form-group">
<label asp-for="AllowFeeBump" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Wallet/#rbf-replace-by-fee" target="_blank" rel="noreferrer noopener">
<vc:icon symbol="info" />
</a>
<select asp-for="AllowFeeBump" class="form-select w-auto">
<option value="Maybe">Randomize for higher privacy</option>
<option value="Yes">Yes</option>
<option value="No">No</option>
</select>
</div>
}
@if (!string.IsNullOrEmpty(Model.PayJoinBIP21))
{
<div class="form-group">

@ -42,6 +42,11 @@
margin-bottom: 0;
}
.no-marker > ul {
list-style-type: none;
padding-left: 0;
}
/* General and site-wide Bootstrap modifications */
p {
margin-bottom: 1.5rem;

Some files were not shown because too many files have changed in this diff Show More