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.Data; using BTCPayServer.Models.AppViewModels; using BTCPayServer.Plugins.Wallets; using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Localization; namespace BTCPayServer.Controllers { [Route("apps")] public partial class UIAppsController( PaymentMethodHandlerDictionary handlers, BTCPayNetworkProvider networkProvider, StoreRepository storeRepository, IFileService fileService, AppService appService, IStringLocalizer stringLocalizer, ViewLocalizer viewLocalizer, IHtmlHelper html) : Controller { public string CreatedAppId { get; set; } public IHtmlHelper Html { get; } = html; public IStringLocalizer StringLocalizer { get; } = stringLocalizer; public ViewLocalizer ViewLocalizer { get; } = viewLocalizer; public class AppUpdated { public string AppId { get; set; } public object Settings { get; set; } public string StoreId { get; set; } } [HttpGet("/apps/{appId}")] public async Task RedirectToApp(string appId) { var app = await appService.GetApp(appId, null); if (app is null) return NotFound(); var res = await appService.ViewLink(app); if (res is null) { return NotFound(); } return Redirect(res); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("/stores/{storeId}/apps")] public async Task ListApps( string storeId, string sortOrder = null, string sortOrderColumn = null, bool archived = false ) { var store = HttpContext.GetStoreData(); var apps = (await appService.GetAllApps(GetUserId(), false, store.Id, archived)) .Where(app => app.Archived == archived); if (sortOrder != null && sortOrderColumn != null) { apps = apps.OrderByDescending(app => { return sortOrderColumn switch { nameof(app.AppName) => app.AppName, nameof(app.StoreName) => app.StoreName, nameof(app.AppType) => app.AppType, _ => app.Id }; }); switch (sortOrder) { case "desc": ViewData[$"{sortOrderColumn}NextSortOrder"] = "asc"; break; case "asc": apps = apps.Reverse(); ViewData[$"{sortOrderColumn}NextSortOrder"] = "desc"; break; } } return View(new ListAppsViewModel { Apps = apps.ToArray() }); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("/stores/{storeId}/apps/create/{appType?}")] public IActionResult CreateApp(string storeId, string appType = null) { var vm = new CreateAppViewModel(appService) { StoreId = storeId, AppType = appType, SelectedAppType = appType }; return View(vm); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpPost("/stores/{storeId}/apps/create/{appType?}")] public async Task CreateApp(string storeId, CreateAppViewModel vm) { var store = HttpContext.GetStoreData(); if (!store.AnyPaymentMethodAvailable(handlers)) { object text = networkProvider.DefaultNetwork?.CryptoCode switch { null => StringLocalizer["To create a {0} app, you need to set up a wallet first", vm.AppType], {} cryptoCode => ViewLocalizer["To create a {0} app, you need to set up a wallet first", vm.AppType, Url.Action(nameof(UIStoreOnChainWalletsController.SetupWallet), "UIStoreOnChainWallets", new { area = WalletsPlugin.Area, cryptoCode, storeId })!] }; TempData.SetStatusMessageModel(new StatusMessageModel { Severity = StatusMessageModel.StatusSeverity.Error, LocalizedHtml = text as LocalizedHtmlString, LocalizedMessage = text as LocalizedString, AllowDismiss = false }); return View(vm); } vm.StoreId = store.Id; var type = appService.GetAppType(vm.AppType ?? vm.SelectedAppType); if (type is null) { ModelState.AddModelError(nameof(vm.SelectedAppType), StringLocalizer["Invalid App Type"]); } if (!ModelState.IsValid) { return View(vm); } var appData = new AppData { StoreDataId = store.Id, Name = vm.AppName, AppType = type!.Type }; var defaultCurrency = await GetStoreDefaultCurrentIfEmpty(appData.StoreDataId, null); await appService.SetDefaultSettings(appData, defaultCurrency); await appService.UpdateOrCreateApp(appData); TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["App successfully created"].Value; CreatedAppId = appData.Id; var url = await type.ConfigureLink(appData); return Redirect(url); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("{appId}/delete")] public IActionResult DeleteApp(string appId) { var app = GetCurrentApp(); if (app == null) return NotFound(); return View("Confirm", new ConfirmModel(StringLocalizer["Delete app"], StringLocalizer["The app {0} and its settings will be permanently deleted. Are you sure?", Html.Encode(app.Name)], StringLocalizer["Delete"])); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpPost("{appId}/delete")] public async Task DeleteAppPost(string appId) { var app = GetCurrentApp(); if (app == null) return NotFound(); if (await appService.DeleteApp(app)) TempData[WellKnownTempData.SuccessMessage] = StringLocalizer["App deleted successfully."].Value; return RedirectToAction(nameof(UIStoresController.Dashboard), "UIStores", new { storeId = app.StoreDataId }); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpPost("{appId}/archive")] public async Task ToggleArchive(string appId) { var app = GetCurrentApp(); if (app == null) return NotFound(); var type = appService.GetAppType(app.AppType); if (type is null) { return UnprocessableEntity(); } var archived = !app.Archived; if (await appService.SetArchived(app, archived)) { TempData[WellKnownTempData.SuccessMessage] = archived ? StringLocalizer["The app has been archived and will no longer appear in the apps list by default."].Value : StringLocalizer["The app has been unarchived and will appear in the apps list by default again."].Value; } else { TempData[WellKnownTempData.ErrorMessage] = archived ? StringLocalizer["Failed to archive the app."].Value : StringLocalizer["Failed to unarchive the app."].Value; } var url = await type.ConfigureLink(app); return Redirect(url); } [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpPost("{appId}/upload-file")] [IgnoreAntiforgeryToken] public async Task FileUpload(IFormFile file) { var app = GetCurrentApp(); var userId = User.GetIdOrNull(); if (app is null || userId is null) return NotFound(); if (!file.FileName.IsValidFileName()) { return Json(new { error = StringLocalizer["Invalid file name"].Value }); } if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) { return Json(new { error = StringLocalizer["The file needs to be an image"].Value }); } if (file.Length > 500_000) { return Json(new { error = StringLocalizer["The file size should be less than 0.5MB"].Value }); } var formFile = await file.Bufferize(); if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName)) { return Json(new { error = StringLocalizer["The file needs to be an image"].Value }); } try { var storedFile = await fileService.AddFile(file, userId); var fileId = storedFile.Id; var fileUrl = await fileService.GetFileUrl(Request.GetAbsoluteRootUri(), fileId); return Json(new { fileId, fileUrl }); } catch (Exception e) { return Json(new { error = $"Could not save file: {e.Message}" }); } } async Task GetStoreDefaultCurrentIfEmpty(string storeId, string currency) { if (string.IsNullOrWhiteSpace(currency)) { var store = await storeRepository.FindStore(storeId); currency = store?.GetStoreBlob().DefaultCurrency; } return currency?.Trim().ToUpperInvariant(); } private string GetUserId() => User.GetId(); private AppData GetCurrentApp() => HttpContext.GetAppDataOrNull(); } }