Greenfield: Add image upload for app items (#6226)

Upload endpoints for app item images. Follow-up to #6075.
Tested to work with the app item editor.

Uses UploadImage consistently in API and UI.
This commit is contained in:
d11n
2024-11-07 02:43:22 +01:00
committed by GitHub
parent 392ec623c0
commit a129114603
13 changed files with 239 additions and 128 deletions

View File

@@ -78,4 +78,14 @@ public partial class BTCPayServerClient
if (appId == null) throw new ArgumentNullException(nameof(appId));
await SendHttpRequest($"api/v1/apps/{appId}", null, HttpMethod.Delete, token);
}
public virtual async Task<FileData> UploadAppItemImage(string appId, string filePath, string mimeType, CancellationToken token = default)
{
return await UploadFileRequest<FileData>($"api/v1/apps/{appId}/image", filePath, mimeType, "file", HttpMethod.Post, token);
}
public virtual async Task DeleteAppItemImage(string appId, string fileId, CancellationToken token = default)
{
await SendHttpRequest($"api/v1/apps/{appId}/image/{fileId}", null, HttpMethod.Delete, token);
}
}

View File

@@ -318,6 +318,20 @@ namespace BTCPayServer.Tests
Assert.Empty(await client.GetFiles());
storeData = await client.GetStore(store.Id);
Assert.Null(storeData.LogoUrl);
// App Item Image
var app = await client.CreatePointOfSaleApp(store.Id, new PointOfSaleAppRequest { AppName = "Test App" });
await AssertValidationError(["file"],
async () => await client.UploadAppItemImage(app.Id, filePath, "text/csv")
);
var fileData = await client.UploadAppItemImage(app.Id, logoPath, "image/png");
Assert.Equal("logo.png", fileData.OriginalName);
files = await client.GetFiles();
Assert.Single(files);
await client.DeleteAppItemImage(app.Id, fileData.Id);
Assert.Empty(await client.GetFiles());
}
[Fact(Timeout = TestTimeout)]

View File

