diff --git a/BTCPayServer.Client/BTCPayServerClient.Files.cs b/BTCPayServer.Client/BTCPayServerClient.Files.cs new file mode 100644 index 000000000..9f2c1353e --- /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.Stores.cs b/BTCPayServer.Client/BTCPayServerClient.Stores.cs index c331a10a8..3bfbe3113 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Stores.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Stores.cs @@ -37,4 +37,13 @@ public partial class BTCPayServerClient 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 e4d212310..9518f9c06 100644 --- a/BTCPayServer.Client/BTCPayServerClient.Users.cs +++ b/BTCPayServer.Client/BTCPayServerClient.Users.cs @@ -18,6 +18,16 @@ public partial class BTCPayServerClient 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 79372f418..1e3a03535 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 @@ public partial class BTCPayServerClient 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 000000000..ece4e8b10 --- /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.Tests/GreenfieldAPITests.cs b/BTCPayServer.Tests/GreenfieldAPITests.cs index 00439a4ad..8c6113933 100644 --- a/BTCPayServer.Tests/GreenfieldAPITests.cs +++ b/BTCPayServer.Tests/GreenfieldAPITests.cs @@ -239,6 +239,76 @@ namespace BTCPayServer.Tests 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() diff --git a/BTCPayServer.Tests/TestData/logo.png b/BTCPayServer.Tests/TestData/logo.png new file mode 100644 index 000000000..9b469c677 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 7789de013..f98295e9e 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -161,10 +161,14 @@ namespace BTCPayServer.Tests { 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] diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldFilesController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldFilesController.cs new file mode 100644 index 000000000..c14463532 --- /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/GreenfieldStoresController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs index 7123d7298..ec710f18e 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldStoresController.cs @@ -3,12 +3,13 @@ using System.Collections.Generic; 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 @@ namespace BTCPayServer.Controllers.Greenfield { 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 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield 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)); } - internal static Client.Models.StoreData FromModel(StoreData data) + [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 async Task FromModel(StoreData data) { var storeBlob = data.GetStoreBlob(); return new Client.Models.StoreData @@ -117,9 +175,9 @@ namespace BTCPayServer.Controllers.Greenfield 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 6b49ab860..d75429cd4 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 @@ namespace BTCPayServer.Controllers.Greenfield [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 4877dcf15..6f4a4db51 100644 --- a/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs +++ b/BTCPayServer/Controllers/GreenField/GreenfieldUsersController.cs @@ -5,6 +5,7 @@ using System.Threading; 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.Security.Greenfield; 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 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield IAuthorizationService authorizationService, UserService userService, UriResolver uriResolver, + IFileService fileService, Logs logs) { this.Logs = logs; @@ -67,6 +71,7 @@ namespace BTCPayServer.Controllers.Greenfield _authorizationService = authorizationService; _userService = userService; _uriResolver = uriResolver; + _fileService = fileService; } [Authorize(Policy = Policies.CanViewUsers, AuthenticationSchemes = AuthenticationSchemes.Greenfield)] @@ -203,7 +208,7 @@ namespace BTCPayServer.Controllers.Greenfield user.SetBlob(blob); if (ModelState.IsValid && needUpdate) - { + { var identityResult = await _userManager.UpdateAsync(user); if (!identityResult.Succeeded) { @@ -224,6 +229,68 @@ namespace BTCPayServer.Controllers.Greenfield 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 338230a14..6925789df 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.Authorization; 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; @@ -772,9 +774,9 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield 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 @@ namespace BTCPayServer.Controllers.Greenfield { 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/UnresolvedUri.cs b/BTCPayServer/UnresolvedUri.cs index 56038ec9f..81597b57f 100644 --- a/BTCPayServer/UnresolvedUri.cs +++ b/BTCPayServer/UnresolvedUri.cs @@ -1,3 +1,4 @@ +#nullable enable using System; namespace BTCPayServer 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 000000000..f3c9cfa73 --- /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.stores.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.stores.json index fb38edb17..5941bc5fa 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 a943bd4f1..a6153e27c 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",