diff --git a/BTCPayServer.Abstractions/Form/Field.cs b/BTCPayServer.Abstractions/Form/Field.cs index 7c7fc7823..421c143d3 100644 --- a/BTCPayServer.Abstractions/Form/Field.cs +++ b/BTCPayServer.Abstractions/Form/Field.cs @@ -12,7 +12,7 @@ public class Field { public static Field Create(string label, string name, string value, bool required, string helpText, string type = "text") { - return new Field() + return new Field { Label = label, Name = name, @@ -26,14 +26,14 @@ public class Field // The name of the HTML5 node. Should be used as the key for the posted data. public string Name; - public bool Hidden; + public bool Constant; // HTML5 compatible type string like "text", "textarea", "email", "password", etc. Each type is a class and may contain more fields (i.e. "select" would have options). public string Type; public static Field CreateFieldset() { - return new Field() { Type = "fieldset" }; + return new Field { Type = "fieldset" }; } // The value field is what is currently in the DB or what the user entered, but possibly not saved yet due to validation errors. @@ -52,10 +52,10 @@ public class Field public string HelpText; [JsonExtensionData] public IDictionary AdditionalData { get; set; } - public List Fields { get; set; } = new(); + public List Fields { get; set; } = new (); // The field is considered "valid" if there are no validation errors - public List ValidationErrors = new List(); + public List ValidationErrors = new (); public virtual bool IsValid() { diff --git a/BTCPayServer.Abstractions/Form/Form.cs b/BTCPayServer.Abstractions/Form/Form.cs index 17b649bfa..2ee6b79fa 100644 --- a/BTCPayServer.Abstractions/Form/Form.cs +++ b/BTCPayServer.Abstractions/Form/Form.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Reflection; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.Primitives; using Newtonsoft.Json.Linq; +using Npgsql.Internal.TypeHandlers.GeometricHandlers; namespace BTCPayServer.Abstractions.Form; @@ -20,6 +22,7 @@ public class Form return JObject.FromObject(this, CamelCaseSerializerSettings.Serializer).ToString(Newtonsoft.Json.Formatting.Indented); } #nullable restore + // Messages to be shown at the top of the form indicating user feedback like "Saved successfully" or "Please change X because of Y." or a warning, etc... public List TopMessages { get; set; } = new(); @@ -32,126 +35,125 @@ public class Form return Fields.Select(f => f.IsValid()).All(o => o); } - public Field GetFieldByName(string name) + public Field GetFieldByFullName(string fullName) { - return GetFieldByName(name, Fields, null); - } - - private static Field GetFieldByName(string name, List fields, string prefix) - { - prefix ??= string.Empty; - foreach (var field in fields) + foreach (var f in GetAllFields()) { - var currentPrefix = prefix; - if (!string.IsNullOrEmpty(field.Name)) - { - - currentPrefix = $"{prefix}{field.Name}"; - if (currentPrefix.Equals(name, StringComparison.InvariantCultureIgnoreCase)) - { - return field; - } - - currentPrefix += "_"; - } - - var subFieldResult = GetFieldByName(name, field.Fields, currentPrefix); - if (subFieldResult is not null) - { - return subFieldResult; - } - + if (f.FullName == fullName) + return f.Field; } return null; } - public List GetAllNames() + public IEnumerable<(string FullName, List Path, Field Field)> GetAllFields() { - return GetAllNames(Fields); - } - - private static List GetAllNames(List fields) - { - var names = new List(); - - foreach (var field in fields) + HashSet nameReturned = new HashSet(); + foreach (var f in GetAllFieldsCore(new List(), Fields)) { - string prefix = string.Empty; - if (!string.IsNullOrEmpty(field.Name)) - { - names.Add(field.Name); - prefix = $"{field.Name}_"; - } - - if (field.Fields.Any()) - { - names.AddRange(GetAllNames(field.Fields).Select(s => $"{prefix}{s}")); - } - } - - return names; - } - - public void ApplyValuesFromOtherForm(Form form) - { - foreach (var fieldset in Fields) - { - foreach (var field in fieldset.Fields) - { - field.Value = form - .GetFieldByName( - $"{(string.IsNullOrEmpty(fieldset.Name) ? string.Empty : fieldset.Name + "_")}{field.Name}") - ?.Value; - } + var fullName = String.Join('_', f.Path); + if (!nameReturned.Add(fullName)) + continue; + yield return (fullName, f.Path, f.Field); } } - public void ApplyValuesFromForm(IFormCollection form) + public bool ValidateFieldNames(out List errors) { - var names = GetAllNames(); - foreach (var name in names) + errors = new List(); + HashSet nameReturned = new HashSet(); + foreach (var f in GetAllFieldsCore(new List(), Fields)) { - var field = GetFieldByName(name); - if (field is null || !form.TryGetValue(name, out var val)) + var fullName = String.Join('_', f.Path); + if (!nameReturned.Add(fullName)) { + errors.Add($"Form contains duplicate field names '{fullName}'"); continue; } - - field.Value = val; } + return errors.Count == 0; } - public Dictionary GetValues() + IEnumerable<(List Path, Field Field)> GetAllFieldsCore(List path, List fields) { - return GetValues(Fields); - } - - private static Dictionary GetValues(List fields) - { - var result = new Dictionary(); - foreach (Field field in fields) + foreach (var field in fields) { - var name = field.Name ?? string.Empty; - if (field.Fields.Any()) + List thisPath = new List(path.Count + 1); + thisPath.AddRange(path); + if (!string.IsNullOrEmpty(field.Name)) { - var values = GetValues(fields); - values.Remove(string.Empty, out var keylessValue); + thisPath.Add(field.Name); + yield return (thisPath, field); + } - result.TryAdd(name, values); - - if (keylessValue is not Dictionary dict) - continue; - foreach (KeyValuePair keyValuePair in dict) + foreach (var child in field.Fields) + { + if (field.Constant) + child.Constant = true; + foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields)) { - result.TryAdd(keyValuePair.Key, keyValuePair.Value); + yield return descendant; } } - else + } + } + + public void ApplyValuesFromForm(IEnumerable> form) + { + var values = form.GroupBy(f => f.Key, f => f.Value).ToDictionary(g => g.Key, g => g.First()); + foreach (var f in GetAllFields()) + { + if (f.Field.Constant || !values.TryGetValue(f.FullName, out var val)) + continue; + + f.Field.Value = val; + } + } + + public void SetValues(JObject values) + { + var fields = GetAllFields().ToDictionary(k => k.FullName, k => k.Field); + SetValues(fields, new List(), values); + } + + private void SetValues(Dictionary fields, List path, JObject values) + { + foreach (var prop in values.Properties()) + { + List propPath = new List(path.Count + 1); + propPath.AddRange(path); + propPath.Add(prop.Name); + if (prop.Value.Type == JTokenType.Object) { - result.TryAdd(name, field.Value); + SetValues(fields, propPath, (JObject)prop.Value); + } + else if (prop.Value.Type == JTokenType.String) + { + var fullname = String.Join('_', propPath); + if (fields.TryGetValue(fullname, out var f) && !f.Constant) + f.Value = prop.Value.Value(); } } + } - return result; + public JObject GetValues() + { + var r = new JObject(); + foreach (var f in GetAllFields()) + { + var node = r; + for (int i = 0; i < f.Path.Count - 1; i++) + { + var p = f.Path[i]; + var child = node[p] as JObject; + if (child is null) + { + child = new JObject(); + node[p] = child; + } + node = child; + } + node[f.Field.Name] = f.Field.Value; + } + return r; } } diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs index 6f40feacb..2c38cd7f8 100644 --- a/BTCPayServer.Data/ApplicationDbContext.cs +++ b/BTCPayServer.Data/ApplicationDbContext.cs @@ -75,6 +75,7 @@ namespace BTCPayServer.Data public DbSet Webhooks { get; set; } public DbSet LightningAddresses { get; set; } public DbSet PayoutProcessors { get; set; } + public DbSet Forms { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { @@ -128,6 +129,7 @@ namespace BTCPayServer.Data LightningAddressData.OnModelCreating(builder); PayoutProcessorData.OnModelCreating(builder); //WebhookData.OnModelCreating(builder); + FormData.OnModelCreating(builder, Database); if (Database.IsSqlite() && !_designTime) diff --git a/BTCPayServer.Data/Data/FormData.cs b/BTCPayServer.Data/Data/FormData.cs index ef22d70c3..4914ae2ab 100644 --- a/BTCPayServer.Data/Data/FormData.cs +++ b/BTCPayServer.Data/Data/FormData.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations.Schema; +using System.ComponentModel.DataAnnotations.Schema; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -6,7 +6,26 @@ namespace BTCPayServer.Data.Data; public class FormData { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public string Id { get; set; } public string Name { get; set; } + public string StoreId { get; set; } + public StoreData Store { get; set; } public string Config { get; set; } + public bool Public { get; set; } + + internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) + { + builder.Entity() + .HasOne(o => o.Store) + .WithMany(o => o.Forms).OnDelete(DeleteBehavior.Cascade); + builder.Entity().HasIndex(o => o.StoreId); + + if (databaseFacade.IsNpgsql()) + { + builder.Entity() + .Property(o => o.Config) + .HasColumnType("JSONB"); + } + } } diff --git a/BTCPayServer.Data/Data/StoreData.cs b/BTCPayServer.Data/Data/StoreData.cs index 02182d142..9746db5f7 100644 --- a/BTCPayServer.Data/Data/StoreData.cs +++ b/BTCPayServer.Data/Data/StoreData.cs @@ -51,6 +51,7 @@ namespace BTCPayServer.Data public IEnumerable Payouts { get; set; } public IEnumerable CustodianAccounts { get; set; } public IEnumerable Settings { get; set; } + public IEnumerable Forms { get; set; } internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) { diff --git a/BTCPayServer.Data/Migrations/20230125085242_AddForms.cs b/BTCPayServer.Data/Migrations/20230125085242_AddForms.cs new file mode 100644 index 000000000..4adb5558e --- /dev/null +++ b/BTCPayServer.Data/Migrations/20230125085242_AddForms.cs @@ -0,0 +1,54 @@ +using System; +using BTCPayServer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace BTCPayServer.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20230125085242_AddForms")] + public partial class AddForms : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + int? maxlength = migrationBuilder.IsMySql() ? 255 : null; + migrationBuilder.CreateTable( + name: "Forms", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false, maxLength: maxlength), + Name = table.Column(type: "TEXT", nullable: true, maxLength: maxlength), + StoreId = table.Column(type: "TEXT", nullable: true, maxLength: maxlength), + Config = table.Column(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true), + Public = table.Column(nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Forms", x => x.Id); + table.ForeignKey( + name: "FK_Forms_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Forms_StoreId", + table: "Forms", + column: "StoreId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Forms"); + + } + } +} diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs index 00ce9916c..df9b6f693 100644 --- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -205,6 +205,31 @@ namespace BTCPayServer.Migrations b.ToTable("CustodianAccount"); }); + modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Public") + .HasColumnType("INTEGER"); + + b.Property("StoreId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("StoreId"); + + b.ToTable("Forms"); + }); + modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b => { b.Property("Id") @@ -705,8 +730,8 @@ namespace BTCPayServer.Migrations b.Property("SpeedPolicy") .HasColumnType("INTEGER"); - b.Property("StoreBlob") - .HasColumnType("BLOB"); + b.Property("StoreBlob") + .HasColumnType("TEXT"); b.Property("StoreCertificate") .HasColumnType("BLOB"); @@ -1129,6 +1154,16 @@ namespace BTCPayServer.Migrations b.Navigation("StoreData"); }); + modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b => + { + b.HasOne("BTCPayServer.Data.StoreData", "Store") + .WithMany("Forms") + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Store"); + }); + modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b => { b.HasOne("BTCPayServer.Data.StoreData", "Store") @@ -1519,6 +1554,8 @@ namespace BTCPayServer.Migrations b.Navigation("CustodianAccounts"); + b.Navigation("Forms"); + b.Navigation("Invoices"); b.Navigation("LightningAddresses"); diff --git a/BTCPayServer.Tests/FormTes.cs b/BTCPayServer.Tests/FormTes.cs new file mode 100644 index 000000000..1d56291b3 --- /dev/null +++ b/BTCPayServer.Tests/FormTes.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using BTCPayServer.Abstractions.Form; +using BTCPayServer.Forms; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; +using Newtonsoft.Json.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace BTCPayServer.Tests; + +[Trait("Fast", "Fast")] +public class FormTests : UnitTestBase +{ + public FormTests(ITestOutputHelper helper) : base(helper) + { + } + + [Fact] + public void CanParseForm() + { + var form = new Form() + { + Fields = new List + { + Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"), + Field.Create("Name", "item2", 2.ToString(), true, null), + Field.Create("Name", "invoice_test", 2.ToString(), true, null), + new Field + { + Name = "invoice", + Type = "fieldset", + Fields = new List + { + Field.Create("Name", "test", 3.ToString(), true, null), + Field.Create("Name", "item4", 4.ToString(), true, null), + Field.Create("Name", "item5", 5.ToString(), true, null), + } + } + } + }; + var service = new FormDataService(null, null); + Assert.False(service.IsFormSchemaValid(form.ToString(), out _, out _)); + form = new Form() + { + Fields = new List + { + Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"), + Field.Create("Name", "item2", 2.ToString(), true, null), + Field.Create("Name", "invoice_item3", 2.ToString(), true, null), + new Field + { + Name = "invoice", + Type = "fieldset", + Fields = new List {Field.Create("Name", "test", 3.ToString(), true, null),} + } + } + }; + + + Assert.True(service.IsFormSchemaValid(form.ToString(), out _, out _)); + form.ApplyValuesFromForm(new FormCollection(new Dictionary() + { + {"item1", new StringValues("updated")}, + {"item2", new StringValues("updated")}, + {"invoice_item3", new StringValues("updated")}, + {"invoice_test", new StringValues("updated")} + })); + foreach (var f in form.GetAllFields()) + { + if (f.Field.Type == "fieldset") + continue; + Assert.Equal("updated", f.Field.Value); + } + + form = new Form() + { + Fields = new List + { + Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"), + Field.Create("Name", "item2", 2.ToString(), true, null), + Field.Create("Name", "invoice_item3", 2.ToString(), true, null), + new Field + { + Name = "invoice", + Type = "fieldset", + Fields = new List + { + new() {Name = "test", Type = "text", Constant = true, Value = "original"} + } + } + } + }; + form.ApplyValuesFromForm(new FormCollection(new Dictionary() + { + {"item1", new StringValues("updated")}, + {"item2", new StringValues("updated")}, + {"invoice_item3", new StringValues("updated")}, + {"invoice_test", new StringValues("updated")} + })); + + foreach (var f in form.GetAllFields()) + { + var field = f.Field; + if (field.Type == "fieldset") + continue; + switch (f.FullName) + { + case "invoice_test": + Assert.Equal("original", field.Value); + break; + default: + Assert.Equal("updated", field.Value); + break; + } + } + + form = new Form() + { + Fields = new List + { + Field.Create("Enter your email", "item1", 1.ToString(), true, null, "email"), + Field.Create("Name", "item2", 2.ToString(), true, null), + Field.Create("Name", "invoice_item3", 2.ToString(), true, null), + new Field + { + Name = "invoice", + Type = "fieldset", + Constant = true, + Fields = new List + { + new() {Name = "test", Type = "text", Value = "original"} + } + } + } + }; + form.ApplyValuesFromForm(new FormCollection(new Dictionary() + { + {"item1", new StringValues("updated")}, + {"item2", new StringValues("updated")}, + {"invoice_item3", new StringValues("updated")}, + {"invoice_test", new StringValues("updated")} + })); + + foreach (var f in form.GetAllFields()) + { + var field = f.Field; + if (field.Type == "fieldset") + continue; + switch (f.FullName) + { + case "invoice_test": + Assert.Equal("original", field.Value); + break; + default: + Assert.Equal("updated", field.Value); + break; + } + } + + var obj = form.GetValues(); + Assert.Equal("original", obj["invoice"]["test"].Value()); + Assert.Equal("updated", obj["invoice_item3"].Value()); + Clear(form); + form.SetValues(obj); + obj = form.GetValues(); + Assert.Equal("original", obj["invoice"]["test"].Value()); + Assert.Equal("updated", obj["invoice_item3"].Value()); + + form = new Form() + { + Fields = new List(){ + new Field + { + Type = "fieldset", + Fields = new List + { + new() {Name = "test", Type = "text"} + } + } + } + }; + form.SetValues(obj); + obj = form.GetValues(); + Assert.Null(obj["test"].Value()); + form.SetValues(new JObject{ ["test"] = "hello" }); + obj = form.GetValues(); + Assert.Equal("hello", obj["test"].Value()); + } + + private void Clear(Form form) + { + foreach (var f in form.Fields.Where(f => !f.Constant)) + f.Value = null; + } +} diff --git a/BTCPayServer.Tests/SeleniumTester.cs b/BTCPayServer.Tests/SeleniumTester.cs index a6bb5fb29..63b70e234 100644 --- a/BTCPayServer.Tests/SeleniumTester.cs +++ b/BTCPayServer.Tests/SeleniumTester.cs @@ -86,8 +86,14 @@ namespace BTCPayServer.Tests Driver.AssertNoError(); } - public void PayInvoice(bool mine = false) + public void PayInvoice(bool mine = false, decimal? amount= null) { + + if (amount is not null) + { + Driver.FindElement(By.Id("test-payment-amount")).Clear(); + Driver.FindElement(By.Id("test-payment-amount")).SendKeys(amount.ToString()); + } Driver.FindElement(By.Id("FakePayment")).Click(); if (mine) { diff --git a/BTCPayServer.Tests/SeleniumTests.cs b/BTCPayServer.Tests/SeleniumTests.cs index 6b490be06..40cbc83fd 100644 --- a/BTCPayServer.Tests/SeleniumTests.cs +++ b/BTCPayServer.Tests/SeleniumTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.ObjectModel; using System.Globalization; using System.Linq; +using System.Net; using System.Net.Http; using System.Text; using System.Text.RegularExpressions; @@ -117,6 +118,68 @@ namespace BTCPayServer.Tests s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click(); s.Driver.Navigate().GoToUrl(editUrl); Assert.Contains("aa@aa.com", s.Driver.PageSource); + + //Custom Forms + s.GoToStore(StoreNavPages.Forms); + Assert.Contains("There are no forms yet.", s.Driver.PageSource); + s.Driver.FindElement(By.Id("CreateForm")).Click(); + s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 1"); + s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click(); + var emailtemplate = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value"); + Assert.Contains("buyerEmail", emailtemplate); + s.Driver.FindElement(By.Name("FormConfig")).Clear(); + s.Driver.FindElement(By.Name("FormConfig")) + .SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest")); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + s.Driver.FindElement(By.Id("ViewForm")).Click(); + + + var formurl = s.Driver.Url; + Assert.Contains("CustomFormInputTest", s.Driver.PageSource); + s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com"); + s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click(); + s.PayInvoice(true); + var result = await s.Server.PayTester.HttpClient.GetAsync(formurl); + Assert.Equal(HttpStatusCode.NotFound, result.StatusCode); + + s.GoToHome(); + s.GoToStore(StoreNavPages.Forms); + Assert.Contains("Custom Form 1", s.Driver.PageSource); + s.Driver.FindElement(By.LinkText("Remove")).Click(); + s.Driver.WaitForElement(By.Id("ConfirmInput")).SendKeys("DELETE"); + s.Driver.FindElement(By.Id("ConfirmContinue")).Click(); + + Assert.DoesNotContain("Custom Form 1", s.Driver.PageSource); + s.Driver.FindElement(By.Id("CreateForm")).Click(); + s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 2"); + s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click(); + s.Driver.SetCheckbox(By.Name("Public"), true); + + s.Driver.FindElement(By.Name("FormConfig")).Clear(); + s.Driver.FindElement(By.Name("FormConfig")) + .SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest2")); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + s.Driver.FindElement(By.Id("ViewForm")).Click(); + formurl = s.Driver.Url; + result = await s.Server.PayTester.HttpClient.GetAsync(formurl); + Assert.NotEqual(HttpStatusCode.NotFound, result.StatusCode); + + s.GoToHome(); + s.GoToStore(StoreNavPages.Forms); + Assert.Contains("Custom Form 2", s.Driver.PageSource); + + s.Driver.FindElement(By.LinkText("Custom Form 2")).Click(); + + s.Driver.FindElement(By.Name("Name")).Clear(); + s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 3"); + s.Driver.FindElement(By.Id("SaveButton")).Click(); + s.GoToStore(StoreNavPages.Forms); + Assert.Contains("Custom Form 3", s.Driver.PageSource); + + s.Driver.FindElement(By.Id("StoreNav-PaymentRequests")).Click(); + s.Driver.FindElement(By.Id("CreatePaymentRequest")).Click(); + Assert.Equal(4, new SelectElement(s.Driver.FindElement(By.Id("FormId"))).Options.Count); + } [Fact(Timeout = TestTimeout)] diff --git a/BTCPayServer/Controllers/UICustodianAccountsController.cs b/BTCPayServer/Controllers/UICustodianAccountsController.cs index eceb3bc4d..58554c24a 100644 --- a/BTCPayServer/Controllers/UICustodianAccountsController.cs +++ b/BTCPayServer/Controllers/UICustodianAccountsController.cs @@ -370,7 +370,7 @@ namespace BTCPayServer.Controllers storedKeys.Add(item.Key); } - var formKeys = form.GetAllNames(); + var formKeys = form.GetAllFields().Select(f => f.FullName).ToHashSet(); foreach (var item in newData) { diff --git a/BTCPayServer/Controllers/UIPaymentRequestController.cs b/BTCPayServer/Controllers/UIPaymentRequestController.cs index 13875e095..879dbcf21 100644 --- a/BTCPayServer/Controllers/UIPaymentRequestController.cs +++ b/BTCPayServer/Controllers/UIPaymentRequestController.cs @@ -10,6 +10,7 @@ using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Filters; using BTCPayServer.Forms; +using BTCPayServer.Forms.Models; using BTCPayServer.Models; using BTCPayServer.Models.PaymentRequestViewModels; using BTCPayServer.PaymentRequest; @@ -40,6 +41,7 @@ namespace BTCPayServer.Controllers private readonly StoreRepository _storeRepository; private FormComponentProviders FormProviders { get; } + public FormDataService FormDataService { get; } public UIPaymentRequestController( UIInvoiceController invoiceController, @@ -50,7 +52,8 @@ namespace BTCPayServer.Controllers CurrencyNameTable currencies, StoreRepository storeRepository, InvoiceRepository invoiceRepository, - FormComponentProviders formProviders) + FormComponentProviders formProviders, + FormDataService formDataService) { _InvoiceController = invoiceController; _UserManager = userManager; @@ -61,6 +64,7 @@ namespace BTCPayServer.Controllers _storeRepository = storeRepository; _InvoiceRepository = invoiceRepository; FormProviders = formProviders; + FormDataService = formDataService; } [BitpayAPIConstraint(false)] @@ -204,7 +208,7 @@ namespace BTCPayServer.Controllers [HttpGet("{payReqId}/form")] [HttpPost("{payReqId}/form")] [AllowAnonymous] - public async Task ViewPaymentRequestForm(string payReqId) + public async Task ViewPaymentRequestForm(string payReqId, FormViewModel viewModel) { var result = await _PaymentRequestRepository.FindPaymentRequest(payReqId, GetUserId()); if (result == null) @@ -213,42 +217,34 @@ namespace BTCPayServer.Controllers } var prBlob = result.GetBlob(); - var prFormId = prBlob.FormId; - var formConfig = prFormId is null ? null : Forms.UIFormsController.GetFormData(prFormId)?.Config; - switch (formConfig) + if (prBlob.FormResponse is not null) { - case null: - case { } when !this.Request.HasFormContentType && prBlob.FormResponse is not null: - return RedirectToAction("ViewPaymentRequest", new { payReqId }); - case { } when !this.Request.HasFormContentType && prBlob.FormResponse is null: - break; - default: - // POST case: Handle form submit - var formData = Form.Parse(formConfig); - formData.ApplyValuesFromForm(Request.Form); - if (FormProviders.Validate(formData, ModelState)) - { - prBlob.FormResponse = JObject.FromObject(formData.GetValues()); - result.SetBlob(prBlob); - await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result); - return RedirectToAction("PayPaymentRequest", new { payReqId }); - } - break; + return RedirectToAction("PayPaymentRequest", new {payReqId}); + } + var prFormId = prBlob.FormId; + var formData = await FormDataService.GetForm(prFormId); + if (formData is null) + { + + return RedirectToAction("PayPaymentRequest", new {payReqId}); } - return View("PostRedirect", new PostRedirectViewModel + var form = Form.Parse(formData.Config); + if (Request.Method == "POST" && Request.HasFormContentType) { - AspController = "UIForms", - AspAction = "ViewPublicForm", - RouteParameters = - { - { "formId", prFormId } - }, - FormParameters = - { - { "redirectUrl", Request.GetCurrentUrl() } + form.ApplyValuesFromForm(Request.Form); + if (FormDataService.Validate(form, ModelState)) + { + prBlob.FormResponse = form.GetValues(); + result.SetBlob(prBlob); + await _PaymentRequestRepository.CreateOrUpdatePaymentRequest(result); + return RedirectToAction("PayPaymentRequest", new {payReqId}); } - }); + } + viewModel.FormName = formData.Name; + viewModel.Form = form; + return View("Views/UIForms/View", viewModel); + } [HttpGet("{payReqId}/pay")] @@ -277,6 +273,15 @@ namespace BTCPayServer.Controllers return BadRequest("Payment Request cannot be paid as it has been archived"); } + if (!result.FormSubmitted && !string.IsNullOrEmpty(result.FormId)) + { + var formData = await FormDataService.GetForm(result.FormId); + if (formData is not null) + { + return RedirectToAction("ViewPaymentRequestForm", new {payReqId}); + } + } + result.HubPath = PaymentRequestHub.GetHubPath(Request); if (result.AmountDue <= 0) { diff --git a/BTCPayServer/Forms/FormDataExtensions.cs b/BTCPayServer/Forms/FormDataExtensions.cs index 2a793d165..8496198a7 100644 --- a/BTCPayServer/Forms/FormDataExtensions.cs +++ b/BTCPayServer/Forms/FormDataExtensions.cs @@ -1,3 +1,4 @@ +using BTCPayServer.Abstractions.Form; using BTCPayServer.Data.Data; using Microsoft.Extensions.DependencyInjection; using Newtonsoft.Json; @@ -14,6 +15,11 @@ public static class FormDataExtensions serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); } + + public static JObject Deserialize(this FormData form) + { + return JsonConvert.DeserializeObject(form.Config); + } public static string Serialize(this JObject form) { diff --git a/BTCPayServer/Forms/FormDataService.cs b/BTCPayServer/Forms/FormDataService.cs index a0c049836..ab637873a 100644 --- a/BTCPayServer/Forms/FormDataService.cs +++ b/BTCPayServer/Forms/FormDataService.cs @@ -1,26 +1,48 @@ #nullable enable using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using BTCPayServer.Abstractions.Form; +using BTCPayServer.Client.Models; using BTCPayServer.Data; using BTCPayServer.Data.Data; +using BTCPayServer.Models; +using BTCPayServer.Services.Stores; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Forms; public class FormDataService { + public const string InvoiceParameterPrefix = "invoice_"; + private readonly ApplicationDbContextFactory _applicationDbContextFactory; + private readonly FormComponentProviders _formProviders; + + public FormDataService( + ApplicationDbContextFactory applicationDbContextFactory, + FormComponentProviders formProviders) + { + _applicationDbContextFactory = applicationDbContextFactory; + _formProviders = formProviders; + } public static readonly Form StaticFormEmail = new() { - Fields = new List() { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") } + Fields = new List { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") } }; public static readonly Form StaticFormAddress = new() { - Fields = new List() + Fields = new List { Field.Create("Enter your email", "buyerEmail", null, true, null, "email"), Field.Create("Name", "buyerName", null, true, null), @@ -32,4 +54,109 @@ public class FormDataService Field.Create("Country", "buyerCountry", null, true, null) } }; + + private static readonly Dictionary _hardcodedOptions = new() + { + {"", ("Do not request any information", null, null)!}, + {"Email", ("Request email address only", "Provide your email address", StaticFormEmail )}, + {"Address", ("Request shipping address", "Provide your address", StaticFormAddress)}, + }; + + public async Task GetSelect(string storeId ,string selectedFormId) + { + var forms = await GetForms(storeId); + return new SelectList(_hardcodedOptions.Select(pair => new SelectListItem(pair.Value.selectText, pair.Key, selectedFormId == pair.Key)).Concat(forms.Select(data => new SelectListItem(data.Name, data.Id, data.Id == selectedFormId))), + nameof(SelectListItem.Value), nameof(SelectListItem.Text)); + } + + public async Task> GetForms(string storeId) + { + ArgumentNullException.ThrowIfNull(storeId); + await using var context = _applicationDbContextFactory.CreateContext(); + return await context.Forms.Where(data => data.StoreId == storeId).ToListAsync(); + } + + public async Task GetForm(string storeId, string? id) + { + if (id is null) + { + return null; + } + await using var context = _applicationDbContextFactory.CreateContext(); + return await context.Forms.Where(data => data.Id == id && data.StoreId == storeId).FirstOrDefaultAsync(); + } + public async Task GetForm(string? id) + { + if (id is null) + { + return null; + } + + if (_hardcodedOptions.TryGetValue(id, out var hardcodedForm)) + { + return new FormData + { + Config = hardcodedForm.form.ToString(), + Id = id, + Name = hardcodedForm.name, + Public = false + }; + } + await using var context = _applicationDbContextFactory.CreateContext(); + return await context.Forms.Where(data => data.Id == id).FirstOrDefaultAsync(); + } + + public async Task RemoveForm(string id, string storeId) + { + await using var context = _applicationDbContextFactory.CreateContext(); + var item = await context.Forms.SingleOrDefaultAsync(data => data.StoreId == storeId && id == data.Id); + if (item is not null) + context.Remove(item); + await context.SaveChangesAsync(); + } + + public async Task AddOrUpdateForm(FormData data) + { + await using var context = _applicationDbContextFactory.CreateContext(); + + context.Update(data); + await context.SaveChangesAsync(); + } + + public bool Validate(Form form, ModelStateDictionary modelState) + { + return _formProviders.Validate(form, modelState); + } + + public bool IsFormSchemaValid(string schema, [MaybeNullWhen(false)] out Form form, [MaybeNullWhen(false)] out string error) + { + error = null; + form = null; + try + { + form = Form.Parse(schema); + if (!form.ValidateFieldNames(out var errors)) + { + error = errors.First(); + } + } + catch (Exception ex) + { + error = $"Form config was invalid: {ex.Message}"; + } + return error is null && form is not null; + } + + public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form) + { + var amt = form.GetFieldByFullName($"{InvoiceParameterPrefix}amount")?.Value; + return new CreateInvoiceRequest + { + Currency = form.GetFieldByFullName($"{InvoiceParameterPrefix}currency")?.Value, + Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture), + + Metadata = form.GetValues(), + + }; + } } diff --git a/BTCPayServer/Forms/Models/FormViewModel.cs b/BTCPayServer/Forms/Models/FormViewModel.cs index c1af61e6d..1dc4cce79 100644 --- a/BTCPayServer/Forms/Models/FormViewModel.cs +++ b/BTCPayServer/Forms/Models/FormViewModel.cs @@ -1,13 +1,18 @@ +using System.Collections.Generic; using BTCPayServer.Abstractions.Form; -using BTCPayServer.Data.Data; -using Newtonsoft.Json.Linq; namespace BTCPayServer.Forms.Models; public class FormViewModel { + public string LogoFileId { get; set; } + public string CssFileId { get; set; } + public string BrandColor { get; set; } + public string StoreName { get; set; } + public string FormName { get; set; } public string RedirectUrl { get; set; } - public FormData FormData { get; set; } - Form _Form; - public Form Form { get => _Form ??= Form.Parse(FormData.Config); } + public Form Form { get; set; } + public string AspController { get; set; } + public string AspAction { get; set; } + public Dictionary RouteParameters { get; set; } = new(); } diff --git a/BTCPayServer/Forms/ModifyForm.cs b/BTCPayServer/Forms/ModifyForm.cs index ae902f54f..5066a762e 100644 --- a/BTCPayServer/Forms/ModifyForm.cs +++ b/BTCPayServer/Forms/ModifyForm.cs @@ -8,4 +8,7 @@ public class ModifyForm [DisplayName("Form configuration (JSON)")] public string FormConfig { get; set; } + + [DisplayName("Allow form for public use")] + public bool Public { get; set; } } diff --git a/BTCPayServer/Forms/UIFormsController.cs b/BTCPayServer/Forms/UIFormsController.cs index be2447dfe..1a3ad22a5 100644 --- a/BTCPayServer/Forms/UIFormsController.cs +++ b/BTCPayServer/Forms/UIFormsController.cs @@ -1,109 +1,203 @@ #nullable enable using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; using BTCPayServer.Abstractions.Constants; +using BTCPayServer.Abstractions.Extensions; using BTCPayServer.Abstractions.Form; +using BTCPayServer.Abstractions.Models; using BTCPayServer.Client; +using BTCPayServer.Client.Models; using BTCPayServer.Controllers; +using BTCPayServer.Data; using BTCPayServer.Data.Data; using BTCPayServer.Forms.Models; -using BTCPayServer.Models; using BTCPayServer.Services.Stores; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Newtonsoft.Json.Linq; namespace BTCPayServer.Forms; [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] public class UIFormsController : Controller { + private readonly FormDataService _formDataService; + private readonly IAuthorizationService _authorizationService; private FormComponentProviders FormProviders { get; } - public UIFormsController(FormComponentProviders formProviders) + public UIFormsController(FormComponentProviders formProviders, FormDataService formDataService, + IAuthorizationService authorizationService) { FormProviders = formProviders; + _formDataService = formDataService; + _authorizationService = authorizationService; + } + + [HttpGet("~/stores/{storeId}/forms")] + public async Task FormsList(string storeId) + { + var forms = await _formDataService.GetForms(storeId); + + return View(forms); + } + + [HttpGet("~/stores/{storeId}/forms/new")] + public IActionResult Create(string storeId) + { + var vm = new ModifyForm {FormConfig = new Form().ToString()}; + return View("Modify", vm); + } + + [HttpGet("~/stores/{storeId}/forms/modify/{id}")] + public async Task Modify(string storeId, string id) + { + var form = await _formDataService.GetForm(storeId, id); + if (form is null) return NotFound(); + + var config = Form.Parse(form.Config); + return View(new ModifyForm {Name = form.Name, FormConfig = config.ToString(), Public = form.Public}); + } + + [HttpPost("~/stores/{storeId}/forms/modify/{id?}")] + public async Task Modify(string storeId, string? id, ModifyForm modifyForm) + { + if (id is not null) + { + if (await _formDataService.GetForm(storeId, id) is null) + { + return NotFound(); + } + } + + if (!_formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error)) + { + + ModelState.AddModelError(nameof(modifyForm.FormConfig), + $"Form config was invalid: {error})"); + } + else + { + modifyForm.FormConfig = form.ToString(); + } + + + if (!ModelState.IsValid) + { + return View(modifyForm); + } + + try + { + var formData = new FormData + { + Id = id, StoreId = storeId, Name = modifyForm.Name, Config = modifyForm.FormConfig,Public = modifyForm.Public + }; + var isNew = id is null; + await _formDataService.AddOrUpdateForm(formData); + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Success, + Message = $"Form {(isNew ? "created" : "updated")} successfully." + }); + if (isNew) + { + return RedirectToAction("Modify", new {storeId, id = formData.Id}); + } + } + catch (Exception e) + { + ModelState.AddModelError("", $"An error occurred while saving: {e.Message}"); + } + + return View(modifyForm); + } + + [HttpPost("~/stores/{storeId}/forms/{id}/remove")] + public async Task Remove(string storeId, string id) + { + await _formDataService.RemoveForm(id, storeId); + TempData.SetStatusMessageModel(new StatusMessageModel + { + Severity = StatusMessageModel.StatusSeverity.Success, Message = "Form removed" + }); + return RedirectToAction("FormsList", new {storeId}); } [AllowAnonymous] [HttpGet("~/forms/{formId}")] - [HttpPost("~/forms")] - public IActionResult ViewPublicForm(string? formId, string? redirectUrl) + public async Task ViewPublicForm(string? formId) { - if (!IsValidRedirectUri(redirectUrl)) - return BadRequest(); - - FormData? formData = string.IsNullOrEmpty(formId) ? null : GetFormData(formId); - if (formData == null) + FormData? formData = await _formDataService.GetForm(formId); + if (formData?.Config is null) { - return string.IsNullOrEmpty(redirectUrl) - ? NotFound() - : Redirect(redirectUrl); + return NotFound(); } - return GetFormView(formData, redirectUrl); + if (!formData.Public && + !(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded) + { + return NotFound(); + } + + return GetFormView(formData); } - ViewResult GetFormView(FormData formData, string? redirectUrl) + ViewResult GetFormView(FormData formData, Form? form = null) { - return View("View", new FormViewModel { FormData = formData, RedirectUrl = redirectUrl }); + form ??= Form.Parse(formData.Config); + form.ApplyValuesFromForm(Request.Query); + var store = formData.Store; + var storeBlob = store?.GetStoreBlob(); + + return View("View", new FormViewModel + { + FormName = formData.Name, + Form = form, + StoreName = store?.StoreName, + BrandColor = storeBlob?.BrandColor, + CssFileId = storeBlob?.CssFileId, + LogoFileId = storeBlob?.LogoFileId, + }); } + [AllowAnonymous] [HttpPost("~/forms/{formId}")] - public IActionResult SubmitForm(string formId, string? redirectUrl, string? command) + public async Task SubmitForm(string formId, + [FromServices] StoreRepository storeRepository, + [FromServices] UIInvoiceController invoiceController) { - if (!IsValidRedirectUri(redirectUrl)) - return BadRequest(); - - var formData = GetFormData(formId); + var formData = await _formDataService.GetForm(formId); if (formData?.Config is null) - return NotFound(); - - if (!Request.HasFormContentType) - return GetFormView(formData, redirectUrl); - - var conf = Form.Parse(formData.Config); - conf.ApplyValuesFromForm(Request.Form); - if (!FormProviders.Validate(conf, ModelState)) - return GetFormView(formData, redirectUrl); - - var form = new MultiValueDictionary(); - foreach (var kv in Request.Form) - form.Add(kv.Key, kv.Value); - - // With redirect, the form comes from another entity that we need to send the data back to - if (!string.IsNullOrEmpty(redirectUrl)) { - return View("PostRedirect", new PostRedirectViewModel - { - FormUrl = redirectUrl, - FormParameters = form - }); + return NotFound(); } - return NotFound(); - } - - internal static FormData? GetFormData(string id) - { - FormData? form = id switch + if (!formData.Public && + !(await _authorizationService.AuthorizeAsync(User, Policies.CanViewStoreSettings)).Succeeded) { - { } formId when formId == GenericFormOption.Address.ToString() => new FormData - { - Config = FormDataService.StaticFormAddress.ToString(), - Id = GenericFormOption.Address.ToString(), - Name = "Provide your address", - }, - { } formId when formId == GenericFormOption.Email.ToString() => new FormData - { - Config = FormDataService.StaticFormEmail.ToString(), - Id = GenericFormOption.Email.ToString(), - Name = "Provide your email address", - }, - _ => null - }; - return form; - } + return NotFound(); + } - private bool IsValidRedirectUri(string? redirectUrl) => - !string.IsNullOrEmpty(redirectUrl) && Uri.TryCreate(redirectUrl, UriKind.RelativeOrAbsolute, out var uri) && - (Url.IsLocalUrl(redirectUrl) || uri.Host.Equals(Request.Host.Host)); + if (!Request.HasFormContentType) + return GetFormView(formData); + + var form = Form.Parse(formData.Config); + form.ApplyValuesFromForm(Request.Form); + + if (!_formDataService.Validate(form, ModelState)) + return GetFormView(formData, form); + + // Create invoice after public form has been filled + var store = await storeRepository.FindStore(formData.StoreId); + if (store is null) + return NotFound(); + + var request = _formDataService.GenerateInvoiceParametersFromForm(form); + var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot()); + + return RedirectToAction("Checkout", "UIInvoice", new {invoiceId = inv.Id}); + } } diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs index 34c714a39..79f840c92 100644 --- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs +++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs @@ -16,6 +16,7 @@ using BTCPayServer.Controllers; using BTCPayServer.Data; using BTCPayServer.Filters; using BTCPayServer.Forms; +using BTCPayServer.Forms.Models; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Plugins.PointOfSale.Models; @@ -42,21 +43,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers CurrencyNameTable currencies, StoreRepository storeRepository, UIInvoiceController invoiceController, - FormComponentProviders formProviders) + FormDataService formDataService) { _currencies = currencies; _appService = appService; _storeRepository = storeRepository; _invoiceController = invoiceController; - FormProviders = formProviders; + FormDataService = formDataService; } private readonly CurrencyNameTable _currencies; private readonly StoreRepository _storeRepository; private readonly AppService _appService; private readonly UIInvoiceController _invoiceController; - - public FormComponentProviders FormProviders { get; } + public FormDataService FormDataService { get; } [HttpGet("/")] [HttpGet("/apps/{appId}/pos/{viewType?}")] @@ -121,14 +121,15 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers [DomainMappingConstraint(AppType.PointOfSale)] [RateLimitsFilter(ZoneLimits.PublicInvoices, Scope = RateLimitsScope.RemoteAddress)] public async Task ViewPointOfSale(string appId, - PosViewType? viewType, - [ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount, - string email, - string orderId, - string notificationUrl, - string redirectUrl, - string choiceKey, + PosViewType? viewType = null, + [ModelBinder(typeof(InvariantDecimalModelBinder))] decimal? amount = null, + string email = null, + string orderId = null, + string notificationUrl = null, + string redirectUrl = null, + string choiceKey = null, string posData = null, + string formResponse = null, RequiresRefundEmail requiresRefundEmail = RequiresRefundEmail.InheritFromStore, CancellationToken cancellationToken = default) { @@ -229,45 +230,38 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers var store = await _appService.GetStore(app); var posFormId = settings.FormId; - - var formConfig = posFormId is null ? null : Forms.UIFormsController.GetFormData(posFormId)?.Config; - JObject formResponse = null; - switch (formConfig) + var formData = await FormDataService.GetForm(posFormId); + + JObject formResponseJObject = null; + switch (formData) { case null: - case { } when !this.Request.HasFormContentType: break; - default: - var formData = Form.Parse(formConfig); - formData.ApplyValuesFromForm(this.Request.Form); - - if (FormProviders.Validate(formData, ModelState)) + case not null: + if (formResponse is null) { - formResponse = JObject.FromObject(formData.GetValues()); - break; + return View("PostRedirect", new PostRedirectViewModel + { + AspAction = nameof(POSForm), + RouteParameters = new Dictionary { { "appId", appId } }, + AspController = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture), + FormParameters = new MultiValueDictionary(Request.Form.Select(pair => new KeyValuePair>(pair.Key, pair.Value))) + }); } - var query = new QueryBuilder(Request.Query); - foreach (var keyValuePair in Request.Form) + formResponseJObject = JObject.Parse(formResponse); + var form = Form.Parse(formData.Config); + form.SetValues(formResponseJObject); + if (!FormDataService.Validate(form, ModelState)) { - query.Add(keyValuePair.Key, keyValuePair.Value.ToArray()); + //someone tried to bypass validation + return RedirectToAction(nameof(ViewPointOfSale), new {appId}); } - // GET or empty form data case: Redirect to form - return View("PostRedirect", new PostRedirectViewModel - { - AspController = "UIForms", - AspAction = "ViewPublicForm", - RouteParameters = - { - { "formId", posFormId } - }, - FormParameters = - { - { "redirectUrl", Request.GetCurrentUrl() + query } - } - }); + formResponseJObject = form.GetValues(); + break; } + try { var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest @@ -293,14 +287,14 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers : requiresRefundEmail == RequiresRefundEmail.On, }, store, HttpContext.Request.GetAbsoluteRoot(), new List { AppService.GetAppInternalTag(appId) }, - cancellationToken, (entity) => + cancellationToken, entity => { entity.Metadata.OrderUrl = Request.GetDisplayUrl(); - if (formResponse is not null) + if (formResponseJObject is not null) { var meta = entity.Metadata.ToJObject(); - meta.Merge(formResponse); + meta.Merge(formResponseJObject); entity.Metadata = InvoiceMetadata.FromJObject(meta); } }); @@ -314,10 +308,85 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers Severity = StatusMessageModel.StatusSeverity.Error, AllowDismiss = true }); - return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId }); + return RedirectToAction(nameof(ViewPointOfSale), new { appId }); } } + [HttpPost("/apps/{appId}/pos/form")] + public async Task POSForm(string appId) + { + var app = await _appService.GetApp(appId, AppType.PointOfSale); + if (app == null) + return NotFound(); + + var settings = app.GetSettings(); + var formData = await FormDataService.GetForm(settings.FormId); + if (formData is null) + { + return RedirectToAction(nameof(ViewPointOfSale), new { appId }); + } + + var myDictionary = Request.Form + .Where(pair => pair.Key != "__RequestVerificationToken") + .ToDictionary(p => p.Key, p => p.Value.ToString()); + myDictionary.Add("appId", appId); + var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture); + var redirectUrl = Url.Action(nameof(ViewPointOfSale), controller, myDictionary); + var store = await _appService.GetStore(app); + var storeBlob = store.GetStoreBlob(); + var form = Form.Parse(formData.Config); + + return View("Views/UIForms/View", new FormViewModel + { + StoreName = store.StoreName, + BrandColor = storeBlob.BrandColor, + CssFileId = storeBlob.CssFileId, + LogoFileId = storeBlob.LogoFileId, + FormName = formData.Name, + Form = form, + RedirectUrl = redirectUrl, + AspController = controller, + AspAction = nameof(POSFormSubmit), + RouteParameters = new Dictionary { { "appId", appId } }, + }); + } + + [HttpPost("/apps/{appId}/pos/form/submit")] + public async Task POSFormSubmit(string appId, FormViewModel viewModel) + { + var app = await _appService.GetApp(appId, AppType.PointOfSale); + if (app == null) + return NotFound(); + var settings = app.GetSettings(); + var formData = await FormDataService.GetForm(settings.FormId); + if (formData is null || viewModel.RedirectUrl is null) + { + return RedirectToAction(nameof(ViewPointOfSale), new {appId }); + } + + var form = Form.Parse(formData.Config); + if (Request.Method == "POST" && Request.HasFormContentType) + { + form.ApplyValuesFromForm(Request.Form); + if (FormDataService.Validate(form, ModelState)) + { + return View("PostRedirect", new PostRedirectViewModel + { + FormUrl = viewModel.RedirectUrl, + FormParameters = + { + { "formResponse", form.GetValues().ToString() } + } + }); + } + } + + viewModel.FormName = formData.Name; + viewModel.Form = form; + + return View("Views/UIForms/View", viewModel); + } + [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [HttpGet("{appId}/settings/pos")] public async Task UpdatePointOfSale(string appId) @@ -326,7 +395,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers if (app == null) return NotFound(); - var storeBlob = GetCurrentStore().GetStoreBlob(); var settings = app.GetSettings(); settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView; settings.EnableShoppingCart = false; @@ -358,13 +426,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "", FormId = settings.FormId }; - if (HttpContext?.Request != null) + if (HttpContext.Request != null) { var appUrl = HttpContext.Request.GetAbsoluteUri($"/apps/{appId}/pos"); var encoder = HtmlEncoder.Default; if (settings.ShowCustomAmount) { - StringBuilder builder = new StringBuilder(); + var builder = new StringBuilder(); builder.AppendLine(CultureInfo.InvariantCulture, $"
"); builder.AppendLine($" "); builder.AppendLine($" "); @@ -443,7 +511,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers Description = vm.Description, EmbeddedCSS = vm.EmbeddedCSS, RedirectAutomatically = - string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically) + string.IsNullOrEmpty(vm.RedirectAutomatically) ? null : bool.Parse(vm.RedirectAutomatically) }; settings.FormId = vm.FormId; diff --git a/BTCPayServer/Services/InvoiceActivator.cs b/BTCPayServer/Services/InvoiceActivator.cs index e3643c8ff..8508f60cb 100644 --- a/BTCPayServer/Services/InvoiceActivator.cs +++ b/BTCPayServer/Services/InvoiceActivator.cs @@ -1,7 +1,6 @@ using System; using System.Linq; using System.Threading.Tasks; -using AngleSharp.Dom; using BTCPayServer.Data; using BTCPayServer.Events; using BTCPayServer.Logging; diff --git a/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs b/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs deleted file mode 100644 index d8acc5686..000000000 --- a/BTCPayServer/Services/Stores/CheckoutFormSelectList.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel.DataAnnotations; -using System.Linq; -using Microsoft.AspNetCore.Mvc.Rendering; - -namespace BTCPayServer.Services.Stores; - -public enum GenericFormOption -{ - [Display(Name = "Do not request any information")] - None, - - [Display(Name = "Request email address only")] - Email, - - [Display(Name = "Request shipping address")] - Address -} - -public static class CheckoutFormSelectList -{ - public static SelectList WithSelected(string selectedFormId) - { - var choices = new List - { - GenericOptionItem(GenericFormOption.None), - GenericOptionItem(GenericFormOption.Email), - GenericOptionItem(GenericFormOption.Address) - }; - - var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId); - return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value); - } - - private static string DisplayName(GenericFormOption opt) => - typeof(GenericFormOption).DisplayName(opt.ToString()); - - private static SelectListItem GenericOptionItem(GenericFormOption opt) => - new() { Text = DisplayName(opt), Value = opt == GenericFormOption.None ? null : opt.ToString() }; -} diff --git a/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml b/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml index bc9ae5f7a..adf264bfe 100644 --- a/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml +++ b/BTCPayServer/Views/Shared/Forms/FieldSetElement.cshtml @@ -1,10 +1,8 @@ -@using BTCPayServer.Abstractions.Form @using BTCPayServer.Forms @using Microsoft.AspNetCore.Mvc.TagHelpers -@using Newtonsoft.Json.Linq @inject FormComponentProviders FormComponentProviders @model BTCPayServer.Abstractions.Form.Field -@if (!Model.Hidden) +@if (!Model.Constant) {
@Model.Label diff --git a/BTCPayServer/Views/Shared/Forms/InputElement.cshtml b/BTCPayServer/Views/Shared/Forms/InputElement.cshtml index 47a13e552..de68756d5 100644 --- a/BTCPayServer/Views/Shared/Forms/InputElement.cshtml +++ b/BTCPayServer/Views/Shared/Forms/InputElement.cshtml @@ -1,34 +1,26 @@ -@using BTCPayServer.Abstractions.Form -@using Newtonsoft.Json.Linq @model BTCPayServer.Abstractions.Form.Field @{ - var isInvalid = this.ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid; - var error = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors[0].ErrorMessage : null; - + var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid; + var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null; }
- @if (Model.Required) - { - - } - else - { - - } - - - @if(isInvalid) - { - @error - } + + + @(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty) @if (!string.IsNullOrEmpty(Model.HelpText)) { -
@Model.HelpText
+
@Model.HelpText
} - -
diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml index 3bde698d0..8c353a5db 100644 --- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml @@ -2,12 +2,14 @@ @using BTCPayServer.Abstractions.Models @using BTCPayServer.Views.Apps @using BTCPayServer.Abstractions.Extensions +@using BTCPayServer.Forms @using BTCPayServer.Services.Stores +@inject FormDataService FormDataService @model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel @{ ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id); - - var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId); + + var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId); } diff --git a/BTCPayServer/Views/Shared/_Form.cshtml b/BTCPayServer/Views/Shared/_Form.cshtml index 9ca4ff6af..b742ff6c5 100644 --- a/BTCPayServer/Views/Shared/_Form.cshtml +++ b/BTCPayServer/Views/Shared/_Form.cshtml @@ -7,6 +7,6 @@ { if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial)) { - + } } diff --git a/BTCPayServer/Views/Shared/_ValidationScriptsPartial.cshtml b/BTCPayServer/Views/Shared/_ValidationScriptsPartial.cshtml index ed9d081a0..3d83359d2 100644 --- a/BTCPayServer/Views/Shared/_ValidationScriptsPartial.cshtml +++ b/BTCPayServer/Views/Shared/_ValidationScriptsPartial.cshtml @@ -1,4 +1,16 @@ - - + diff --git a/BTCPayServer/Views/UIForms/FormsList.cshtml b/BTCPayServer/Views/UIForms/FormsList.cshtml new file mode 100644 index 000000000..57d0fa09c --- /dev/null +++ b/BTCPayServer/Views/UIForms/FormsList.cshtml @@ -0,0 +1,55 @@ +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.Models +@model List +@{ + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["NavPartialName"] = "../UIStores/_Nav"; + ViewData.SetActivePage(StoreNavPages.Forms, "Forms"); + var storeId = Context.GetCurrentStoreId(); +} + +
+
+
+

