using BTCPayServer.Authentication; using BTCPayServer.Configuration; using BTCPayServer.Data; using BTCPayServer.HostedServices; using BTCPayServer.Models; using BTCPayServer.Models.StoreViewModels; using BTCPayServer.Security; using BTCPayServer.Services; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using BTCPayServer.Services.Wallets; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Options; using NBitcoin; using NBitcoin.DataEncoders; using NBXplorer.DerivationStrategy; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; namespace BTCPayServer.Controllers { [Route("stores")] [Authorize(AuthenticationSchemes = Policies.CookieAuthentication)] [Authorize(Policy = Policies.CanModifyStoreSettings.Key)] [AutoValidateAntiforgeryToken] public partial class StoresController : Controller { public string CreatedStoreId { get; set; } public StoresController( NBXplorerDashboard dashboard, IServiceProvider serviceProvider, BTCPayServerOptions btcpayServerOptions, BTCPayServerEnvironment btcpayEnv, IOptions mvcJsonOptions, StoreRepository repo, TokenRepository tokenRepo, UserManager userManager, AccessTokenController tokenController, BTCPayWalletProvider walletProvider, BTCPayNetworkProvider networkProvider, ExplorerClientProvider explorerProvider, IFeeProviderFactory feeRateProvider, LanguageService langService, IHostingEnvironment env, CoinAverageSettings coinAverage) { _Dashboard = dashboard; _Repo = repo; _TokenRepository = tokenRepo; _UserManager = userManager; _LangService = langService; _TokenController = tokenController; _WalletProvider = walletProvider; _Env = env; _NetworkProvider = networkProvider; _ExplorerProvider = explorerProvider; _MvcJsonOptions = mvcJsonOptions.Value; _FeeRateProvider = feeRateProvider; _ServiceProvider = serviceProvider; _BtcpayServerOptions = btcpayServerOptions; _BTCPayEnv = btcpayEnv; _CoinAverage = coinAverage; } CoinAverageSettings _CoinAverage; NBXplorerDashboard _Dashboard; BTCPayServerOptions _BtcpayServerOptions; BTCPayServerEnvironment _BTCPayEnv; IServiceProvider _ServiceProvider; BTCPayNetworkProvider _NetworkProvider; private ExplorerClientProvider _ExplorerProvider; private MvcJsonOptions _MvcJsonOptions; private IFeeProviderFactory _FeeRateProvider; BTCPayWalletProvider _WalletProvider; AccessTokenController _TokenController; StoreRepository _Repo; TokenRepository _TokenRepository; UserManager _UserManager; private LanguageService _LangService; IHostingEnvironment _Env; [TempData] public string StatusMessage { get; set; } [HttpGet] [Route("{storeId}/wallet/{cryptoCode}")] public IActionResult Wallet(string cryptoCode) { WalletModel model = new WalletModel(); model.ServerUrl = GetStoreUrl(StoreData.Id); model.CryptoCurrency = cryptoCode; return View(model); } private string GetStoreUrl(string storeId) { return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/"; } [HttpGet] [Route("{storeId}/users")] public async Task StoreUsers() { StoreUsersViewModel vm = new StoreUsersViewModel(); await FillUsers(vm); return View(vm); } private async Task FillUsers(StoreUsersViewModel vm) { var users = await _Repo.GetStoreUsers(StoreData.Id); vm.StoreId = StoreData.Id; vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel() { Email = u.Email, Id = u.Id, Role = u.Role }).ToList(); } public StoreData StoreData { get { return this.HttpContext.GetStoreData(); } } [HttpPost] [Route("{storeId}/users")] public async Task StoreUsers(StoreUsersViewModel vm) { await FillUsers(vm); if (!ModelState.IsValid) { return View(vm); } var user = await _UserManager.FindByEmailAsync(vm.Email); if (user == null) { ModelState.AddModelError(nameof(vm.Email), "User not found"); return View(vm); } if (!StoreRoles.AllRoles.Contains(vm.Role)) { ModelState.AddModelError(nameof(vm.Role), "Invalid role"); return View(vm); } if (!await _Repo.AddStoreUser(StoreData.Id, user.Id, vm.Role)) { ModelState.AddModelError(nameof(vm.Email), "The user already has access to this store"); return View(vm); } StatusMessage = "User added successfully"; return RedirectToAction(nameof(StoreUsers)); } [HttpGet] [Route("{storeId}/users/{userId}/delete")] public async Task DeleteStoreUser(string userId) { StoreUsersViewModel vm = new StoreUsersViewModel(); var user = await _UserManager.FindByIdAsync(userId); if (user == null) return NotFound(); return View("Confirm", new ConfirmModel() { Title = $"Remove store user", Description = $"Are you sure to remove access to remove access to {user.Email}?", Action = "Delete" }); } [HttpPost] [Route("{storeId}/users/{userId}/delete")] public async Task DeleteStoreUserPost(string storeId, string userId) { await _Repo.RemoveStoreUser(storeId, userId); StatusMessage = "User removed successfully"; return RedirectToAction(nameof(StoreUsers), new { storeId = storeId, userId = userId }); } [HttpGet] [Route("{storeId}/checkout")] public IActionResult CheckoutExperience() { var storeBlob = StoreData.GetStoreBlob(); var vm = new CheckoutExperienceViewModel(); vm.SetCryptoCurrencies(_ExplorerProvider, StoreData.GetDefaultCrypto()); vm.SetLanguages(_LangService, storeBlob.DefaultLang); vm.LightningMaxValue = storeBlob.LightningMaxValue?.ToString() ?? ""; vm.OnChainMinValue = storeBlob.OnChainMinValue?.ToString() ?? ""; vm.AllowCoinConversion = storeBlob.AllowCoinConversion; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.CustomCSS = storeBlob.CustomCSS?.AbsoluteUri; vm.CustomLogo = storeBlob.CustomLogo?.AbsoluteUri; return View(vm); } [HttpPost] [Route("{storeId}/checkout")] public async Task CheckoutExperience(CheckoutExperienceViewModel model) { CurrencyValue lightningMaxValue = null; if (!string.IsNullOrWhiteSpace(model.LightningMaxValue)) { if (!CurrencyValue.TryParse(model.LightningMaxValue, out lightningMaxValue)) { ModelState.AddModelError(nameof(model.LightningMaxValue), "Invalid lightning max value"); } } CurrencyValue onchainMinValue = null; if (!string.IsNullOrWhiteSpace(model.OnChainMinValue)) { if (!CurrencyValue.TryParse(model.OnChainMinValue, out onchainMinValue)) { ModelState.AddModelError(nameof(model.OnChainMinValue), "Invalid on chain min value"); } } bool needUpdate = false; var blob = StoreData.GetStoreBlob(); if (StoreData.GetDefaultCrypto() != model.DefaultCryptoCurrency) { needUpdate = true; StoreData.SetDefaultCrypto(model.DefaultCryptoCurrency); } model.SetCryptoCurrencies(_ExplorerProvider, model.DefaultCryptoCurrency); model.SetLanguages(_LangService, model.DefaultLang); if (!ModelState.IsValid) { return View(model); } blob.DefaultLang = model.DefaultLang; blob.AllowCoinConversion = model.AllowCoinConversion; blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.LightningMaxValue = lightningMaxValue; blob.OnChainMinValue = onchainMinValue; blob.CustomLogo = string.IsNullOrWhiteSpace(model.CustomLogo) ? null : new Uri(model.CustomLogo, UriKind.Absolute); blob.CustomCSS = string.IsNullOrWhiteSpace(model.CustomCSS) ? null : new Uri(model.CustomCSS, UriKind.Absolute); if (StoreData.SetStoreBlob(blob)) { needUpdate = true; } if (needUpdate) { await _Repo.UpdateStore(StoreData); StatusMessage = "Store successfully updated"; } return RedirectToAction(nameof(CheckoutExperience), new { storeId = StoreData.Id }); } [HttpGet] [Route("{storeId}")] public IActionResult UpdateStore(string storeId) { var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); var storeBlob = store.GetStoreBlob(); var vm = new StoreViewModel(); vm.SetExchangeRates(GetSupportedExchanges(), storeBlob.PreferredExchange ?? CoinAverageRateProvider.CoinAverageName); vm.Id = store.Id; vm.StoreName = store.StoreName; vm.StoreWebsite = store.StoreWebsite; vm.NetworkFee = !storeBlob.NetworkFeeDisabled; vm.SpeedPolicy = store.SpeedPolicy; AddPaymentMethods(store, vm); vm.MonitoringExpiration = storeBlob.MonitoringExpiration; vm.InvoiceExpiration = storeBlob.InvoiceExpiration; vm.RateMultiplier = (double)storeBlob.GetRateMultiplier(); vm.LightningDescriptionTemplate = storeBlob.LightningDescriptionTemplate; return View(vm); } private void AddPaymentMethods(StoreData store, StoreViewModel vm) { var derivationByCryptoCode = store .GetSupportedPaymentMethods(_NetworkProvider) .OfType() .ToDictionary(c => c.Network.CryptoCode); foreach (var network in _NetworkProvider.GetAll()) { var strategy = derivationByCryptoCode.TryGet(network.CryptoCode); vm.DerivationSchemes.Add(new StoreViewModel.DerivationScheme() { Crypto = network.CryptoCode, Value = strategy?.DerivationStrategyBase?.ToString() ?? string.Empty }); } var lightningByCryptoCode = store .GetSupportedPaymentMethods(_NetworkProvider) .OfType() .ToDictionary(c => c.CryptoCode); foreach (var network in _NetworkProvider.GetAll()) { var lightning = lightningByCryptoCode.TryGet(network.CryptoCode); vm.LightningNodes.Add(new StoreViewModel.LightningNode() { CryptoCode = network.CryptoCode, Address = lightning?.GetLightningUrl()?.BaseUri.AbsoluteUri ?? string.Empty }); } } [HttpPost] [Route("{storeId}")] public async Task UpdateStore(StoreViewModel model) { model.SetExchangeRates(GetSupportedExchanges(), model.PreferredExchange); if (!ModelState.IsValid) { return View(model); } if (model.PreferredExchange != null) model.PreferredExchange = model.PreferredExchange.Trim().ToLowerInvariant(); AddPaymentMethods(StoreData, model); bool needUpdate = false; if (StoreData.SpeedPolicy != model.SpeedPolicy) { needUpdate = true; StoreData.SpeedPolicy = model.SpeedPolicy; } if (StoreData.StoreName != model.StoreName) { needUpdate = true; StoreData.StoreName = model.StoreName; } if (StoreData.StoreWebsite != model.StoreWebsite) { needUpdate = true; StoreData.StoreWebsite = model.StoreWebsite; } var blob = StoreData.GetStoreBlob(); blob.NetworkFeeDisabled = !model.NetworkFee; blob.MonitoringExpiration = model.MonitoringExpiration; blob.InvoiceExpiration = model.InvoiceExpiration; blob.LightningDescriptionTemplate = model.LightningDescriptionTemplate ?? string.Empty; bool newExchange = blob.PreferredExchange != model.PreferredExchange; blob.PreferredExchange = model.PreferredExchange; blob.SetRateMultiplier(model.RateMultiplier); if (StoreData.SetStoreBlob(blob)) { needUpdate = true; } if (newExchange) { if (!GetSupportedExchanges().Select(c => c.Name).Contains(blob.PreferredExchange, StringComparer.OrdinalIgnoreCase)) { ModelState.AddModelError(nameof(model.PreferredExchange), $"Unsupported exchange ({model.RateSource})"); return View(model); } } if (needUpdate) { await _Repo.UpdateStore(StoreData); StatusMessage = "Store successfully updated"; } return RedirectToAction(nameof(UpdateStore), new { storeId = StoreData.Id }); } private CoinAverageExchange[] GetSupportedExchanges() { return _CoinAverage.AvailableExchanges .Select(c => c.Value) .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); } private DerivationStrategy ParseDerivationStrategy(string derivationScheme, Script hint, BTCPayNetwork network) { var parser = new DerivationSchemeParser(network.NBitcoinNetwork); parser.HintScriptPubKey = hint; return new DerivationStrategy(parser.Parse(derivationScheme), network); } [HttpGet] [Route("{storeId}/Tokens")] public async Task ListTokens() { var model = new TokensViewModel(); var tokens = await _TokenRepository.GetTokensByStoreIdAsync(StoreData.Id); model.StatusMessage = StatusMessage; model.Tokens = tokens.Select(t => new TokenViewModel() { Facade = t.Facade, Label = t.Label, SIN = t.SIN, Id = t.Value }).ToArray(); model.ApiKey = (await _TokenRepository.GetLegacyAPIKeys(StoreData.Id)).FirstOrDefault(); if (model.ApiKey == null) model.EncodedApiKey = "*API Key*"; else model.EncodedApiKey = Encoders.Base64.EncodeData(Encoders.ASCII.DecodeData(model.ApiKey)); return View(model); } [HttpPost] [Route("/api-tokens")] [Route("{storeId}/Tokens/Create")] [AllowAnonymous] public async Task CreateToken(CreateTokenViewModel model) { if (!ModelState.IsValid) { return View(model); } model.Label = model.Label ?? String.Empty; var userId = GetUserId(); if (userId == null) return Challenge(Policies.CookieAuthentication); var store = StoreData; var storeId = StoreData?.Id; if (storeId == null) { storeId = model.StoreId; store = await _Repo.FindStore(storeId, userId); if (store == null) return Challenge(Policies.CookieAuthentication); } if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { return Challenge(Policies.CookieAuthentication); } var tokenRequest = new TokenRequest() { Facade = model.Facade, Label = model.Label, Id = model.PublicKey == null ? null : NBitpayClient.Extensions.BitIdExtensions.GetBitIDSIN(new PubKey(model.PublicKey)) }; string pairingCode = null; if (model.PublicKey == null) { tokenRequest.PairingCode = await _TokenRepository.CreatePairingCodeAsync(); await _TokenRepository.UpdatePairingCode(new PairingCodeEntity() { Id = tokenRequest.PairingCode, Facade = model.Facade, Label = model.Label, }); await _TokenRepository.PairWithStoreAsync(tokenRequest.PairingCode, storeId); pairingCode = tokenRequest.PairingCode; } else { pairingCode = ((DataWrapper>)await _TokenController.Tokens(tokenRequest)).Data[0].PairingCode; } GeneratedPairingCode = pairingCode; return RedirectToAction(nameof(RequestPairing), new { pairingCode = pairingCode, selectedStore = storeId }); } public string GeneratedPairingCode { get; set; } [HttpGet] [Route("/api-tokens")] [Route("{storeId}/Tokens/Create")] [AllowAnonymous] public async Task CreateToken() { var userId = GetUserId(); if (string.IsNullOrWhiteSpace(userId)) return Challenge(Policies.CookieAuthentication); var storeId = StoreData?.Id; if (StoreData != null) { if (!StoreData.HasClaim(Policies.CanModifyStoreSettings.Key)) { return Challenge(Policies.CookieAuthentication); } } var model = new CreateTokenViewModel(); model.Facade = "merchant"; ViewBag.HidePublicKey = storeId == null; ViewBag.ShowStores = storeId == null; ViewBag.ShowMenu = storeId != null; model.StoreId = storeId; if (storeId == null) { var stores = await _Repo.GetStoresByUserId(userId); model.Stores = new SelectList(stores.Where(s => s.HasClaim(Policies.CanModifyStoreSettings.Key)), nameof(StoreData.Id), nameof(StoreData.StoreName), storeId); } if (model.Stores.Count() == 0) { StatusMessage = "Error: You need to be owner of at least one store before pairing"; return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } return View(model); } [HttpPost] [Route("{storeId}/Tokens/Delete")] public async Task DeleteToken(string tokenId) { var token = await _TokenRepository.GetToken(tokenId); if (token == null || token.StoreId != StoreData.Id || !await _TokenRepository.DeleteToken(tokenId)) StatusMessage = "Failure to revoke this token"; else StatusMessage = "Token revoked"; return RedirectToAction(nameof(ListTokens)); } [HttpPost] [Route("{storeId}/tokens/apikey")] public async Task GenerateAPIKey() { var store = HttpContext.GetStoreData(); if (store == null) return NotFound(); await _TokenRepository.GenerateLegacyAPIKey(StoreData.Id); StatusMessage = "API Key re-generated"; return RedirectToAction(nameof(ListTokens)); } [HttpGet] [Route("/api-access-request")] [AllowAnonymous] public async Task RequestPairing(string pairingCode, string selectedStore = null) { var userId = GetUserId(); if (userId == null) return Challenge(Policies.CookieAuthentication); if (pairingCode == null) return NotFound(); var pairing = await _TokenRepository.GetPairingAsync(pairingCode); if (pairing == null) { StatusMessage = "Unknown pairing code"; return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } else { var stores = await _Repo.GetStoresByUserId(userId); return View(new PairingModel() { Id = pairing.Id, Facade = pairing.Facade, Label = pairing.Label, SIN = pairing.SIN ?? "Server-Initiated Pairing", SelectedStore = selectedStore ?? stores.FirstOrDefault()?.Id, Stores = stores.Where(u => u.HasClaim(Policies.CanModifyStoreSettings.Key)).Select(s => new PairingModel.StoreViewModel() { Id = s.Id, Name = string.IsNullOrEmpty(s.StoreName) ? s.Id : s.StoreName }).ToArray() }); } } [HttpPost] [Route("/api-access-request")] [AllowAnonymous] public async Task Pair(string pairingCode, string selectedStore) { if (pairingCode == null) return NotFound(); var userId = GetUserId(); if (userId == null) return Challenge(Policies.CookieAuthentication); var store = await _Repo.FindStore(selectedStore, userId); var pairing = await _TokenRepository.GetPairingAsync(pairingCode); if (store == null || pairing == null) return NotFound(); if (!store.HasClaim(Policies.CanModifyStoreSettings.Key)) { return Challenge(Policies.CookieAuthentication); } var pairingResult = await _TokenRepository.PairWithStoreAsync(pairingCode, store.Id); if (pairingResult == PairingResult.Complete || pairingResult == PairingResult.Partial) { StatusMessage = "Pairing is successful"; if (pairingResult == PairingResult.Partial) StatusMessage = "Server initiated pairing code: " + pairingCode; return RedirectToAction(nameof(ListTokens), new { storeId = store.Id }); } else { StatusMessage = $"Pairing failed ({pairingResult})"; return RedirectToAction(nameof(ListTokens), new { storeId = store.Id }); } } private string GetUserId() { if (User.Identity.AuthenticationType != Policies.CookieAuthentication) return null; return _UserManager.GetUserId(User); } } }