@@ -3,7 +3,9 @@ using System;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -15,6 +17,7 @@ using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
@@ -33,13 +36,14 @@ namespace BTCPayServer.Controllers.Greenfield
private readonly StoreRepository _storeRepository;
private readonly CurrencyNameTable _currencies;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IFileService _fileService;
public GreenfieldAppsController(
AppService appService,
UriResolver uriResolver,
StoreRepository storeRepository,
BTCPayNetworkProvider btcPayNetworkProvider,
CurrencyNameTable currencies,
IFileService fileService,
UserManager<ApplicationUser> userManager
)
{
@@ -47,6 +51,7 @@ namespace BTCPayServer.Controllers.Greenfield
_uriResolver = uriResolver;
_storeRepository = storeRepository;
_currencies = currencies;
_fileService = fileService;
_userManager = userManager;
}
@@ -221,10 +226,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> DeleteApp(string appId)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
if (app == null)
{
return AppNotFound();
}
if (app == null) return AppNotFound();
await _appService.DeleteApp(app);
@@ -255,6 +257,57 @@ namespace BTCPayServer.Controllers.Greenfield
return Ok(items);
}
[HttpPost("~/api/v1/apps/{appId}/image")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
public async Task<IActionResult> UploadAppItemImage(string appId, IFormFile? file)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
var userId = _userManager.GetUserId(User);
if (app == null || userId == null) return AppNotFound();
UploadImageResultModel? upload = null;
if (file is null)
ModelState.AddModelError(nameof(file), "Invalid file");
else
{
upload = await _fileService.UploadImage(file, userId, 500_000);
if (!upload.Success)
ModelState.AddModelError(nameof(file), upload.Response);
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
try
{
var storedFile = upload!.StoredFile!;
var fileData = new FileData
{
Id = storedFile.Id,
UserId = storedFile.ApplicationUserId,
Url = await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), storedFile.Id),
OriginalName = storedFile.FileName,
StorageName = storedFile.StorageFileName,
CreatedAt = storedFile.Timestamp
};
return Ok(fileData);
}
catch (Exception e)
{
return this.CreateAPIError(404, "file-upload-failed", e.Message);
}
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
[HttpDelete("~/api/v1/apps/{appId}/image/{fileId}")]
public async Task<IActionResult> DeleteAppItemImage(string appId, string fileId)
{
var app = await _appService.GetApp(appId, null, includeArchived: true);
var userId = _userManager.GetUserId(User);
if (app == null || userId == null) return AppNotFound();
if (!string.IsNullOrEmpty(fileId)) await _fileService.RemoveFile(fileId, userId);
return Ok();
}
private IActionResult AppNotFound()
{
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found");

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
@@ -111,30 +112,25 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/stores/{storeId}/logo")]
public async Task<IActionResult> UploadStoreLogo(string storeId, IFormFile file)
{
var user = await _userManager.GetUserAsync(User);
var store = HttpContext.GetStoreData();
if (store == null) return StoreNotFound();
if (user == null || store == null) return StoreNotFound();
UploadImageResultModel upload = null;
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");
upload = await _fileService.UploadImage(file, user.Id);
if (!upload.Success)
ModelState.AddModelError(nameof(file), upload.Response);
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
try
{
var userId = _userManager.GetUserId(User)!;
var storedFile = await _fileService.AddFile(file!, userId);
var storedFile = upload!.StoredFile!;
var blob = store.GetStoreBlob();
blob.LogoUrl = new UnresolvedUri.FileIdUri(storedFile.Id);
store.SetStoreBlob(blob);

View File

@@ -7,6 +7,7 @@ using System.Xml.Linq;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
@@ -233,27 +234,24 @@ namespace BTCPayServer.Controllers.Greenfield
[HttpPost("~/api/v1/users/me/picture")]
public async Task<IActionResult> UploadCurrentUserProfilePicture(IFormFile? file)
{
var user = await _userManager.GetUserAsync(User);
if (user is null) return this.UserNotFound();
UploadImageResultModel? upload = null;
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");
upload = await _fileService.UploadImage(file, user.Id);
if (!upload.Success)
ModelState.AddModelError(nameof(file), upload.Response);
}
if (!ModelState.IsValid)
return this.CreateValidationError(ModelState);
try
{
var user = await _userManager.GetUserAsync(User);
var storedFile = await _fileService.AddFile(file!, user!.Id);
var storedFile = upload!.StoredFile!;
var blob = user.GetBlob() ?? new UserBlob();
var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id);
blob.ImageUrl = fileIdUri.ToString();
@@ -274,10 +272,7 @@ namespace BTCPayServer.Controllers.Greenfield
public async Task<IActionResult> DeleteCurrentUserProfilePicture()
{
var user = await _userManager.GetUserAsync(User);
if (user is null)
{
return this.UserNotFound();
}
if (user is null) return this.UserNotFound();
var blob = user.GetBlob() ?? new UserBlob();
if (!string.IsNullOrEmpty(blob.ImageUrl))

View File

@@ -1179,6 +1179,17 @@ namespace BTCPayServer.Controllers.Greenfield
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteApp(appId));
}
public override async Task<FileData> UploadAppItemImage(string appId, string filePath, string mimeType, CancellationToken token = default)
{
var file = GetFormFile(filePath, mimeType);
return GetFromActionResult<FileData>(await GetController<GreenfieldAppsController>().UploadAppItemImage(appId, file));
}
public override async Task DeleteAppItemImage(string appId, string fileId, CancellationToken token = default)
{
HandleActionResult(await GetController<GreenfieldAppsController>().DeleteAppItemImage(appId, fileId));
}
public override Task<List<RateSource>> GetRateSources(CancellationToken token = default)
{
return Task.FromResult(GetFromActionResult(GetController<GreenfieldStoreRateConfigurationController>().GetRateSources()));

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Fido2;
@@ -159,28 +160,14 @@ namespace BTCPayServer.Controllers
if (model.ImageFile != null)
{
if (model.ImageFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded image file should be less than 1MB");
}
else if (!model.ImageFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded file needs to be an image");
}
var imageUpload = await _fileService.UploadImage(model.ImageFile, user.Id);
if (!imageUpload.Success)
ModelState.AddModelError(nameof(model.ImageFile), imageUpload.Response);
else
{
var formFile = await model.ImageFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.ImageFile), "The uploaded file needs to be an image");
}
else
{
model.ImageFile = formFile;
// add new image
try
{
var storedFile = await _fileService.AddFile(model.ImageFile, user.Id);
var storedFile = imageUpload.StoredFile!;
var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id);
blob.ImageUrl = fileIdUri.ToString();
needUpdate = true;
@@ -191,7 +178,6 @@ namespace BTCPayServer.Controllers
}
}
}
}
else if (RemoveImageFile && !string.IsNullOrEmpty(blob.ImageUrl))
{
blob.ImageUrl = null;

View File

@@ -137,28 +137,14 @@ namespace BTCPayServer.Controllers
if (viewModel.ImageFile != null)
{
if (viewModel.ImageFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(viewModel.ImageFile), StringLocalizer["The uploaded image file should be less than {0}", "1MB"]);
}
else if (!viewModel.ImageFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(viewModel.ImageFile), StringLocalizer["The uploaded file needs to be an image"]);
}
var imageUpload = await _fileService.UploadImage(viewModel.ImageFile, user.Id);
if (!imageUpload.Success)
ModelState.AddModelError(nameof(viewModel.ImageFile), imageUpload.Response);
else
{
var formFile = await viewModel.ImageFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(viewModel.ImageFile), StringLocalizer["The uploaded file needs to be an image"]);
}
else
{
viewModel.ImageFile = formFile;
// add new image
try
{
var storedFile = await _fileService.AddFile(viewModel.ImageFile, userId);
var storedFile = imageUpload.StoredFile!;
var fileIdUri = new UnresolvedUri.FileIdUri(storedFile.Id);
blob.ImageUrl = fileIdUri.ToString();
propertiesChanged = true;
@@ -169,7 +155,6 @@ namespace BTCPayServer.Controllers
}
}
}
}
else if (RemoveImageFile && !string.IsNullOrEmpty(blob.ImageUrl))
{
blob.ImageUrl = null;
@@ -181,7 +166,7 @@ namespace BTCPayServer.Controllers
var wasAdmin = Roles.HasServerAdmin(roles);
if (!viewModel.IsAdmin && admins.Count == 1 && wasAdmin)
{
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["This is the only admin, so their role can't be removed until another Admin is added."].Value;
TempData[WellKnownTempData.ErrorMessage] = StringLocalizer["This is the only admin, so their role can't be removed until another admin is added."].Value;
return View(viewModel);
}

View File

@@ -1108,12 +1108,12 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
ModelState.AddModelError(nameof(vm.CustomThemeFile), $"Could not save theme file: {e.Message}");
ModelState.AddModelError(nameof(vm.CustomThemeFile), StringLocalizer["Could not save CSS file: {0}", e.Message]);
}
}
else
{
ModelState.AddModelError(nameof(vm.CustomThemeFile), "The uploaded theme file needs to be a CSS file");
ModelState.AddModelError(nameof(vm.CustomThemeFile), StringLocalizer["The uploaded file needs to be a CSS file"]);
}
}
else if (RemoveCustomThemeFile && theme.CustomThemeCssUrl is not null)
@@ -1129,18 +1129,18 @@ namespace BTCPayServer.Controllers
{
if (vm.LogoFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file should be less than 1MB");
ModelState.AddModelError(nameof(vm.LogoFile), StringLocalizer["The uploaded file should be less than {0}", "1MB"]);
}
else if (!vm.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file needs to be an image");
ModelState.AddModelError(nameof(vm.LogoFile), StringLocalizer["The uploaded file needs to be an image"]);
}
else
{
var formFile = await vm.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(vm.LogoFile), "The uploaded logo file needs to be an image");
ModelState.AddModelError(nameof(vm.LogoFile), StringLocalizer["The uploaded file needs to be an image"]);
}
else
{
@@ -1155,7 +1155,7 @@ namespace BTCPayServer.Controllers
}
catch (Exception e)
{
ModelState.AddModelError(nameof(vm.LogoFile), $"Could not save logo: {e.Message}");
ModelState.AddModelError(nameof(vm.LogoFile), StringLocalizer["Could not save logo: {0}", e.Message]);
}
}
}

