diff --git a/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj b/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj index 0b04b5f0a9..e1ecd781e0 100644 --- a/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj +++ b/BTCPayServer.Abstractions/BTCPayServer.Abstractions.csproj @@ -32,8 +32,8 @@ - - + + diff --git a/BTCPayServer.Client/BTCPayServerClient.Files.cs b/BTCPayServer.Client/BTCPayServerClient.Files.cs new file mode 100644 index 0000000000..9f2c1353e7 --- /dev/null +++ b/BTCPayServer.Client/BTCPayServerClient.Files.cs @@ -0,0 +1,29 @@ +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Client.Models; + +namespace BTCPayServer.Client; + +public partial class BTCPayServerClient +{ + public virtual async Task GetFiles(CancellationToken token = default) + { + return await SendHttpRequest("api/v1/files", null, HttpMethod.Get, token); + } + + public virtual async Task GetFile(string fileId, CancellationToken token = default) + { + return await SendHttpRequest($"api/v1/files/{fileId}", null, HttpMethod.Get, token); + } + + public virtual async Task UploadFile(string filePath, string mimeType, CancellationToken token = default) + { + return await UploadFileRequest("api/v1/files", filePath, mimeType, "file", HttpMethod.Post, token); + } + + public virtual async Task DeleteFile(string fileId, CancellationToken token = default) + { + await SendHttpRequest($"api/v1/files/{fileId}", null, HttpMethod.Delete, token); + } +} diff --git a/BTCPayServer.Client/BTCPayServerClient.Notifications.cs b/BTCPayServer.Client/BTCPayServerClient.Notifications.cs index bdae85d308..5c55f9e37c 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Notifications.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Notifications.cs @@ -9,7 +9,7 @@ namespace BTCPayServer.Client; public partial class BTCPayServerClient { public virtual async Task> GetNotifications(bool? seen = null, int? skip = null, - int? take = null, CancellationToken token = default) + int? take = null, string[] storeId = null, CancellationToken token = default) { var queryPayload = new Dictionary(); if (seen != null) @@ -18,6 +18,8 @@ public virtual async Task> GetNotifications(bool? queryPayload.Add(nameof(skip), skip); if (take != null) queryPayload.Add(nameof(take), take); + if (storeId != null) + queryPayload.Add(nameof(storeId), storeId); return await SendHttpRequest>("api/v1/users/me/notifications", queryPayload, HttpMethod.Get, token); } diff --git a/BTCPayServer.Client/BTCPayServerClient.Stores.cs b/BTCPayServer.Client/BTCPayServerClient.Stores.cs index c331a10a89..3bfbe31134 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Stores.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Stores.cs @@ -37,4 +37,13 @@ public virtual async Task UpdateStore(string storeId, UpdateStoreRequ return await SendHttpRequest($"api/v1/stores/{storeId}", request, HttpMethod.Put, token); } + public virtual async Task UploadStoreLogo(string storeId, string filePath, string mimeType, CancellationToken token = default) + { + return await UploadFileRequest($"api/v1/stores/{storeId}/logo", filePath, mimeType, "file", HttpMethod.Post, token); + } + + public virtual async Task DeleteStoreLogo(string storeId, CancellationToken token = default) + { + await SendHttpRequest($"api/v1/stores/{storeId}/logo", null, HttpMethod.Delete, token); + } } diff --git a/BTCPayServer.Client/BTCPayServerClient.Users.cs b/BTCPayServer.Client/BTCPayServerClient.Users.cs index e4d212310d..9518f9c06b 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Users.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Users.cs @@ -18,6 +18,16 @@ public virtual async Task UpdateCurrentUser(UpdateApplicati return await SendHttpRequest("api/v1/users/me", request, HttpMethod.Put, token); } + public virtual async Task UploadCurrentUserProfilePicture(string filePath, string mimeType, CancellationToken token = default) + { + return await UploadFileRequest("api/v1/users/me/picture", filePath, mimeType, "file", HttpMethod.Post, token); + } + + public virtual async Task DeleteCurrentUserProfilePicture(CancellationToken token = default) + { + await SendHttpRequest("api/v1/users/me/picture", null, HttpMethod.Delete, token); + } + public virtual async Task CreateUser(CreateApplicationUserRequest request, CancellationToken token = default) { return await SendHttpRequest("api/v1/users", request, HttpMethod.Post, token); diff --git a/BTCPayServer.Client/BTCPayServerClient.cs b/BTCPayServer.Client/BTCPayServerClient.cs index 79372f418d..1e3a035354 100644 --- a/BTCPayServer.Client/BTCPayServerClient.cs +++ b/BTCPayServer.Client/BTCPayServerClient.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; @@ -152,6 +153,19 @@ protected virtual HttpRequestMessage CreateHttpRequest(string path, return request; } + protected virtual async Task UploadFileRequest(string apiPath, string filePath, string mimeType, string formFieldName, HttpMethod method = null, CancellationToken token = default) + { + using MultipartFormDataContent multipartContent = new(); + var fileContent = new StreamContent(File.OpenRead(filePath)); + var fileName = Path.GetFileName(filePath); + fileContent.Headers.ContentType = MediaTypeHeaderValue.Parse(mimeType); + multipartContent.Add(fileContent, formFieldName, fileName); + var req = CreateHttpRequest(apiPath, null, method ?? HttpMethod.Post); + req.Content = multipartContent; + using var resp = await _httpClient.SendAsync(req, token); + return await HandleResponse(resp); + } + public static void AppendPayloadToQuery(UriBuilder uri, KeyValuePair keyValuePair) { if (uri.Query.Length > 1) diff --git a/BTCPayServer.Client/Models/FileData.cs b/BTCPayServer.Client/Models/FileData.cs new file mode 100644 index 0000000000..ece4e8b100 --- /dev/null +++ b/BTCPayServer.Client/Models/FileData.cs @@ -0,0 +1,16 @@ +using System; +using Newtonsoft.Json; + +namespace BTCPayServer.Client.Models; + +public class FileData +{ + public string Id { get; set; } + public string UserId { get; set; } + public string Uri { get; set; } + public string Url { get; set; } + public string OriginalName { get; set; } + public string StorageName { get; set; } + [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] + public DateTimeOffset? CreatedAt { get; set; } +} diff --git a/BTCPayServer.Client/Models/NotificationData.cs b/BTCPayServer.Client/Models/NotificationData.cs index ef1014738f..b0306f136c 100644 --- a/BTCPayServer.Client/Models/NotificationData.cs +++ b/BTCPayServer.Client/Models/NotificationData.cs @@ -9,6 +9,7 @@ public class NotificationData public string Identifier { get; set; } public string Type { get; set; } public string Body { get; set; } + public string StoreId { get; set; } public bool Seen { get; set; } public Uri Link { get; set; } diff --git a/BTCPayServer.Data/BTCPayServer.Data.csproj b/BTCPayServer.Data/BTCPayServer.Data.csproj index bbadfe0ec5..3c56110ce0 100644 --- a/BTCPayServer.Data/BTCPayServer.Data.csproj +++ b/BTCPayServer.Data/BTCPayServer.Data.csproj @@ -3,11 +3,11 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/BTCPayServer.Rating/BTCPayServer.Rating.csproj b/BTCPayServer.Rating/BTCPayServer.Rating.csproj index 9f78b949a4..36b8460aa6 100644 --- a/BTCPayServer.Rating/BTCPayServer.Rating.csproj +++ b/BTCPayServer.Rating/BTCPayServer.Rating.csproj @@ -4,7 +4,7 @@ - + diff --git a/BTCPayServer.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index f61b1cdc41..8c61139331 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -1,7 +1,7 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; -using System.Net.Http; using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; @@ -13,7 +13,6 @@ using BTCPayServer.Lightning; using BTCPayServer.Models.InvoicingModels; using BTCPayServer.NTag424; -using BTCPayServer.Payments; using BTCPayServer.Payments.Lightning; using BTCPayServer.PayoutProcessors; using BTCPayServer.Services; @@ -30,7 +29,6 @@ using Xunit; using Xunit.Abstractions; using Xunit.Sdk; -using static Org.BouncyCastle.Math.EC.ECCurve; using CreateApplicationUserRequest = BTCPayServer.Client.Models.CreateApplicationUserRequest; namespace BTCPayServer.Tests @@ -241,6 +239,76 @@ await AssertHttpError(403, await newUserClient.GetInvoices(store.Id); } + [Fact(Timeout = TestTimeout)] + [Trait("Integration", "Integration")] + public async Task CanCreateReadAndDeleteFiles() + { + using var tester = CreateServerTester(newDb: true); + await tester.StartAsync(); + var user = tester.NewAccount(); + await user.GrantAccessAsync(); + await user.MakeAdmin(); + var client = await user.CreateClient(); + + // List + Assert.Empty(await client.GetFiles()); + + // Upload + var filePath = TestUtils.GetTestDataFullPath("OldInvoices.csv"); + var upload = await client.UploadFile(filePath, "text/csv"); + Assert.Equal("OldInvoices.csv", upload.OriginalName); + Assert.NotNull(upload.Uri); + Assert.NotNull(upload.Url); + + // Re-check list + Assert.Single(await client.GetFiles()); + + // Single file endpoint + var singleFile = await client.GetFile(upload.Id); + Assert.Equal("OldInvoices.csv", singleFile.OriginalName); + Assert.NotNull(singleFile.Uri); + Assert.NotNull(singleFile.Url); + + // Delete + await client.DeleteFile(upload.Id); + Assert.Empty(await client.GetFiles()); + + // Profile image + await AssertValidationError(["file"], + async () => await client.UploadCurrentUserProfilePicture(filePath, "text/csv") + ); + + var profilePath = TestUtils.GetTestDataFullPath("logo.png"); + var currentUser = await client.UploadCurrentUserProfilePicture(profilePath, "image/png"); + var files = await client.GetFiles(); + Assert.Single(files); + Assert.Equal("logo.png", files[0].OriginalName); + Assert.Equal(files[0].Url, currentUser.ImageUrl); + + await client.DeleteCurrentUserProfilePicture(); + Assert.Empty(await client.GetFiles()); + currentUser = await client.GetCurrentUser(); + Assert.Null(currentUser.ImageUrl); + + // Store logo + var store = await client.CreateStore(new CreateStoreRequest { Name = "mystore" }); + await AssertValidationError(["file"], + async () => await client.UploadStoreLogo(store.Id, filePath, "text/csv") + ); + + var logoPath = TestUtils.GetTestDataFullPath("logo.png"); + var storeData = await client.UploadStoreLogo(store.Id, logoPath, "image/png"); + files = await client.GetFiles(); + Assert.Single(files); + Assert.Equal("logo.png", files[0].OriginalName); + Assert.Equal(files[0].Url, storeData.LogoUrl); + + await client.DeleteStoreLogo(store.Id); + Assert.Empty(await client.GetFiles()); + storeData = await client.GetStore(store.Id); + Assert.Null(storeData.LogoUrl); + } + [Fact(Timeout = TestTimeout)] [Trait("Integration", "Integration")] public async Task CanCreateReadUpdateAndDeletePointOfSaleApp() @@ -2915,14 +2983,18 @@ public async Task NotificationAPITests() await tester.PayTester.GetService() .SendNotification(new UserScope(user.UserId), new NewVersionNotification()); - Assert.Single(await viewOnlyClient.GetNotifications()); + var notifications = (await viewOnlyClient.GetNotifications()).ToList(); + Assert.Single(notifications); Assert.Single(await viewOnlyClient.GetNotifications(false)); Assert.Empty(await viewOnlyClient.GetNotifications(true)); + var notification = notifications.First(); + Assert.Null(notification.StoreId); + Assert.Single(await client.GetNotifications()); Assert.Single(await client.GetNotifications(false)); Assert.Empty(await client.GetNotifications(true)); - var notification = (await client.GetNotifications()).First(); + notification = (await client.GetNotifications()).First(); notification = await client.GetNotification(notification.Id); Assert.False(notification.Seen); await AssertHttpError(403, async () => @@ -2940,6 +3012,41 @@ await AssertHttpError(403, async () => Assert.Empty(await viewOnlyClient.GetNotifications(true)); Assert.Empty(await viewOnlyClient.GetNotifications(false)); + // Store association + var unrestricted = await user.CreateClient(Policies.Unrestricted); + var store1 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store A" }); + await tester.PayTester.GetService() + .SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{ + UserId = user.UserId, + UserEmail = user.Email, + StoreId = store1.Id, + StoreName = store1.Name + }); + notifications = (await client.GetNotifications()).ToList(); + Assert.Single(notifications); + + notification = notifications.First(); + Assert.Equal(store1.Id, notification.StoreId); + Assert.Equal($"User {user.Email} accepted the invite to {store1.Name}.", notification.Body); + + var store2 = await unrestricted.CreateStore(new CreateStoreRequest { Name = "Store B" }); + await tester.PayTester.GetService() + .SendNotification(new UserScope(user.UserId), new InviteAcceptedNotification{ + UserId = user.UserId, + UserEmail = user.Email, + StoreId = store2.Id, + StoreName = store2.Name + }); + notifications = (await client.GetNotifications(storeId: [store2.Id])).ToList(); + Assert.Single(notifications); + + notification = notifications.First(); + Assert.Equal(store2.Id, notification.StoreId); + Assert.Equal($"User {user.Email} accepted the invite to {store2.Name}.", notification.Body); + + Assert.Equal(2, (await client.GetNotifications(storeId: [store1.Id, store2.Id])).Count()); + Assert.Equal(2, (await client.GetNotifications()).Count()); + // Settings var settings = await client.GetNotificationSettings(); Assert.True(settings.Notifications.Find(n => n.Identifier == "newversion").Enabled); diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 4f6db53acb..8673364aae 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -2696,6 +2696,33 @@ public async Task CanUsePOSKeypad() Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text); Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector("td")).Text); + // Receipt print + s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click(); + windows = s.Driver.WindowHandles; + Assert.Equal(3, windows.Count); + s.Driver.SwitchTo().Window(windows[2]); + var paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table")); + items = paymentDetails.FindElements(By.CssSelector("tr.cart-data")); + sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data")); + Assert.Equal(2, items.Count); + Assert.Equal(4, sums.Count); + Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Discount", sums[1].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Tip", sums[2].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Total", sums[3].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector(".val")).Text); + s.Driver.Close(); + s.Driver.SwitchTo().Window(windows[1]); + s.Driver.Close(); + s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First()); + // Once more with items s.GoToUrl(editUrl); s.Driver.FindElement(By.Id("ShowItems")).Click(); @@ -2733,7 +2760,6 @@ public async Task CanUsePOSKeypad() // Receipt s.Driver.WaitForElement(By.Id("ReceiptLink")).Click(); - cartData = s.Driver.FindElement(By.CssSelector("#CartData table")); items = cartData.FindElements(By.CssSelector("tbody tr")); sums = cartData.FindElements(By.CssSelector("tfoot tr")); @@ -2748,6 +2774,27 @@ public async Task CanUsePOSKeypad() Assert.Contains("Total", sums[0].FindElement(By.CssSelector("th")).Text); Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text); + // Receipt print + s.Driver.FindElement(By.Id("ReceiptLinkPrint")).Click(); + windows = s.Driver.WindowHandles; + Assert.Equal(2, windows.Count); + s.Driver.SwitchTo().Window(windows[1]); + paymentDetails = s.Driver.WaitForElement(By.CssSelector("#PaymentDetails table")); + items = paymentDetails.FindElements(By.CssSelector("tr.cart-data")); + sums = paymentDetails.FindElements(By.CssSelector("tr.sums-data")); + Assert.Equal(3, items.Count); + Assert.Single(sums); + Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector(".val")).Text); + Assert.Contains("Total", sums[0].FindElement(By.CssSelector(".key")).Text); + Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector(".val")).Text); + s.Driver.Close(); + s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First()); + // Guest user can access recent transactions s.GoToHome(); s.Logout(); diff --git a/BTCPayServer.Tests/TestData/logo.png b/BTCPayServer.Tests/TestData/logo.png new file mode 100644 index 0000000000..9b469c677b Binary files /dev/null and b/BTCPayServer.Tests/TestData/logo.png differ diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 5408bb5f41..f98295e9e1 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -161,10 +161,14 @@ public async Task CheckSwaggerIsConformToSchema() { errors.Remove(validationError); } - valid = !errors.Any(); - + if (errors.Any()) + { + foreach (ValidationError error in errors) + { + TestLogs.LogInformation($"Error Type: {error.ErrorType} - {error.Path}: {error.Message} - Value: {error.Value}"); + } + } Assert.Empty(errors); - Assert.True(valid); } [Fact] @@ -2919,7 +2923,7 @@ await settings.UpdateSetting(new EmailSettings() Password = "store@store.com", Port = 1234, Server = "store.com" - }), "", true)); + }), "")); Assert.Equal("store@store.com", (await Assert.IsType(await emailSenderFactory.GetEmailSender(acc.StoreId)).GetEmailSettings()).Login); } diff --git a/BTCPayServer.Tests/docker-compose.altcoins.yml b/BTCPayServer.Tests/docker-compose.altcoins.yml index 15995552bb..54805adda9 100644 --- a/BTCPayServer.Tests/docker-compose.altcoins.yml +++ b/BTCPayServer.Tests/docker-compose.altcoins.yml @@ -229,7 +229,7 @@ services: - "5432" merchant_lnd: - image: btcpayserver/lnd:v0.18.0-beta + image: btcpayserver/lnd:v0.18.1-beta restart: unless-stopped environment: LND_CHAIN: "btc" @@ -264,7 +264,7 @@ services: - bitcoind customer_lnd: - image: btcpayserver/lnd:v0.18.0-beta + image: btcpayserver/lnd:v0.18.1-beta restart: unless-stopped environment: LND_CHAIN: "btc" diff --git a/BTCPayServer.Tests/docker-compose.yml b/BTCPayServer.Tests/docker-compose.yml index 5000a42c45..57ed0229a8 100644 --- a/BTCPayServer.Tests/docker-compose.yml +++ b/BTCPayServer.Tests/docker-compose.yml @@ -215,7 +215,7 @@ services: - "5432" merchant_lnd: - image: btcpayserver/lnd:v0.18.0-beta + image: btcpayserver/lnd:v0.18.1-beta restart: unless-stopped environment: LND_CHAIN: "btc" @@ -252,7 +252,7 @@ services: - bitcoind customer_lnd: - image: btcpayserver/lnd:v0.18.0-beta + image: btcpayserver/lnd:v0.18.1-beta restart: unless-stopped environment: LND_CHAIN: "btc" diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj index 725116300b..fef6e32ed7 100644 --- a/BTCPayServer/BTCPayServer.csproj +++ b/BTCPayServer/BTCPayServer.csproj @@ -78,8 +78,8 @@ - - + + diff --git a/BTCPayServer/Components/TruncateCenter/Default.cshtml b/BTCPayServer/Components/TruncateCenter/Default.cshtml index b481d7fc85..305c7fd8e4 100644 --- a/BTCPayServer/Components/TruncateCenter/Default.cshtml +++ b/BTCPayServer/Components/TruncateCenter/Default.cshtml @@ -4,23 +4,23 @@ var isTruncated = !string.IsNullOrEmpty(Model.Start) && !string.IsNullOrEmpty(Model.End); @if (Model.Copy) classes += " truncate-center--copy"; @if (Model.Elastic) classes += " truncate-center--elastic"; + var prefix = Model.IsVue ? ":" : ""; } - + @if (Model.IsVue) { - + @if (Model.Elastic) { - + } else { - - … + } - + - + } else { @@ -35,13 +35,13 @@ } @if (Model.Copy) { - + } @if (!string.IsNullOrEmpty(Model.Link)) { - + } diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs index b8809323aa..308a7d027b 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldAppsController.cs @@ -212,6 +212,7 @@ public async Task GetCrowdfundApp(string appId) } [HttpDelete("~/api/v1/apps/{appId}")] + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] public async Task DeleteApp(string appId) { var app = await _appService.GetApp(appId, null, includeArchived: true); diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldFilesController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldFilesController.cs new file mode 100644 index 0000000000..c14463532b --- /dev/null +++ b/BTCPayServer/Controllers/GreenField/GreenfieldFilesController.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Contracts; +using BTCPayServer.Abstractions.Extensions; +using BTCPayServer.Client; +using BTCPayServer.Client.Models; +using BTCPayServer.Data; +using BTCPayServer.Storage.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace BTCPayServer.Controllers.Greenfield; + +[ApiController] +[EnableCors(CorsPolicies.All)] +[Authorize(Policy = Policies.CanModifyServerSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] +public class GreenfieldFilesController( + UserManager userManager, + IFileService fileService, + StoredFileRepository fileRepository) + : Controller +{ + [HttpGet("~/api/v1/files")] + public async Task GetFiles() + { + var storedFiles = await fileRepository.GetFiles(); + var files = new List(); + foreach (var file in storedFiles) + files.Add(await ToFileData(file)); + return Ok(files); + } + + [HttpGet("~/api/v1/files/{fileId}")] + public async Task GetFile(string fileId) + { + var file = await fileRepository.GetFile(fileId); + return file == null + ? this.CreateAPIError(404, "file-not-found", "The file does not exist.") + : Ok(await ToFileData(file)); + } + + [HttpPost("~/api/v1/files")] + public async Task UploadFile(IFormFile file) + { + if (file is null) + ModelState.AddModelError(nameof(file), "Invalid file"); + else if (!file.FileName.IsValidFileName()) + ModelState.AddModelError(nameof(file.FileName), "Invalid filename"); + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + try + { + var userId = userManager.GetUserId(User)!; + var newFile = await fileService.AddFile(file!, userId); + return Ok(await ToFileData(newFile)); + } + catch (Exception e) + { + return this.CreateAPIError(404, "file-upload-failed", e.Message); + } + } + + [HttpDelete("~/api/v1/files/{fileId}")] + public async Task DeleteFile(string fileId) + { + var file = await fileRepository.GetFile(fileId); + if (file == null) return this.CreateAPIError(404, "file-not-found", "The file does not exist."); + await fileRepository.RemoveFile(file); + return Ok(); + } + + private async Task ToFileData(IStoredFile file) + { + return new FileData + { + Id = file.Id, + UserId = file.ApplicationUserId, + Uri = new UnresolvedUri.FileIdUri(file.Id).ToString(), + Url = await fileService.GetFileUrl(Request.GetAbsoluteRootUri(), file.Id), + OriginalName = file.FileName, + StorageName = file.StorageFileName, + CreatedAt = file.Timestamp + }; + } +} diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldNotificationsController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldNotificationsController.cs index 2d86d7c22a..4e3a7c5592 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldNotificationsController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldNotificationsController.cs @@ -1,3 +1,4 @@ +#nullable enable using System; using System.Collections.Generic; using System.Linq; @@ -36,71 +37,58 @@ public GreenfieldNotificationsController( _notificationHandlers = notificationHandlers; } - [Authorize(Policy = Policies.CanViewNotificationsForUser, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [Authorize(Policy = Policies.CanViewNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/users/me/notifications")] - public async Task GetNotifications(bool? seen = null, [FromQuery] int? skip = null, [FromQuery] int? take = null) + public async Task GetNotifications(bool? seen = null, [FromQuery] int? skip = null, [FromQuery] int? take = null, [FromQuery] string[]? storeId = null) { - var items = await _notificationManager.GetNotifications(new NotificationsQuery() + var items = await _notificationManager.GetNotifications(new NotificationsQuery { Seen = seen, UserId = _userManager.GetUserId(User), Skip = skip, - Take = take + Take = take, + StoreIds = storeId, }); return Ok(items.Items.Select(ToModel)); } - [Authorize(Policy = Policies.CanViewNotificationsForUser, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [Authorize(Policy = Policies.CanViewNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/users/me/notifications/{id}")] public async Task GetNotification(string id) { - var items = await _notificationManager.GetNotifications(new NotificationsQuery() + var items = await _notificationManager.GetNotifications(new NotificationsQuery { - Ids = new[] { id }, + Ids = [id], UserId = _userManager.GetUserId(User) }); - if (items.Count == 0) - { - return NotificationNotFound(); - } - - return Ok(ToModel(items.Items.First())); + return items.Count == 0 ? NotificationNotFound() : Ok(ToModel(items.Items.First())); } - [Authorize(Policy = Policies.CanManageNotificationsForUser, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpPut("~/api/v1/users/me/notifications/{id}")] public async Task UpdateNotification(string id, UpdateNotification request) { var items = await _notificationManager.ToggleSeen( - new NotificationsQuery() { Ids = new[] { id }, UserId = _userManager.GetUserId(User) }, request.Seen); - - if (items.Count == 0) - { - return NotificationNotFound(); - } + new NotificationsQuery { Ids = [id], UserId = _userManager.GetUserId(User) }, request.Seen); - return Ok(ToModel(items.First())); + return items.Count == 0 ? NotificationNotFound() : Ok(ToModel(items.First())); } - [Authorize(Policy = Policies.CanManageNotificationsForUser, - AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpDelete("~/api/v1/users/me/notifications/{id}")] public async Task DeleteNotification(string id) { - await _notificationManager.Remove(new NotificationsQuery() + await _notificationManager.Remove(new NotificationsQuery { - Ids = new[] { id }, + Ids = [id], UserId = _userManager.GetUserId(User) }); return Ok(); } - + [Authorize(Policy = Policies.CanManageNotificationsForUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/users/me/notification-settings")] public async Task GetNotificationSettings() @@ -132,7 +120,7 @@ public async Task UpdateNotificationSettings(UpdateNotificationSe return Ok(model); } - private NotificationData ToModel(NotificationViewModel entity) + private static NotificationData ToModel(NotificationViewModel entity) { return new NotificationData { @@ -141,10 +129,12 @@ private NotificationData ToModel(NotificationViewModel entity) Type = entity.Type, CreatedTime = entity.Created, Body = entity.Body, + StoreId = entity.StoreId, Seen = entity.Seen, Link = string.IsNullOrEmpty(entity.ActionLink) ? null : new Uri(entity.ActionLink) }; } + private IActionResult NotificationNotFound() { return this.CreateAPIError(404, "notification-not-found", "The notification was not found"); diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs index 4e90555498..2710414f80 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs @@ -3,12 +3,13 @@ using System.Linq; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client; using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Payments; -using BTCPayServer.Security; +using BTCPayServer.Services; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; @@ -27,31 +28,40 @@ public class GreenfieldStoresController : ControllerBase { private readonly StoreRepository _storeRepository; private readonly UserManager _userManager; + private readonly IFileService _fileService; + private readonly UriResolver _uriResolver; - public GreenfieldStoresController(StoreRepository storeRepository, UserManager userManager) + public GreenfieldStoresController( + StoreRepository storeRepository, + UserManager userManager, + IFileService fileService, + UriResolver uriResolver) { _storeRepository = storeRepository; _userManager = userManager; + _fileService = fileService; + _uriResolver = uriResolver; } [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/stores")] - public Task>> GetStores() + public async Task>> GetStores() { - var stores = HttpContext.GetStoresData(); - return Task.FromResult>>(Ok(stores.Select(FromModel))); + var storesData = HttpContext.GetStoresData(); + var stores = new List(); + foreach (var storeData in storesData) + { + stores.Add(await FromModel(storeData)); + } + return Ok(stores); } [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpGet("~/api/v1/stores/{storeId}")] - public IActionResult GetStore(string storeId) + public async Task GetStore(string storeId) { var store = HttpContext.GetStoreData(); - if (store == null) - { - return StoreNotFound(); - } - return Ok(FromModel(store)); + return store == null ? StoreNotFound() : Ok(await FromModel(store)); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] @@ -59,10 +69,8 @@ public IActionResult GetStore(string storeId) public async Task RemoveStore(string storeId) { var store = HttpContext.GetStoreData(); - if (store == null) - { - return StoreNotFound(); - } + if (store == null) return StoreNotFound(); + await _storeRepository.RemoveStore(storeId, _userManager.GetUserId(User)); return Ok(); } @@ -72,17 +80,13 @@ public async Task RemoveStore(string storeId) public async Task CreateStore(CreateStoreRequest request) { var validationResult = Validate(request); - if (validationResult != null) - { - return validationResult; - } - - var store = new Data.StoreData(); + if (validationResult != null) return validationResult; + var store = new StoreData(); PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId); ToModel(request, store, defaultPaymentMethodId); await _storeRepository.CreateStore(_userManager.GetUserId(User), store); - return Ok(FromModel(store)); + return Ok(await FromModel(store)); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] @@ -90,24 +94,78 @@ public async Task CreateStore(CreateStoreRequest request) public async Task UpdateStore(string storeId, UpdateStoreRequest request) { var store = HttpContext.GetStoreData(); - if (store == null) - { - return StoreNotFound(); - } + if (store == null) return StoreNotFound(); var validationResult = Validate(request); - if (validationResult != null) - { - return validationResult; - } + if (validationResult != null) return validationResult; PaymentMethodId.TryParse(request.DefaultPaymentMethod, out var defaultPaymentMethodId); - ToModel(request, store, defaultPaymentMethodId); await _storeRepository.UpdateStore(store); - return Ok(FromModel(store)); + return Ok(await FromModel(store)); + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/stores/{storeId}/logo")] + public async Task UploadStoreLogo(string storeId, IFormFile file) + { + var store = HttpContext.GetStoreData(); + if (store == null) return StoreNotFound(); + + if (file is null) + ModelState.AddModelError(nameof(file), "Invalid file"); + else if (file.Length > 1_000_000) + ModelState.AddModelError(nameof(file), "The uploaded image file should be less than 1MB"); + else if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + ModelState.AddModelError(nameof(file), "The uploaded file needs to be an image"); + else if (!file.FileName.IsValidFileName()) + ModelState.AddModelError(nameof(file.FileName), "Invalid filename"); + else + { + var formFile = await file.Bufferize(); + if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName)) + ModelState.AddModelError(nameof(file), "The uploaded file needs to be an image"); + } + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + try + { + var userId = _userManager.GetUserId(User)!; + var storedFile = await _fileService.AddFile(file!, userId); + var blob = store.GetStoreBlob(); + blob.LogoUrl = new UnresolvedUri.FileIdUri(storedFile.Id); + store.SetStoreBlob(blob); + await _storeRepository.UpdateStore(store); + + return Ok(await FromModel(store)); + } + catch (Exception e) + { + return this.CreateAPIError(404, "file-upload-failed", e.Message); + } + } + + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/stores/{storeId}/logo")] + public async Task DeleteStoreLogo(string storeId) + { + var store = HttpContext.GetStoreData(); + if (store == null) return StoreNotFound(); + + var blob = store.GetStoreBlob(); + var fileId = (blob.LogoUrl as UnresolvedUri.FileIdUri)?.FileId; + if (!string.IsNullOrEmpty(fileId)) + { + var userId = _userManager.GetUserId(User)!; + await _fileService.RemoveFile(fileId, userId); + blob.LogoUrl = null; + store.SetStoreBlob(blob); + await _storeRepository.UpdateStore(store); + } + return Ok(); } - internal static Client.Models.StoreData FromModel(StoreData data) + internal async Task FromModel(StoreData data) { var storeBlob = data.GetStoreBlob(); return new Client.Models.StoreData @@ -117,9 +175,9 @@ internal static Client.Models.StoreData FromModel(StoreData data) Website = data.StoreWebsite, Archived = data.Archived, BrandColor = storeBlob.BrandColor, - CssUrl = storeBlob.CssUrl?.ToString(), - LogoUrl = storeBlob.LogoUrl?.ToString(), - PaymentSoundUrl = storeBlob.PaymentSoundUrl?.ToString(), + CssUrl = storeBlob.CssUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.CssUrl), + LogoUrl = storeBlob.LogoUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.LogoUrl), + PaymentSoundUrl = storeBlob.PaymentSoundUrl == null ? null : await _uriResolver.Resolve(Request.GetAbsoluteRootUri(), storeBlob.PaymentSoundUrl), SupportUrl = storeBlob.StoreSupportUrl, SpeedPolicy = data.SpeedPolicy, DefaultPaymentMethod = data.GetDefaultPaymentId()?.ToString(), diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldTestApiKeyController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldTestApiKeyController.cs index 6b49ab860a..d75429cd43 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldTestApiKeyController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldTestApiKeyController.cs @@ -1,10 +1,8 @@ -using System.Linq; +using System.Collections.Generic; using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; using BTCPayServer.Client; using BTCPayServer.Data; -using BTCPayServer.Security; -using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Identity; @@ -22,14 +20,14 @@ namespace BTCPayServer.Controllers.Greenfield public class GreenfieldTestApiKeyController : ControllerBase { private readonly UserManager _userManager; - private readonly StoreRepository _storeRepository; - private readonly BTCPayServerClient _localBTCPayServerClient; + private readonly GreenfieldStoresController _greenfieldStoresController; - public GreenfieldTestApiKeyController(UserManager userManager, StoreRepository storeRepository, BTCPayServerClient localBTCPayServerClient) + public GreenfieldTestApiKeyController( + UserManager userManager, + GreenfieldStoresController greenfieldStoresController) { _userManager = userManager; - _storeRepository = storeRepository; - _localBTCPayServerClient = localBTCPayServerClient; + _greenfieldStoresController = greenfieldStoresController; } [HttpGet("me/id")] @@ -55,9 +53,15 @@ public bool AmIAnAdmin() [HttpGet("me/stores")] [Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] - public BTCPayServer.Client.Models.StoreData[] GetCurrentUserStores() + public async Task GetCurrentUserStores() { - return this.HttpContext.GetStoresData().Select(Greenfield.GreenfieldStoresController.FromModel).ToArray(); + var storesData = HttpContext.GetStoresData(); + var stores = new List(); + foreach (var storeData in storesData) + { + stores.Add(await _greenfieldStoresController.FromModel(storeData)); + } + return stores.ToArray(); } [HttpGet("me/stores/{storeId}/can-view")] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs index 4877dcf157..6f4a4db51a 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Xml.Linq; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Client; using BTCPayServer.Client.Models; @@ -17,6 +18,7 @@ using BTCPayServer.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using NicolasDorier.RateLimits; @@ -41,6 +43,7 @@ public class GreenfieldUsersController : ControllerBase private readonly IAuthorizationService _authorizationService; private readonly UserService _userService; private readonly UriResolver _uriResolver; + private readonly IFileService _fileService; public GreenfieldUsersController(UserManager userManager, RoleManager roleManager, @@ -53,6 +56,7 @@ public GreenfieldUsersController(UserManager userManager, IAuthorizationService authorizationService, UserService userService, UriResolver uriResolver, + IFileService fileService, Logs logs) { this.Logs = logs; @@ -67,6 +71,7 @@ public GreenfieldUsersController(UserManager userManager, _authorizationService = authorizationService; _userService = userService; _uriResolver = uriResolver; + _fileService = fileService; } [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] @@ -203,7 +208,7 @@ public async Task UpdateCurrentUser(UpdateApplicationUserRequest user.SetBlob(blob); if (ModelState.IsValid && needUpdate) - { + { var identityResult = await _userManager.UpdateAsync(user); if (!identityResult.Succeeded) { @@ -224,6 +229,68 @@ public async Task UpdateCurrentUser(UpdateApplicationUserRequest return Ok(model); } + [Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpPost("~/api/v1/users/me/picture")] + public async Task UploadCurrentUserProfilePicture(IFormFile? file) + { + if (file is null) + ModelState.AddModelError(nameof(file), "Invalid file"); + else if (file.Length > 1_000_000) + ModelState.AddModelError(nameof(file), "The uploaded image file should be less than 1MB"); + else if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) + ModelState.AddModelError(nameof(file), "The uploaded file needs to be an image"); + else if (!file.FileName.IsValidFileName()) + ModelState.AddModelError(nameof(file.FileName), "Invalid filename"); + else + { + var formFile = await file.Bufferize(); + if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName)) + ModelState.AddModelError(nameof(file), "The uploaded file needs to be an image"); + } + if (!ModelState.IsValid) + return this.CreateValidationError(ModelState); + + try + { + var user = await _userManager.GetUserAsync(User); + var storedFile = await _fileService.AddFile(file!, user!.Id); + var blob = user.GetBlob() ?? new UserBlob(); + var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id); + blob.ImageUrl = fileIdUri.ToString(); + user.SetBlob(blob); + await _userManager.UpdateAsync(user); + + var model = await FromModel(user); + return Ok(model); + } + catch (Exception e) + { + return this.CreateAPIError(404, "file-upload-failed", e.Message); + } + } + + [Authorize(Policy = Policies.CanModifyProfile, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] + [HttpDelete("~/api/v1/users/me/picture")] + public async Task DeleteCurrentUserProfilePicture() + { + var user = await _userManager.GetUserAsync(User); + if (user is null) + { + return this.UserNotFound(); + } + + var blob = user.GetBlob() ?? new UserBlob(); + if (!string.IsNullOrEmpty(blob.ImageUrl)) + { + var fileId = (UnresolvedUri.Create(blob.ImageUrl) as UnresolvedUri.FileIdUri)?.FileId; + if (!string.IsNullOrEmpty(fileId)) await _fileService.RemoveFile(fileId, user.Id); + blob.ImageUrl = null; + user.SetBlob(blob); + await _userManager.UpdateAsync(user); + } + return Ok(); + } + [Authorize(Policy = Policies.CanDeleteUser, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] [HttpDelete("~/api/v1/users/me")] public async Task DeleteCurrentUser() diff --git a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs index 2d443d3dcf..6925789dfc 100644 --- a/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs +++ b/BTCPayServer/Controllers/GreenField/LocalBTCPayServerClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; @@ -19,6 +20,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using NBitcoin; @@ -656,10 +658,10 @@ public override async Task UpdateNotificationSettings( } public override async Task> GetNotifications(bool? seen = null, - int? skip = null, int? take = null, CancellationToken token = default) + int? skip = null, int? take = null, string[] storeId = null, CancellationToken token = default) { return GetFromActionResult>( - await GetController().GetNotifications(seen, skip, take)); + await GetController().GetNotifications(seen, skip, take, storeId)); } public override async Task GetNotification(string notificationId, @@ -772,9 +774,9 @@ public override async Task> GetStores(CancellationToken t return GetFromActionResult(await GetController().GetStores()); } - public override Task GetStore(string storeId, CancellationToken token = default) + public override async Task GetStore(string storeId, CancellationToken token = default) { - return Task.FromResult(GetFromActionResult(GetController().GetStore(storeId))); + return GetFromActionResult(await GetController().GetStore(storeId)); } public override async Task RemoveStore(string storeId, CancellationToken token = default) @@ -793,6 +795,17 @@ public override async Task UpdateStore(string storeId, UpdateStoreReq return GetFromActionResult(await GetController().UpdateStore(storeId, request)); } + public override async Task UploadStoreLogo(string storeId, string filePath, string mimeType, CancellationToken token = default) + { + var file = GetFormFile(filePath, mimeType); + return GetFromActionResult(await GetController().UploadStoreLogo(storeId, file)); + } + + public override async Task DeleteStoreLogo(string storeId, CancellationToken token = default) + { + HandleActionResult(await GetController().DeleteStoreLogo(storeId)); + } + public override async Task> GetInvoices(string storeId, string[] orderId = null, InvoiceStatus[] status = null, DateTimeOffset? startDate = null, @@ -880,6 +893,17 @@ public override async Task UpdateCurrentUser(UpdateApplicat return GetFromActionResult(await GetController().UpdateCurrentUser(request, token)); } + public override async Task UploadCurrentUserProfilePicture(string filePath, string mimeType, CancellationToken token = default) + { + var file = GetFormFile(filePath, mimeType); + return GetFromActionResult(await GetController().UploadCurrentUserProfilePicture(file)); + } + + public override async Task DeleteCurrentUserProfilePicture(CancellationToken token = default) + { + HandleActionResult(await GetController().DeleteCurrentUserProfilePicture()); + } + public override async Task DeleteCurrentUser(CancellationToken token = default) { HandleActionResult(await GetController().DeleteCurrentUser()); @@ -1251,5 +1275,37 @@ public override async Task> GetStoreRoles(string storeId, Cancell { return GetFromActionResult>(await GetController().GetStoreRoles(storeId)); } + + public override async Task GetFiles(CancellationToken token = default) + { + return GetFromActionResult(await GetController().GetFiles()); + } + + public override async Task GetFile(string fileId, CancellationToken token = default) + { + return GetFromActionResult(await GetController().GetFile(fileId)); + } + + public override async Task UploadFile(string filePath, string mimeType, CancellationToken token = default) + { + var file = GetFormFile(filePath, mimeType); + return GetFromActionResult(await GetController().UploadFile(file)); + } + + public override async Task DeleteFile(string fileId, CancellationToken token = default) + { + HandleActionResult(await GetController().DeleteFile(fileId)); + } + + private IFormFile GetFormFile(string filePath, string mimeType) + { + var fileName = Path.GetFileName(filePath); + var fs = File.OpenRead(filePath); + return new FormFile(fs, 0, fs.Length, fileName, fileName) + { + Headers = new HeaderDictionary(), + ContentType = mimeType + }; + } } } diff --git a/BTCPayServer/Controllers/UIInvoiceController.UI.cs b/BTCPayServer/Controllers/UIInvoiceController.UI.cs index fa01f63525..1711de0bcc 100644 --- a/BTCPayServer/Controllers/UIInvoiceController.UI.cs +++ b/BTCPayServer/Controllers/UIInvoiceController.UI.cs @@ -214,7 +214,7 @@ public async Task InvoiceReceipt(string invoiceId, [FromQuery] bo { InvoiceId = i.Id, OrderId = i.Metadata?.OrderId, - OrderUrl = i.Metadata?.OrderUrl, + RedirectUrl = i.RedirectURL?.AbsoluteUri ?? i.Metadata?.OrderUrl, Status = i.Status, Currency = i.Currency, Timestamp = i.InvoiceTime, @@ -250,10 +250,12 @@ public async Task InvoiceReceipt(string invoiceId, [FromQuery] bo receiptData.Remove(key); } } - // assign the rest to additional data + // assign the rest to additional data and remove empty values if (receiptData.Any()) { - vm.AdditionalData = receiptData; + vm.AdditionalData = receiptData + .Where(x => !string.IsNullOrEmpty(x.Value.ToString())) + .ToDictionary(x => x.Key, x => x.Value); } } diff --git a/BTCPayServer/Controllers/UINotificationsController.cs b/BTCPayServer/Controllers/UINotificationsController.cs index 02f83e18e4..339e2ef821 100644 --- a/BTCPayServer/Controllers/UINotificationsController.cs +++ b/BTCPayServer/Controllers/UINotificationsController.cs @@ -19,7 +19,6 @@ namespace BTCPayServer.Controllers [Route("notifications/{action:lowercase=Index}")] public class UINotificationsController : Controller { - private readonly ApplicationDbContextFactory _factory; private readonly StoreRepository _storeRepo; private readonly UserManager _userManager; private readonly NotificationManager _notificationManager; @@ -27,13 +26,11 @@ public class UINotificationsController : Controller public UINotificationsController( StoreRepository storeRepo, UserManager userManager, - NotificationManager notificationManager, - ApplicationDbContextFactory factory) + NotificationManager notificationManager) { _storeRepo = storeRepo; _userManager = userManager; _notificationManager = notificationManager; - _factory = factory; } [HttpGet] @@ -49,9 +46,6 @@ public async Task Index(NotificationIndexViewModel model = null) var stores = await _storeRepo.GetStoresByUserId(userId); model.Stores = stores.Where(store => !store.Archived).OrderBy(s => s.StoreName).ToList(); - - await using var dbContext = _factory.CreateContext(); - var searchTerm = string.IsNullOrEmpty(model.SearchText) ? model.SearchTerm : $"{model.SearchText},{model.SearchTerm}"; var fs = new SearchString(searchTerm, timezoneOffset); model.Search = fs; @@ -63,7 +57,7 @@ public async Task Index(NotificationIndexViewModel model = null) UserId = userId, SearchText = model.SearchText, Type = fs.GetFilterArray("type"), - Stores = fs.GetFilterArray("store"), + StoreIds = fs.GetFilterArray("storeid"), Seen = model.Status == "Unread" ? false : null }); model.Items = res.Items; @@ -71,14 +65,13 @@ public async Task Index(NotificationIndexViewModel model = null) return View(model); } - [HttpPost] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanManageNotificationsForUser)] public async Task FlipRead(string id) { if (ValidUserClaim(out var userId)) { - await _notificationManager.ToggleSeen(new NotificationsQuery() { Ids = new[] { id }, UserId = userId }, null); + await _notificationManager.ToggleSeen(new NotificationsQuery { Ids = [id], UserId = userId }, null); return RedirectToAction(nameof(Index)); } @@ -91,9 +84,9 @@ public async Task NotificationPassThrough(string id) if (ValidUserClaim(out var userId)) { var items = await - _notificationManager.ToggleSeen(new NotificationsQuery() + _notificationManager.ToggleSeen(new NotificationsQuery { - Ids = new[] { id }, + Ids = [id], UserId = userId }, true); @@ -168,7 +161,7 @@ public async Task MarkAllAsSeen(string returnUrl) { return NotFound(); } - await _notificationManager.ToggleSeen(new NotificationsQuery() { Seen = false, UserId = userId }, true); + await _notificationManager.ToggleSeen(new NotificationsQuery { Seen = false, UserId = userId }, true); return LocalRedirect(returnUrl); } diff --git a/BTCPayServer/Controllers/UIStoresController.Email.cs b/BTCPayServer/Controllers/UIStoresController.Email.cs index 9951e9ded2..5034c04b53 100644 --- a/BTCPayServer/Controllers/UIStoresController.Email.cs +++ b/BTCPayServer/Controllers/UIStoresController.Email.cs @@ -195,10 +195,11 @@ public async Task StoreEmailSettings(string storeId, EmailsViewMo if (store == null) return NotFound(); - ViewBag.UseCustomSMTP = useCustomSMTP; model.FallbackSettings = await _emailSenderFactory.GetEmailSender(store.Id) is StoreEmailSender { FallbackSender: not null } storeSender ? await storeSender.FallbackSender.GetEmailSettings() : null; + if (model.FallbackSettings is null) useCustomSMTP = true; + ViewBag.UseCustomSMTP = useCustomSMTP; if (useCustomSMTP) { model.Settings.Validate("Settings.", ModelState); diff --git a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs index f0ee56af97..b20514609a 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/LightningLikePayoutHandler.cs @@ -89,7 +89,11 @@ public HttpClient CreateClient(Uri uri) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); using var t = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, cancellationToken); - var info = (LNURLPayRequest)(await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token)); + var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), t.Token); + if(rawInfo is null) + return (null, "The LNURL / Lightning Address provided was not online."); + if(rawInfo is not LNURLPayRequest info) + return (null, "The LNURL was not a valid LNURL Pay request."); lnurlTag = info.Tag; } @@ -159,9 +163,27 @@ public Task BackgroundCheck(object o) return Task.CompletedTask; } - public Task GetMinimumPayoutAmount(IClaimDestination claimDestination) + public async Task GetMinimumPayoutAmount(IClaimDestination claimDestination) { - return Task.FromResult(Money.Satoshis(1).ToDecimal(MoneyUnit.BTC)); + if(claimDestination is LNURLPayClaimDestinaton lnurlPayClaimDestinaton) + { + try + { + var lnurl = lnurlPayClaimDestinaton.LNURL.IsValidEmail() + ? LNURL.LNURL.ExtractUriFromInternetIdentifier(lnurlPayClaimDestinaton.LNURL) + : LNURL.LNURL.Parse(lnurlPayClaimDestinaton.LNURL, out var lnurlTag); + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + var rawInfo = await LNURL.LNURL.FetchInformation(lnurl, CreateClient(lnurl), timeout.Token); + if (rawInfo is LNURLPayRequest info) + return info.MinSendable.ToDecimal(LightMoneyUnit.BTC); + } + catch + { + // ignored + } + } + return Money.Satoshis(1).ToDecimal(MoneyUnit.BTC); } public Dictionary> GetPayoutSpecificActions() diff --git a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs index a5b4712534..bf0f1580a6 100644 --- a/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs +++ b/BTCPayServer/Data/Payouts/LightningLike/UILightningLikePayoutController.cs @@ -245,13 +245,15 @@ public async Task ProcessLightningPayout(string cryptoCode, strin var lm = new LightMoney(blob.CryptoAmount.Value, LightMoneyUnit.BTC); if (lm > lnurlInfo.MaxSendable || lm < lnurlInfo.MinSendable) { + + payoutData.State = PayoutState.Cancelled; return (null, new ResultVM { PayoutId = payoutData.Id, Result = PayResult.Error, Destination = blob.Destination, Message = - $"The LNURL provided would not generate an invoice of {lm.MilliSatoshi}msats" + $"The LNURL provided would not generate an invoice of {lm.ToDecimal(LightMoneyUnit.Satoshi)} sats" }); } diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index b07df42634..7c90e3f42c 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -291,6 +291,7 @@ public static IServiceCollection AddBTCPayServer(this IServiceCollection service }); services.TryAddSingleton(); + services.AddExceptionHandler(); services.TryAddSingleton(); services.AddTransient(); services.AddSingleton(); diff --git a/BTCPayServer/Hosting/Startup.cs b/BTCPayServer/Hosting/Startup.cs index 6e48f6b0a6..223c089bfb 100644 --- a/BTCPayServer/Hosting/Startup.cs +++ b/BTCPayServer/Hosting/Startup.cs @@ -293,6 +293,7 @@ private void ConfigureCore(IApplicationBuilder app, IWebHostEnvironment env, ISe app.UseStatusCodePagesWithReExecute("/errors/{0}"); + app.UseExceptionHandler("/errors/{0}"); app.UsePayServer(); app.UseRouting(); app.UseCors(); diff --git a/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs index 50c85a53ab..6d8a64ee1f 100644 --- a/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs +++ b/BTCPayServer/Models/InvoicingModels/InvoiceReceiptViewModel.cs @@ -20,6 +20,6 @@ public class InvoiceReceiptViewModel public Dictionary CartData { get; set; } public ReceiptOptions ReceiptOptions { get; set; } public List Payments { get; set; } - public string OrderUrl { get; set; } + public string RedirectUrl { get; set; } } } diff --git a/BTCPayServer/Plugins/PluginExceptionHandler.cs b/BTCPayServer/Plugins/PluginExceptionHandler.cs new file mode 100644 index 0000000000..93cd049c79 --- /dev/null +++ b/BTCPayServer/Plugins/PluginExceptionHandler.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Configuration; +using BTCPayServer.Logging; +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace BTCPayServer.Plugins +{ + public class PluginExceptionHandler : IExceptionHandler + { + readonly string _pluginDir; + readonly IHostApplicationLifetime _applicationLifetime; + private readonly Logs _logs; + + public PluginExceptionHandler(IOptions options, IHostApplicationLifetime applicationLifetime, Logs logs) + { + _applicationLifetime = applicationLifetime; + _logs = logs; + _pluginDir = options.Value.PluginDir; + } + public ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken) + { + if (!GetDisablePluginIfCrash(httpContext) || + !PluginManager.IsExceptionByPlugin(exception, out var pluginName)) + return ValueTask.FromResult(false); + _logs.Configuration.LogError(exception, $"Unhandled exception caused by plugin '{pluginName}', disabling it and restarting..."); + _ = Task.Delay(3000).ContinueWith((t) => _applicationLifetime.StopApplication()); + // Returning true here means we will see Error 500 error message. + // Returning false means that the user will see a stacktrace. + return ValueTask.FromResult(false); + } + + internal static bool GetDisablePluginIfCrash(HttpContext httpContext) + { + return httpContext.Items.TryGetValue("DisablePluginIfCrash", out object renderingDashboard) || + renderingDashboard is not true; + } + internal static void SetDisablePluginIfCrash(HttpContext httpContext) + { + httpContext.Items.TryAdd("DisablePluginIfCrash", true); + } + } +} diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index effb552670..2904c6f41e 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -348,11 +348,9 @@ public async Task ViewPointOfSale(string appId, var receiptData = new JObject(); if (choice is not null) { - receiptData = JObject.FromObject(new Dictionary - { - {"Title", choice.Title}, - {"Description", choice.Description}, - }); + var dict = new Dictionary { { "Title", choice.Title } }; + if (!string.IsNullOrEmpty(choice.Description)) dict["Description"] = choice.Description; + receiptData = JObject.FromObject(dict); } else if (jposData is not null) { diff --git a/BTCPayServer/Services/Invoices/InvoiceRepository.cs b/BTCPayServer/Services/Invoices/InvoiceRepository.cs index 06d7c70e52..b99c122c9a 100644 --- a/BTCPayServer/Services/Invoices/InvoiceRepository.cs +++ b/BTCPayServer/Services/Invoices/InvoiceRepository.cs @@ -806,7 +806,7 @@ private string NormalizeStatus(string status) { "new" => "New", "paid" or "processing" => "Processing", - "complete" or "confirmed" => "Settled", + "complete" or "confirmed" or "settled" => "Settled", "expired" => "Expired", "invalid" => "Invalid", _ => null diff --git a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs index ab477ed2a0..b3f61c7a7e 100644 --- a/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs +++ b/BTCPayServer/Services/Notifications/Blobs/InvoiceEventNotification.cs @@ -54,7 +54,7 @@ protected override void FillViewModel(InvoiceEventNotification notification, } vm.Identifier = notification.Identifier; vm.Type = notification.NotificationType; - vm.StoreId = notification?.StoreId; + vm.StoreId = notification.StoreId; vm.ActionLink = _linkGenerator.GetPathByAction(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = notification.InvoiceId }, _options.RootPath); diff --git a/BTCPayServer/Services/Notifications/NotificationManager.cs b/BTCPayServer/Services/Notifications/NotificationManager.cs index 0b5b62ab8b..6ad8e00524 100644 --- a/BTCPayServer/Services/Notifications/NotificationManager.cs +++ b/BTCPayServer/Services/Notifications/NotificationManager.cs @@ -151,9 +151,9 @@ private List FilterNotifications(List 0) + if (query.StoreIds?.Length > 0) { - notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.Stores.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList(); + notifications = notifications.Where(n => !string.IsNullOrEmpty(n.StoreId) && query.StoreIds.Contains(n.StoreId, StringComparer.OrdinalIgnoreCase)).ToList(); } return notifications; } @@ -221,6 +221,6 @@ public class NotificationsQuery public bool? Seen { get; set; } public string SearchText { get; set; } public string[] Type { get; set; } - public string[] Stores { get; set; } + public string[] StoreIds { get; set; } } } diff --git a/BTCPayServer/Services/TransactionLinkProviders.cs b/BTCPayServer/Services/TransactionLinkProviders.cs index 48e3eadd9f..b3c47f21da 100644 --- a/BTCPayServer/Services/TransactionLinkProviders.cs +++ b/BTCPayServer/Services/TransactionLinkProviders.cs @@ -29,7 +29,7 @@ public async Task RefreshTransactionLinkTemplates() { foreach ((var pmi, var prov) in this) { - var overrideLink = links.SingleOrDefault(item => + var overrideLink = links.FirstOrDefault(item => item.CryptoCode.Equals(pmi, StringComparison.InvariantCultureIgnoreCase)); prov.OverrideBlockExplorerLink = overrideLink?.Link ?? prov.BlockExplorerLinkDefault; } diff --git a/BTCPayServer/UnresolvedUri.cs b/BTCPayServer/UnresolvedUri.cs index 56038ec9f1..81597b57f0 100644 --- a/BTCPayServer/UnresolvedUri.cs +++ b/BTCPayServer/UnresolvedUri.cs @@ -1,3 +1,4 @@ +#nullable enable using System; namespace BTCPayServer diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml index 20d4c31895..0de68477f7 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml @@ -78,12 +78,12 @@ buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted); var categories = new JArray(item.Categories ?? new object[] { }); - + @if (!string.IsNullOrWhiteSpace(item.Image)) { } - + @Safe.Raw(item.Title) @if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0) @@ -108,7 +108,7 @@ @if (inStock) { - + @if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed) { diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml index ce75fcbcad..c880cf79b9 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml @@ -78,8 +78,8 @@ else var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol); if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && item.Price == 0) continue; - - + + @Safe.Raw(item.Title) diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml index 7d7621932e..f93787a5cc 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml @@ -36,12 +36,12 @@ buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted); - + @if (!string.IsNullOrWhiteSpace(item.Image)) { } - + @Safe.Raw(item.Title) @if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0) @@ -64,7 +64,7 @@ @Safe.Raw(item.Description) } - @@ -173,9 +173,9 @@ } } - @if (!string.IsNullOrEmpty(Model.OrderUrl)) + @if (!string.IsNullOrEmpty(Model.RedirectUrl)) { - Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName) + Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName) } diff --git a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml index 10e9f9c7d9..2f8e96d8bb 100644 --- a/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml +++ b/BTCPayServer/Views/UIInvoice/InvoiceReceiptPrint.cshtml @@ -104,7 +104,7 @@ { @foreach (var (key, value) in Model.AdditionalData) { - + @key @value @@ -113,20 +113,20 @@ } - @if (hasCart) + @if (hasCart) { _ = Model.CartData.TryGetValue("cart", out var cart) || Model.CartData.TryGetValue("Cart", out cart); var hasTotal = Model.CartData.TryGetValue("total", out var total) || Model.CartData.TryGetValue("Total", out total); var hasSubtotal = Model.CartData.TryGetValue("subtotal", out var subtotal) || Model.CartData.TryGetValue("subTotal", out subtotal) || Model.CartData.TryGetValue("Subtotal", out subtotal); var hasDiscount = Model.CartData.TryGetValue("discount", out var discount) || Model.CartData.TryGetValue("Discount", out discount); var hasTip = Model.CartData.TryGetValue("tip", out var tip) || Model.CartData.TryGetValue("Tip", out tip); - if (cart is Dictionary { Keys.Count: > 0 } cartDict) + if (cart is Dictionary { Keys.Count: > 0 } cartDict) { @foreach (var (key, value) in cartDict) { - - @key - @value + + @key + @value } } @@ -135,7 +135,7 @@ @foreach (var value in cartCollection) { - @value + @value } } @@ -144,23 +144,23 @@ - - Subtotal - @subtotal + + Subtotal + @subtotal } if (hasDiscount) { - - Discount - @discount + + Discount + @discount } if (hasTip) { - - Tip - @tip + + Tip + @tip } if (hasTotal) @@ -168,17 +168,17 @@ - - Total - @total + + Total + @total } } else { - - Total - @DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol) + + Total + @DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol) } @if (Model.Payments?.Any() is true) @@ -194,25 +194,25 @@ Payment @(i + 1) - + Received @payment.ReceivedDate.ToBrowserDate() } - + @(Model.Payments.Count == 1 ? "Paid" : "") @payment.AmountFormatted - + @payment.PaidFormatted - + Rate @payment.RateFormatted @if (!string.IsNullOrEmpty(payment.Destination)) { - + Destination @if (payment.Destination.Length > 69) @@ -232,7 +232,7 @@ } @if (!string.IsNullOrEmpty(payment.PaymentProof)) { - + Pay Proof @payment.PaymentProof diff --git a/BTCPayServer/Views/UINotifications/Index.cshtml b/BTCPayServer/Views/UINotifications/Index.cshtml index a376b9e4af..eb41839f01 100644 --- a/BTCPayServer/Views/UINotifications/Index.cshtml +++ b/BTCPayServer/Views/UINotifications/Index.cshtml @@ -3,7 +3,7 @@ ViewData["Title"] = "Notifications"; string status = ViewBag.Status; var statusFilterCount = CountArrayFilter("type"); - var storesFilterCount = CountArrayFilter("store"); + var storesFilterCount = CountArrayFilter("storeid"); } @functions @@ -86,7 +86,7 @@ @if (storesFilterCount > 0) { - @storesFilterCount Store + @storesFilterCount Store@(storesFilterCount > 1 ? "s" : "") } else { @@ -99,8 +99,8 @@ + asp-route-searchTerm="@Model.Search.Toggle("storeid", store.Id)" + class="dropdown-item @(HasArrayFilter("storeid", store.Id) ? "custom-active" : "")"> @store.StoreName } diff --git a/BTCPayServer/Views/UIServer/ListPlugins.cshtml b/BTCPayServer/Views/UIServer/ListPlugins.cshtml index 4b3d39abb9..8ea60c9792 100644 --- a/BTCPayServer/Views/UIServer/ListPlugins.cshtml +++ b/BTCPayServer/Views/UIServer/ListPlugins.cshtml @@ -84,7 +84,7 @@ @plugin @if (version != null) { - ({version}) + (@version) } diff --git a/BTCPayServer/Views/UIServer/Policies.cshtml b/BTCPayServer/Views/UIServer/Policies.cshtml index f96ec55339..6cae51123a 100644 --- a/BTCPayServer/Views/UIServer/Policies.cshtml +++ b/BTCPayServer/Views/UIServer/Policies.cshtml @@ -235,7 +235,7 @@ { var cryptoCode = linkProviders[lpi].Key; var defaultLink = linkProviders[lpi].Value.BlockExplorerLinkDefault; - var existingOverride = Model.BlockExplorerLinks?.SingleOrDefault(tuple => tuple.CryptoCode == cryptoCode); + var existingOverride = Model.BlockExplorerLinks?.FirstOrDefault(tuple => tuple.CryptoCode == cryptoCode); if (existingOverride is null) { existingOverride = new PoliciesSettings.BlockExplorerOverrideItem { CryptoCode = cryptoCode, Link = null }; diff --git a/BTCPayServer/Views/UIStores/Dashboard.cshtml b/BTCPayServer/Views/UIStores/Dashboard.cshtml index 66095e1b3d..c5fc159358 100644 --- a/BTCPayServer/Views/UIStores/Dashboard.cshtml +++ b/BTCPayServer/Views/UIStores/Dashboard.cshtml @@ -9,7 +9,8 @@ @using BTCPayServer.Client @model StoreDashboardViewModel @{ - ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId); + BTCPayServer.Plugins.PluginExceptionHandler.SetDisablePluginIfCrash(Context); + ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId); var store = ViewContext.HttpContext.GetStoreData(); } diff --git a/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml b/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml index fde1bb9d9b..a108083721 100644 --- a/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml +++ b/BTCPayServer/Views/UIStores/StoreEmailSettings.cshtml @@ -29,6 +29,7 @@ } else { + } diff --git a/BTCPayServer/wwwroot/main/site.css b/BTCPayServer/wwwroot/main/site.css index 672c78e5c8..6faa26f876 100644 --- a/BTCPayServer/wwwroot/main/site.css +++ b/BTCPayServer/wwwroot/main/site.css @@ -732,6 +732,8 @@ input:checked + label.btcpay-list-select-item { .tile { --tile-padding: 1rem; --tile-border-radius: none; + margin-left: calc(var(--wrap-padding-horizontal) * -1); + margin-right: calc(var(--wrap-padding-horizontal) * -1); } } diff --git a/BTCPayServer/wwwroot/pos/cart.css b/BTCPayServer/wwwroot/pos/cart.css index 3fa2dcf053..6036d966b4 100644 --- a/BTCPayServer/wwwroot/pos/cart.css +++ b/BTCPayServer/wwwroot/pos/cart.css @@ -7,11 +7,6 @@ padding-top: 0; } -@media (max-width: 400px) { - #PosCart .public-page-wrap { - --wrap-padding-horizontal: var(--btcpay-space-s); - } -} #PosCart .offcanvas-backdrop { top: var(--mobile-header-height); @@ -59,8 +54,19 @@ } #RecentTransactionsToggle { + left: -.5rem; +} +#RecentTransactionsToggle.btn .icon { --icon-size: 1.5rem; - left: 0; +} +@media screen and (max-width: 400px) { + header { + padding-left: .5rem; + padding-right: .5rem; + } + #RecentTransactionsToggle { + left: -.125rem; + } } header .cart-toggle-btn { diff --git a/BTCPayServer/wwwroot/pos/common.css b/BTCPayServer/wwwroot/pos/common.css index 157e0542c7..e9b776ea70 100644 --- a/BTCPayServer/wwwroot/pos/common.css +++ b/BTCPayServer/wwwroot/pos/common.css @@ -45,6 +45,12 @@ .lead :last-child { margin-bottom: 0; } +@media screen and (max-width: 400px) { + .lead { + padding-left: 1rem; + padding-right: 1rem; + } +} .posItem { display: none; position: relative; @@ -123,3 +129,11 @@ width: 1rem; height: 1rem; } +.tile.card { + --btcpay-card-cap-bg: transparent; + --btcpay-card-border-width: 0; + --btcpay-card-spacer-x: 0; + --btcpay-card-cap-padding-x: 0; + --btcpay-card-cap-padding-y: 0; + height: 100%; +} diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.files.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.files.json new file mode 100644 index 0000000000..f3c9cfa730 --- /dev/null +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.files.json @@ -0,0 +1,223 @@ +{ + "paths": { + "/api/v1/files": { + "get": { + "operationId": "Files_GetFiles", + "tags": [ + "Files" + ], + "summary": "Get all files", + "description": "Load all files that exist.", + "parameters": [], + "responses": { + "200": { + "description": "Files found", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FileData" + } + } + } + } + }, + "401": { + "description": "Missing authorization for loading the files" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canmodifyserversettings" + ], + "Basic": [] + } + ] + }, + "post": { + "tags": [ + "Files" + ], + "summary": "Uploads a file", + "description": "Uploads a file", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "description": "The profile picture", + "format": "binary" + } + } + } + } + } + }, + "operationId": "Files_UploadFile", + "responses": { + "200": { + "description": "Uploads a file", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileData" + } + } + } + }, + "415": { + "description": "The upload did not work" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canmodifyserversettings" + ], + "Basic": [] + } + ] + } + }, + "/api/v1/files/{fileId}": { + "get": { + "operationId": "Files_GetFile", + "tags": [ + "Files" + ], + "summary": "Get file", + "description": "View information about the specified file", + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "The file information to fetch", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileData" + } + } + } + }, + "401": { + "description": "Missing authorization for loading the file" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canmodifyserversettings" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Files" + ], + "summary": "Delete file", + "description": "Deletes the file", + "operationId": "Files_DeleteFile", + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "The file to delete", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File deleted successfully" + }, + "404": { + "description": "The file could not be found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.server.canmodifyserversettings" + ], + "Basic": [] + } + ] + } + } + }, + "components": { + "schemas": { + "FileData": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "The id of the file", + "nullable": false + }, + "userId": { + "type": "string", + "description": "The id of the user that uploaded the file", + "nullable": false + }, + "uri": { + "type": "string", + "description": "The internal URI of the file", + "nullable": false + }, + "url": { + "type": "string", + "description": "The full URL of the file", + "nullable": true + }, + "originalName": { + "type": "string", + "description": "The original name of the file", + "nullable": true + }, + "storageName": { + "type": "string", + "description": "The storage name of the file", + "nullable": true + }, + "created": { + "nullable": true, + "description": "The creation date of the file as a unix timestamp", + "allOf": [ + { + "$ref": "#/components/schemas/UnixTimestamp" + } + ] + } + } + } + } + }, + "tags": [ + { + "name": "Files", + "description": "File operations" + } + ] +} diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.notifications.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.notifications.json index 9e0f0aabc5..9e58eaf694 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.notifications.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.notifications.json @@ -36,6 +36,19 @@ "nullable": true, "type": "number" } + }, + { + "name": "storeId", + "in": "query", + "required": false, + "description": "Array of store ids to fetch the notifications for", + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + "example": "&storeId=ABCDE&storeId=FGHIJ" } ], "description": "View current user's notifications", @@ -313,6 +326,11 @@ "format": "html", "description": "The html body of the notifications" }, + "storeId": { + "type": "string", + "nullable": true, + "description": "If related to a store, the store id of the notification" + }, "link": { "type": "string", "format": "uri", diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json index fb38edb177..5941bc5fa0 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json @@ -255,6 +255,80 @@ ] } }, + "/api/v1/stores/{storeId}/logo": { + "post": { + "tags": [ + "Stores" + ], + "summary": "Uploads a logo for the store", + "description": "Uploads a logo for the store", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "description": "The logo", + "format": "binary" + } + } + } + } + } + }, + "operationId": "Stores_UploadStoreLogo", + "responses": { + "200": { + "description": "Uploads a logo for the store", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserData" + } + } + } + }, + "404": { + "description": "The store could not be found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Stores" + ], + "summary": "Deletes the store logo", + "description": "Delete the store's logo", + "operationId": "Stores_DeleteStoreLogo", + "responses": { + "200": { + "description": "Store logo deleted successfully" + }, + "404": { + "description": "The store could not be found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.store.canmodifystoresettings" + ], + "Basic": [] + } + ] + } + }, "/api/v1/stores/{storeId}/roles": { "get": { "tags": [ diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json index a943bd4f19..a6153e27c0 100644 --- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json +++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.users.json @@ -128,6 +128,80 @@ ] } }, + "/api/v1/users/me/picture": { + "post": { + "tags": [ + "Users" + ], + "summary": "Uploads a profile picture for the current user", + "description": "Uploads a profile picture for the current user", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "additionalProperties": false, + "properties": { + "file": { + "type": "string", + "description": "The profile picture", + "format": "binary" + } + } + } + } + } + }, + "operationId": "Users_UploadCurrentUserProfilePicture", + "responses": { + "200": { + "description": "Uploads a profile picture for the current user", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplicationUserData" + } + } + } + }, + "404": { + "description": "The user could not be found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.user.canmodifyprofile" + ], + "Basic": [] + } + ] + }, + "delete": { + "tags": [ + "Users" + ], + "summary": "Deletes user profile picture", + "description": "Deletes the user profile picture", + "operationId": "Users_DeleteCurrentUserProfilePicture", + "responses": { + "200": { + "description": "Profile picture deleted successfully" + }, + "404": { + "description": "The user could not be found" + } + }, + "security": [ + { + "API_Key": [ + "btcpay.user.canmodifyprofile" + ], + "Basic": [] + } + ] + } + }, "/api/v1/users": { "get": { "operationId": "Users_GetUsers",
@Safe.Raw(item.Description)