@ViewData["Title"]

+ + + Create Form + +
+ @if (Model.Any()) + { + + + + + + + + + @foreach (var item in Model) + { + + + + + + } + +
NameActions
+ @item.Name + + Remove - + View +
+ } + else + { +

+ There are no forms yet. +

+ } +
+
+ + diff --git a/BTCPayServer/Views/UIForms/Modify.cshtml b/BTCPayServer/Views/UIForms/Modify.cshtml new file mode 100644 index 000000000..dd45f14a8 --- /dev/null +++ b/BTCPayServer/Views/UIForms/Modify.cshtml @@ -0,0 +1,87 @@ +@using BTCPayServer.Forms +@using Microsoft.AspNetCore.Mvc.TagHelpers +@using BTCPayServer.Abstractions.TagHelpers +@using Newtonsoft.Json +@model BTCPayServer.Forms.ModifyForm + +@{ + var formId = Context.GetRouteValue("id"); + var isNew = formId is null; + Layout = "../Shared/_NavLayout.cshtml"; + ViewData["NavPartialName"] = "../UIStores/_Nav"; + ViewData.SetActivePage(StoreNavPages.Forms, $"{(isNew ? "Create" : "Edit")} Form", Model.Name); + + var storeId = Context.GetCurrentStoreId(); +} + +@section PageFootContent { + + +} + + + + + +
+
+
+