View File

@@ -101,28 +101,14 @@ public partial class UIStoresController
if (model.LogoFile != null)
{
if (model.LogoFile.Length > 1_000_000)
{
ModelState.AddModelError(nameof(model.LogoFile), StringLocalizer["The uploaded logo file should be less than {0}", "1MB"]);
}
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
ModelState.AddModelError(nameof(model.LogoFile), StringLocalizer["The uploaded logo file needs to be an image"]);
}
var imageUpload = await _fileService.UploadImage(model.LogoFile, userId);
if (!imageUpload.Success)
ModelState.AddModelError(nameof(model.LogoFile), imageUpload.Response);
else
{
var formFile = await model.LogoFile.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
ModelState.AddModelError(nameof(model.LogoFile), StringLocalizer["The uploaded logo file needs to be an image"]);
}
else
{
model.LogoFile = formFile;
// add new image
try
{
var storedFile = await _fileService.AddFile(model.LogoFile, userId);
var storedFile = imageUpload.StoredFile!;
blob.LogoUrl = new UnresolvedUri.FileIdUri(storedFile.Id);
}
catch (Exception e)
@@ -131,7 +117,6 @@ public partial class UIStoresController
}
}
}
}
else if (RemoveLogoFile && blob.LogoUrl is not null)
{
blob.LogoUrl = null;

View File

@@ -685,6 +685,7 @@ namespace BTCPayServer.Services
"If your security device has a button, tap on it.": "",
"Image": "",
"Image Size": "",
"Image uploaded successfully": "",
"Image Url": "",
"Import {0} Wallet": "",
"Import an existing hardware or software wallet": "",
@@ -1452,9 +1453,6 @@ namespace BTCPayServer.Services
"The uploaded file needs to be a CSS file": "",
"The uploaded file needs to be an image": "",
"The uploaded file should be less than {0}": "",
"The uploaded image file should be less than {0}": "",
"The uploaded logo file needs to be an image": "",
"The uploaded logo file should be less than {0}": "",
"The uploaded sound file needs to be an audio file": "",
"The uploaded sound file should be less than {0}": "",
"The URL to post purchase data.": "",
@@ -1516,7 +1514,7 @@ namespace BTCPayServer.Services
"This invoice has expired": "",
"This is an extremely dangerous operation!": "",
"This is an overpayment of the initial amount.": "",
"This is the only admin, so their role can't be removed until another Admin is added.": "",
"This is the only admin, so their role can't be removed until another admin is added.": "",
"This key, also called \"xpub\", is used to generate individual destination addresses for your invoices.": "",
"This label will be removed from this wallet and its associated transactions.": "",
"this link": "",

View File

@@ -1,12 +1,10 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using BTCPayServer.Abstractions.Contracts;
@@ -18,6 +16,7 @@ using BTCPayServer.Storage.Models;
using BTCPayServer.Storage.Services.Providers;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Storage.Services
@@ -29,11 +28,13 @@ namespace BTCPayServer.Storage.Services
private readonly SettingsRepository _settingsRepository;
private readonly IOptions<DataDirectories> _dataDirectories;
private readonly IHttpClientFactory _httpClientFactory;
private IStringLocalizer StringLocalizer { get; }
public FileService(StoredFileRepository fileRepository,
SettingsRepository settingsRepository,
IEnumerable<IStorageProviderService> providers,
IHttpClientFactory httpClientFactory,
IStringLocalizer stringLocalizer,
IOptions<DataDirectories> dataDirectories)
{
_fileRepository = fileRepository;
@@ -41,6 +42,7 @@ namespace BTCPayServer.Storage.Services
_settingsRepository = settingsRepository;
_httpClientFactory = httpClientFactory;
_dataDirectories = dataDirectories;
StringLocalizer = stringLocalizer;
}
public async Task<bool> IsAvailable()
@@ -56,32 +58,32 @@ namespace BTCPayServer.Storage.Services
if (file.Length > maxFileSizeInBytes)
{
result.Success = false;
result.Response = $"The uploaded image file should be less than {maxFileSizeInBytes / 1_000_000}MB";
result.Response = StringLocalizer["The uploaded file should be less than {0}", $"{maxFileSizeInBytes / 1_000_000}MB"].Value;
return result;
}
if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{
result.Success = false;
result.Response = "The uploaded file needs to be an image (based on content type)";
result.Response = StringLocalizer["The uploaded file needs to be an image"].Value;
return result;
}
var formFile = await file.Bufferize();
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
{
result.Success = false;
result.Response = "The uploaded file needs to be an image (based on file content)";
result.Response = StringLocalizer["The uploaded file needs to be an image"].Value;
return result;
}
try
{
result.StoredFile = await AddFile(formFile, userId);
result.Success = true;
result.Response = "Image uploaded successfully";
result.Response = StringLocalizer["Image uploaded successfully"].Value;
}
catch (Exception e)
{
result.Success = false;
result.Response = $"Could not save image: {e.Message}";
result.Response = StringLocalizer["Could not save image: {0}", e.Message].Value;
}
return result;
@@ -170,7 +172,7 @@ namespace BTCPayServer.Storage.Services
private IStorageProviderService GetProvider(StorageSettings storageSettings)
{
return _providers.First((service) => service.StorageProvider().Equals(storageSettings.Provider));
return _providers.First(service => service.StorageProvider().Equals(storageSettings.Provider));
}
private static string GetContentType(string filePath)

View File

@@ -345,6 +345,82 @@
]
}
},
"/api/v1/apps/{appId}/image": {
"post": {
"tags": [
"Apps"
],
"summary": "Uploads an image for a app item",
"description": "Uploads an image for a app item",
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"additionalProperties": false,
"properties": {
"file": {
"type": "string",
"description": "The image",
"format": "binary"
}
}
}
}
}
},
"operationId": "Apps_UploadAppItemImage",
"responses": {
"200": {
"description": "Uploads an image for a app item",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileData"
}
}
}
},
"404": {
"description": "The app could not be found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/apps/{appId}/image/{fileId}": {
"delete": {
"tags": [
"Apps"
],
"summary": "Deletes the app item image",
"description": "Deletes the app item image",
"operationId": "App_DeleteAppItemImage",
"responses": {
"200": {
"description": "App item image deleted successfully"
},
"404": {
"description": "The app could not be found"
}
},
"security": [
{
"API_Key": [
"btcpay.store.canmodifystoresettings"
],
"Basic": []
}
]
}
},
"/api/v1/apps/{appId}/sales": {
"get": {
"tags": [