From 39b34ff4edc74917720cd53d53383e302e5eda09 Mon Sep 17 00:00:00 2001 From: "nicolas.dorier" Date: Fri, 23 Mar 2018 16:24:57 +0900 Subject: [PATCH] Can invite user to manage your store --- BTCPayServer.Tests/TestAccount.cs | 6 +- .../Controllers/InvoiceController.UI.cs | 11 +- .../StoresController.LightningLike.cs | 1 + BTCPayServer/Controllers/StoresController.cs | 182 +++++++++--------- .../Controllers/UserStoresController.cs | 147 ++++++++++++++ BTCPayServer/Hosting/BTCPayServerServices.cs | 6 +- .../StoreViewModels/StoreUsersViewModel.cs | 28 +++ .../Models/StoreViewModels/StoresViewModel.cs | 10 +- .../Lightning/LightningLikePaymentHandler.cs | 4 + .../Services/Stores/StoreRepository.cs | 64 +++++- BTCPayServer/StorePolicies.cs | 26 +++ BTCPayServer/Views/Shared/_Layout.cshtml | 2 +- .../Views/Stores/RequestPairing.cshtml | 54 +++--- BTCPayServer/Views/Stores/StoreNavPages.cs | 3 +- BTCPayServer/Views/Stores/StoreUsers.cshtml | 56 ++++++ BTCPayServer/Views/Stores/Wallet.cshtml | 76 ++++---- BTCPayServer/Views/Stores/_Nav.cshtml | 1 + .../{Stores => UserStores}/CreateStore.cshtml | 0 .../{Stores => UserStores}/ListStores.cshtml | 11 +- 19 files changed, 514 insertions(+), 174 deletions(-) create mode 100644 BTCPayServer/Controllers/UserStoresController.cs create mode 100644 BTCPayServer/Models/StoreViewModels/StoreUsersViewModel.cs create mode 100644 BTCPayServer/StorePolicies.cs create mode 100644 BTCPayServer/Views/Stores/StoreUsers.cshtml rename BTCPayServer/Views/{Stores => UserStores}/CreateStore.cshtml (100%) rename BTCPayServer/Views/{Stores => UserStores}/ListStores.cshtml (78%) diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs index b344276d0..1e02933e5 100644 --- a/BTCPayServer.Tests/TestAccount.cs +++ b/BTCPayServer.Tests/TestAccount.cs @@ -56,10 +56,12 @@ namespace BTCPayServer.Tests public async Task CreateStoreAsync() { - var store = parent.PayTester.GetController(UserId); + var store = parent.PayTester.GetController(UserId); await store.CreateStore(new CreateStoreViewModel() { Name = "Test Store" }); StoreId = store.CreatedStoreId; - return store; + var store2 = parent.PayTester.GetController(UserId); + store2.CreatedStoreId = store.CreatedStoreId; + return store2; } public BTCPayNetwork SupportedNetwork { get; set; } diff --git a/BTCPayServer/Controllers/InvoiceController.UI.cs b/BTCPayServer/Controllers/InvoiceController.UI.cs index 2617b1547..c6490b444 100644 --- a/BTCPayServer/Controllers/InvoiceController.UI.cs +++ b/BTCPayServer/Controllers/InvoiceController.UI.cs @@ -417,7 +417,7 @@ namespace BTCPayServer.Controllers if (stores.Count() == 0) { StatusMessage = "Error: You need to create at least one store before creating a transaction"; - return RedirectToAction(nameof(StoresController.ListStores), "Stores"); + return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } return View(new CreateInvoiceModel() { Stores = stores }); } @@ -434,9 +434,18 @@ namespace BTCPayServer.Controllers return View(model); } var store = await _StoreRepository.FindStore(model.StoreId, GetUserId()); + StatusMessage = null; + if (store.Role != StoreRoles.Owner) + { + StatusMessage = "Error: You need to be owner of this store to create an invoice"; + } if (store.GetSupportedPaymentMethods(_NetworkProvider).Count() == 0) { StatusMessage = "Error: You need to configure the derivation scheme in order to create an invoice"; + } + + if(StatusMessage != null) + { return RedirectToAction(nameof(StoresController.UpdateStore), "Stores", new { storeId = store.Id diff --git a/BTCPayServer/Controllers/StoresController.LightningLike.cs b/BTCPayServer/Controllers/StoresController.LightningLike.cs index fed781576..86c975aab 100644 --- a/BTCPayServer/Controllers/StoresController.LightningLike.cs +++ b/BTCPayServer/Controllers/StoresController.LightningLike.cs @@ -15,6 +15,7 @@ namespace BTCPayServer.Controllers { public partial class StoresController { + [HttpGet] [Route("{storeId}/lightning/{cryptoCode}")] public async Task AddLightningNode(string storeId, string cryptoCode) diff --git a/BTCPayServer/Controllers/StoresController.cs b/BTCPayServer/Controllers/StoresController.cs index 03cb339a4..55061522d 100644 --- a/BTCPayServer/Controllers/StoresController.cs +++ b/BTCPayServer/Controllers/StoresController.cs @@ -27,10 +27,11 @@ namespace BTCPayServer.Controllers { [Route("stores")] [Authorize(AuthenticationSchemes = "Identity.Application")] - [Authorize(Policy = "CanAccessStore")] + [Authorize(Policy = StorePolicies.OwnStore)] [AutoValidateAntiforgeryToken] public partial class StoresController : Controller { + public string CreatedStoreId { get; set; } public StoresController( NBXplorerDashboard dashboard, IServiceProvider serviceProvider, @@ -83,32 +84,6 @@ namespace BTCPayServer.Controllers get; set; } - [HttpGet] - [Route("create")] - public IActionResult CreateStore() - { - return View(); - } - - [HttpPost] - [Route("create")] - public async Task CreateStore(CreateStoreViewModel vm) - { - if (!ModelState.IsValid) - { - return View(vm); - } - var store = await _Repo.CreateStore(GetUserId(), vm.Name); - CreatedStoreId = store.Id; - StatusMessage = "Store successfully created"; - return RedirectToAction(nameof(ListStores)); - } - - public string CreatedStoreId - { - get; set; - } - [HttpGet] [Route("{storeId}/wallet/{cryptoCode}")] public async Task Wallet(string storeId, string cryptoCode) @@ -125,79 +100,84 @@ namespace BTCPayServer.Controllers private string GetStoreUrl(string storeId) { return HttpContext.Request.GetAbsoluteRoot() + "/stores/" + storeId + "/"; + } + + [HttpGet] + [Route("{storeId}/users")] + public async Task StoreUsers(string storeId) + { + StoreUsersViewModel vm = new StoreUsersViewModel(); + await FillUsers(storeId, vm); + return View(vm); + } + + private async Task FillUsers(string storeId, StoreUsersViewModel vm) + { + var users = await _Repo.GetStoreUsers(storeId); + vm.StoreId = storeId; + vm.Users = users.Select(u => new StoreUsersViewModel.StoreUserViewModel() + { + Email = u.Email, + Id = u.Id, + Role = u.Role + }).ToList(); + } + + [HttpPost] + [Route("{storeId}/users")] + public async Task StoreUsers(string storeId, StoreUsersViewModel vm) + { + await FillUsers(storeId, 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(storeId, 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] - public async Task ListStores() + [Route("{storeId}/users/{userId}/delete")] + public async Task DeleteStoreUser(string storeId, string userId) { - StoresViewModel result = new StoresViewModel(); - result.StatusMessage = StatusMessage; - var stores = await _Repo.GetStoresByUserId(GetUserId()); - var balances = stores - .Select(s => s.GetSupportedPaymentMethods(_NetworkProvider) - .OfType() - .Select(d => ((Wallet: _WalletProvider.GetWallet(d.Network), - DerivationStrategy: d.DerivationStrategyBase))) - .Where(_ => _.Wallet != null) - .Select(async _ => (await GetBalanceString(_)) + " " + _.Wallet.Network.CryptoCode)) - .ToArray(); - - await Task.WhenAll(balances.SelectMany(_ => _)); - for (int i = 0; i < stores.Length; i++) - { - var store = stores[i]; - result.Stores.Add(new StoresViewModel.StoreViewModel() - { - Id = store.Id, - Name = store.StoreName, - WebSite = store.StoreWebsite, - Balances = balances[i].Select(t => t.Result).ToArray() - }); - } - return View(result); - } - - private static async Task GetBalanceString((BTCPayWallet Wallet, DerivationStrategyBase DerivationStrategy) _) - { - using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) - { - try - { - return (await _.Wallet.GetBalance(_.DerivationStrategy, cts.Token)).ToString(); - } - catch - { - return "--"; - } - } - } - - [HttpGet] - [Route("{storeId}/delete")] - public async Task DeleteStore(string storeId) - { - var store = await _Repo.FindStore(storeId, GetUserId()); + StoreUsersViewModel vm = new StoreUsersViewModel(); + var store = await _Repo.FindStore(storeId, userId); if (store == null) return NotFound(); + var user = await _UserManager.FindByIdAsync(userId); + if (user == null) + return NotFound(); return View("Confirm", new ConfirmModel() { - Title = "Delete store " + store.StoreName, - Description = "This store will still be accessible to users sharing it", + Title = $"Remove store user", + Description = $"Are you sure to remove access to remove {store.Role} access to {user.Email}?", Action = "Delete" }); } [HttpPost] - [Route("{storeId}/delete")] - public async Task DeleteStorePost(string storeId) + [Route("{storeId}/users/{userId}/delete")] + public async Task DeleteStoreUserPost(string storeId, string userId) { - var userId = GetUserId(); - var store = await _Repo.FindStore(storeId, GetUserId()); - if (store == null) - return NotFound(); - await _Repo.RemoveStore(storeId, userId); - StatusMessage = "Store removed successfully"; - return RedirectToAction(nameof(ListStores)); + await _Repo.RemoveStoreUser(storeId, userId); + StatusMessage = "User removed successfully"; + return RedirectToAction(nameof(StoreUsers), new { storeId = storeId, userId = userId }); } [HttpGet] @@ -403,15 +383,17 @@ namespace BTCPayServer.Controllers return View(model); } model.Label = model.Label ?? String.Empty; - if (storeId == null) // Permissions are not checked by Policy if the storeId is not passed by url + storeId = model.StoreId ?? storeId; + var userId = GetUserId(); + if (userId == null) + return Unauthorized(); + var store = await _Repo.FindStore(storeId, userId); + if (store == null) + return Unauthorized(); + if (store.Role != StoreRoles.Owner) { - storeId = model.StoreId; - var userId = GetUserId(); - if (userId == null) - return Unauthorized(); - var store = await _Repo.FindStore(storeId, userId); - if (store == null) - return Unauthorized(); + StatusMessage = "Error: You need to be owner of this store to request pairing codes"; + return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } var tokenRequest = new TokenRequest() @@ -491,11 +473,13 @@ namespace BTCPayServer.Controllers [Route("/api-access-request")] public async Task RequestPairing(string pairingCode, string selectedStore = null) { + if (pairingCode == null) + return NotFound(); var pairing = await _TokenRepository.GetPairingAsync(pairingCode); if (pairing == null) { StatusMessage = "Unknown pairing code"; - return RedirectToAction(nameof(ListStores)); + return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); } else { @@ -517,7 +501,7 @@ namespace BTCPayServer.Controllers } [HttpPost] - [Route("api-access-request")] + [Route("/api-access-request")] public async Task Pair(string pairingCode, string selectedStore) { if (pairingCode == null) @@ -527,6 +511,12 @@ namespace BTCPayServer.Controllers if (store == null || pairing == null) return NotFound(); + if(store.Role != StoreRoles.Owner) + { + StatusMessage = "Error: You can't approve a pairing without being owner of the store"; + return RedirectToAction(nameof(UserStoresController.ListStores), "UserStores"); + } + var pairingResult = await _TokenRepository.PairWithStoreAsync(pairingCode, store.Id); if (pairingResult == PairingResult.Complete || pairingResult == PairingResult.Partial) { diff --git a/BTCPayServer/Controllers/UserStoresController.cs b/BTCPayServer/Controllers/UserStoresController.cs new file mode 100644 index 000000000..b0000ee07 --- /dev/null +++ b/BTCPayServer/Controllers/UserStoresController.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Models; +using BTCPayServer.Models.StoreViewModels; +using BTCPayServer.Services.Stores; +using BTCPayServer.Services.Wallets; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using NBXplorer.DerivationStrategy; + +namespace BTCPayServer.Controllers +{ + [Route("stores")] + [Authorize(AuthenticationSchemes = "Identity.Application")] + [AutoValidateAntiforgeryToken] + public partial class UserStoresController : Controller + { + private StoreRepository _Repo; + private BTCPayNetworkProvider _NetworkProvider; + private UserManager _UserManager; + private BTCPayWalletProvider _WalletProvider; + + public UserStoresController( + UserManager userManager, + BTCPayNetworkProvider networkProvider, + BTCPayWalletProvider walletProvider, + StoreRepository storeRepository) + { + _Repo = storeRepository; + _NetworkProvider = networkProvider; + _UserManager = userManager; + _WalletProvider = walletProvider; + } + [HttpGet] + [Route("{storeId}/delete")] + public async Task DeleteStore(string storeId) + { + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + return View("Confirm", new ConfirmModel() + { + Title = "Delete store " + store.StoreName, + Description = "This store will still be accessible to users sharing it", + Action = "Delete" + }); + } + + [HttpGet] + [Route("create")] + public IActionResult CreateStore() + { + return View(); + } + + public string CreatedStoreId + { + get; set; + } + + [HttpPost] + [Route("{storeId}/delete")] + public async Task DeleteStorePost(string storeId) + { + var userId = GetUserId(); + var store = await _Repo.FindStore(storeId, GetUserId()); + if (store == null) + return NotFound(); + await _Repo.RemoveStore(storeId, userId); + StatusMessage = "Store removed successfully"; + return RedirectToAction(nameof(ListStores)); + } + + [TempData] + public string StatusMessage { get; set; } + + [HttpGet] + public async Task ListStores() + { + StoresViewModel result = new StoresViewModel(); + var stores = await _Repo.GetStoresByUserId(GetUserId()); + + var balances = stores + .Select(s => s.GetSupportedPaymentMethods(_NetworkProvider) + .OfType() + .Select(d => ((Wallet: _WalletProvider.GetWallet(d.Network), + DerivationStrategy: d.DerivationStrategyBase))) + .Where(_ => _.Wallet != null) + .Select(async _ => (await GetBalanceString(_)) + " " + _.Wallet.Network.CryptoCode)) + .ToArray(); + + await Task.WhenAll(balances.SelectMany(_ => _)); + for (int i = 0; i < stores.Length; i++) + { + var store = stores[i]; + result.Stores.Add(new StoresViewModel.StoreViewModel() + { + Id = store.Id, + Name = store.StoreName, + WebSite = store.StoreWebsite, + IsOwner = store.Role == StoreRoles.Owner, + Balances = store.Role == StoreRoles.Owner ? balances[i].Select(t => t.Result).ToArray() : Array.Empty() + }); + } + return View(result); + } + + [HttpPost] + [Route("create")] + public async Task CreateStore(CreateStoreViewModel vm) + { + if (!ModelState.IsValid) + { + return View(vm); + } + var store = await _Repo.CreateStore(GetUserId(), vm.Name); + CreatedStoreId = store.Id; + StatusMessage = "Store successfully created"; + return RedirectToAction(nameof(ListStores)); + } + + private static async Task GetBalanceString((BTCPayWallet Wallet, DerivationStrategyBase DerivationStrategy) _) + { + using (CancellationTokenSource cts = new CancellationTokenSource(TimeSpan.FromSeconds(10))) + { + try + { + return (await _.Wallet.GetBalance(_.DerivationStrategy, cts.Token)).ToString(); + } + catch + { + return "--"; + } + } + } + + + private string GetUserId() + { + return _UserManager.GetUserId(User); + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index cb85c51b7..120e74b38 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -173,14 +173,14 @@ namespace BTCPayServer.Hosting services.AddAuthorization(o => { - o.AddPolicy("CanAccessStore", builder => + o.AddPolicy(StorePolicies.CanAccessStores, builder => { builder.AddRequirements(new OwnStoreAuthorizationRequirement()); }); - o.AddPolicy("OwnStore", builder => + o.AddPolicy(StorePolicies.OwnStore, builder => { - builder.AddRequirements(new OwnStoreAuthorizationRequirement("Owner")); + builder.AddRequirements(new OwnStoreAuthorizationRequirement(StoreRoles.Owner)); }); }); diff --git a/BTCPayServer/Models/StoreViewModels/StoreUsersViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoreUsersViewModel.cs new file mode 100644 index 000000000..46df85d8a --- /dev/null +++ b/BTCPayServer/Models/StoreViewModels/StoreUsersViewModel.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer.Models.StoreViewModels +{ + public class StoreUsersViewModel + { + public class StoreUserViewModel + { + public string Email { get; set; } + public string Role { get; set; } + public string Id { get; set; } + } + public StoreUsersViewModel() + { + Role = StoreRoles.Guest; + } + [Required] + [EmailAddress] + public string Email { get; set; } + public string StoreId { get; set; } + public string Role { get; set; } + public List Users { get; set; } + } +} diff --git a/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs b/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs index 25e07b7ca..5f05d61f9 100644 --- a/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs +++ b/BTCPayServer/Models/StoreViewModels/StoresViewModel.cs @@ -8,14 +8,11 @@ namespace BTCPayServer.Models.StoreViewModels { public class StoresViewModel { - public string StatusMessage - { - get; set; - } public List Stores { get; set; } = new List(); + public class StoreViewModel { public string Name @@ -32,6 +29,11 @@ namespace BTCPayServer.Models.StoreViewModels { get; set; } + public bool IsOwner + { + get; + set; + } public string[] Balances { get; set; diff --git a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs index fbfd4346d..041975fca 100644 --- a/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs +++ b/BTCPayServer/Payments/Lightning/LightningLikePaymentHandler.cs @@ -66,6 +66,10 @@ namespace BTCPayServer.Payments.Lightning { info = await client.GetInfo(cts.Token); } + catch (OperationCanceledException) when (cts.IsCancellationRequested) + { + throw new Exception($"The lightning node did not replied in a timely maner"); + } catch (Exception ex) { throw new Exception($"Error while connecting to the API ({ex.Message})"); diff --git a/BTCPayServer/Services/Stores/StoreRepository.cs b/BTCPayServer/Services/Stores/StoreRepository.cs index 1f0afe98d..c7f9c9342 100644 --- a/BTCPayServer/Services/Stores/StoreRepository.cs +++ b/BTCPayServer/Services/Stores/StoreRepository.cs @@ -48,14 +48,72 @@ namespace BTCPayServer.Services.Stores } } + public class StoreUser + { + public string Id { get; set; } + public string Email { get; set; } + public string Role { get; set; } + } + public async Task GetStoreUsers(string storeId) + { + if (storeId == null) + throw new ArgumentNullException(nameof(storeId)); + using (var ctx = _ContextFactory.CreateContext()) + { + return await ctx + .UserStore + .Where(u => u.StoreDataId == storeId) + .Select(u => new StoreUser() + { + Id = u.ApplicationUserId, + Email = u.ApplicationUser.Email, + Role = u.Role + }).ToArrayAsync(); + } + } + public async Task GetStoresByUserId(string userId) { using (var ctx = _ContextFactory.CreateContext()) { - return await ctx.UserStore + return (await ctx.UserStore .Where(u => u.ApplicationUserId == userId) - .Select(u => u.StoreData) - .ToArrayAsync(); + .Select(u => new { u.StoreData, u.Role }) + .ToArrayAsync()) + .Select(u => + { + u.StoreData.Role = u.Role; + return u.StoreData; + }).ToArray(); + } + } + + public async Task AddStoreUser(string storeId, string userId, string role) + { + using (var ctx = _ContextFactory.CreateContext()) + { + var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId, Role = role }; + ctx.UserStore.Add(userStore); + try + { + await ctx.SaveChangesAsync(); + return true; + } + catch (DbUpdateException) + { + return false; + } + } + } + + public async Task RemoveStoreUser(string storeId, string userId) + { + using (var ctx = _ContextFactory.CreateContext()) + { + var userStore = new UserStore() { StoreDataId = storeId, ApplicationUserId = userId }; + ctx.UserStore.Add(userStore); + ctx.Entry(userStore).State = EntityState.Deleted; + await ctx.SaveChangesAsync(); } } diff --git a/BTCPayServer/StorePolicies.cs b/BTCPayServer/StorePolicies.cs new file mode 100644 index 000000000..876e58700 --- /dev/null +++ b/BTCPayServer/StorePolicies.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace BTCPayServer +{ + public class StorePolicies + { + public const string CanAccessStores = "CanAccessStore"; + public const string OwnStore = "OwnStore"; + } + public class StoreRoles + { + public const string Owner = "Owner"; + public const string Guest = "Guest"; + public static IEnumerable AllRoles + { + get + { + yield return Owner; + yield return Guest; + } + } + } +} diff --git a/BTCPayServer/Views/Shared/_Layout.cshtml b/BTCPayServer/Views/Shared/_Layout.cshtml index 119d24e53..30f5bf895 100644 --- a/BTCPayServer/Views/Shared/_Layout.cshtml +++ b/BTCPayServer/Views/Shared/_Layout.cshtml @@ -54,7 +54,7 @@ { } - +