@ViewData["Title"]

+
+ + @if (!isNew) + { + View + } +
+
+
+
+ + + +
+
+ +
+ +
+ Standalone mode, which can be used to generate invoices + independent of payment requests or apps. +
+
+
+
+
+ +
+ Templates: + + +
+
+ + +
+
+
+ diff --git a/BTCPayServer/Views/UIForms/View.cshtml b/BTCPayServer/Views/UIForms/View.cshtml index 7243b89d1..fe342c404 100644 --- a/BTCPayServer/Views/UIForms/View.cshtml +++ b/BTCPayServer/Views/UIForms/View.cshtml @@ -1,39 +1,58 @@ @using Microsoft.AspNetCore.Mvc.TagHelpers -@inject BTCPayServer.Services.BTCPayServerEnvironment env +@inject BTCPayServer.Services.BTCPayServerEnvironment Env @model BTCPayServer.Forms.Models.FormViewModel @{ Layout = null; - ViewData["Title"] = Model.FormData.Name; + ViewData["Title"] = Model.FormName; } - - + - + +
-
-
- - @if (!ViewContext.ModelState.IsValid) - { -
- } - -
-

@ViewData["Title"]

-
-
+ + @if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId)) + { + + } + else + { +

@ViewData["Title"]

+ } +
+ @if (!ViewContext.ModelState.IsValid) + { +
+ } + +
+
+ @if (string.IsNullOrEmpty(Model.AspAction)) + { + @if (!string.IsNullOrEmpty(Model.RedirectUrl)) { - + } - - + + -
+ } + else + { +
+ @if (!string.IsNullOrEmpty(Model.RedirectUrl)) + { + + } + + + + }
@@ -43,6 +62,7 @@
- + + diff --git a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml index 6361fc8df..2e5e58278 100644 --- a/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml +++ b/BTCPayServer/Views/UIPaymentRequest/EditPaymentRequest.cshtml @@ -1,11 +1,14 @@ @using BTCPayServer.Services.PaymentRequests @using System.Globalization +@using BTCPayServer.Forms @using BTCPayServer.Services.Stores @using BTCPayServer.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers +@inject FormDataService FormDataService @model BTCPayServer.Models.PaymentRequestViewModels.UpdatePaymentRequestViewModel @{ - var checkoutFormOptions = CheckoutFormSelectList.WithSelected(Model.FormId); + + var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId); ViewData.SetActivePage(PaymentRequestsNavPages.Create, $"{(string.IsNullOrEmpty(Model.Id) ? "Create" : "Edit")} Payment Request", Model.Id); } diff --git a/BTCPayServer/Views/UIStores/_Nav.cshtml b/BTCPayServer/Views/UIStores/_Nav.cshtml index da8b952ef..059db2938 100644 --- a/BTCPayServer/Views/UIStores/_Nav.cshtml +++ b/BTCPayServer/Views/UIStores/_Nav.cshtml @@ -18,6 +18,7 @@ Webhooks Payout Processors Emails + Forms