Generic Forms (#4561)

* Custom Forms

* Update BTCPayServer.Data/Migrations/20230125085242_AddForms.cs

* Cleanups

* Explain public form

* Add store branding

* Add form name to POS form

* add tests

* fix migration

* Minor cleanups

* Code improvements

* Add form validation

Closes #4317.

* Adapt form validation for Bootstrap 5

* update logic for forms

* pr changes

* Minor code cleanup

* Remove unused parameters

* Refactor Form data handling to avoid O(n3) issues

* Rename Hidden to Constant

* Pre-populate FormView from the query string params

* Fix test

---------

Co-authored-by: d11n <mail@dennisreimann.de>
Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
Andrew Camilleri 2023-02-20 11:35:54 +01:00 committed by GitHub
parent 60f84d5e30
commit bbbaacc350
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1174 additions and 354 deletions

View file

@ -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<string, JToken> AdditionalData { get; set; }
public List<Field> Fields { get; set; } = new();
public List<Field> Fields { get; set; } = new ();
// The field is considered "valid" if there are no validation errors
public List<string> ValidationErrors = new List<string>();
public List<string> ValidationErrors = new ();
public virtual bool IsValid()
{

View file

@ -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<AlertMessage> 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<Field> 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<string> GetAllNames()
public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields()
{
return GetAllNames(Fields);
}
private static List<string> GetAllNames(List<Field> fields)
{
var names = new List<string>();
foreach (var field in fields)
HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), 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<string> errors)
{
var names = GetAllNames();
foreach (var name in names)
errors = new List<string>();
HashSet<string> nameReturned = new HashSet<string>();
foreach (var f in GetAllFieldsCore(new List<string>(), 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<string, object> GetValues()
IEnumerable<(List<string> Path, Field Field)> GetAllFieldsCore(List<string> path, List<Field> fields)
{
return GetValues(Fields);
}
private static Dictionary<string, object> GetValues(List<Field> fields)
{
var result = new Dictionary<string, object>();
foreach (Field field in fields)
foreach (var field in fields)
{
var name = field.Name ?? string.Empty;
if (field.Fields.Any())
List<string> thisPath = new List<string>(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<string, object> dict)
continue;
foreach (KeyValuePair<string, object> 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<KeyValuePair<string, StringValues>> 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<string>(), values);
}
private void SetValues(Dictionary<string, Field> fields, List<string> path, JObject values)
{
foreach (var prop in values.Properties())
{
List<string> propPath = new List<string>(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<string>();
}
}
}
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;
}
}

View file

@ -75,6 +75,7 @@ namespace BTCPayServer.Data
public DbSet<WebhookData> Webhooks { get; set; }
public DbSet<LightningAddressData> LightningAddresses { get; set; }
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> 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)

View file

@ -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<FormData>()
.HasOne(o => o.Store)
.WithMany(o => o.Forms).OnDelete(DeleteBehavior.Cascade);
builder.Entity<FormData>().HasIndex(o => o.StoreId);
if (databaseFacade.IsNpgsql())
{
builder.Entity<FormData>()
.Property(o => o.Config)
.HasColumnType("JSONB");
}
}
}

View file

@ -51,6 +51,7 @@ namespace BTCPayServer.Data
public IEnumerable<PayoutData> Payouts { get; set; }
public IEnumerable<CustodianAccountData> CustodianAccounts { get; set; }
public IEnumerable<StoreSettingData> Settings { get; set; }
public IEnumerable<FormData> Forms { get; set; }
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{

View file

@ -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<string>(type: "TEXT", nullable: false, maxLength: maxlength),
Name = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
StoreId = table.Column<string>(type: "TEXT", nullable: true, maxLength: maxlength),
Config = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true),
Public = table.Column<bool>(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");
}
}
}

View file

@ -205,6 +205,31 @@ namespace BTCPayServer.Migrations
b.ToTable("CustodianAccount");
});
modelBuilder.Entity("BTCPayServer.Data.Data.FormData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("Public")
.HasColumnType("INTEGER");
b.Property<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("Forms");
});
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
{
b.Property<string>("Id")
@ -705,8 +730,8 @@ namespace BTCPayServer.Migrations
b.Property<int>("SpeedPolicy")
.HasColumnType("INTEGER");
b.Property<byte[]>("StoreBlob")
.HasColumnType("BLOB");
b.Property<string>("StoreBlob")
.HasColumnType("TEXT");
b.Property<byte[]>("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");

View file

@ -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>
{
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>
{
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>
{
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> {Field.Create("Name", "test", 3.ToString(), true, null),}
}
}
};
Assert.True(service.IsFormSchemaValid(form.ToString(), out _, out _));
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"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>
{
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>
{
new() {Name = "test", Type = "text", Constant = true, Value = "original"}
}
}
}
};
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"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>
{
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<Field>
{
new() {Name = "test", Type = "text", Value = "original"}
}
}
}
};
form.ApplyValuesFromForm(new FormCollection(new Dictionary<string, StringValues>()
{
{"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<string>());
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
Clear(form);
form.SetValues(obj);
obj = form.GetValues();
Assert.Equal("original", obj["invoice"]["test"].Value<string>());
Assert.Equal("updated", obj["invoice_item3"].Value<string>());
form = new Form()
{
Fields = new List<Field>(){
new Field
{
Type = "fieldset",
Fields = new List<Field>
{
new() {Name = "test", Type = "text"}
}
}
}
};
form.SetValues(obj);
obj = form.GetValues();
Assert.Null(obj["test"].Value<string>());
form.SetValues(new JObject{ ["test"] = "hello" });
obj = form.GetValues();
Assert.Equal("hello", obj["test"].Value<string>());
}
private void Clear(Form form)
{
foreach (var f in form.Fields.Where(f => !f.Constant))
f.Value = null;
}
}

View file

@ -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)
{

View file

@ -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)]

View file

@ -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)
{

View file

@ -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<IActionResult> ViewPaymentRequestForm(string payReqId)
public async Task<IActionResult> 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)
{

View file

@ -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<IFormComponentProvider, HtmlInputFormProvider>();
serviceCollection.AddSingleton<IFormComponentProvider, HtmlFieldsetFormProvider>();
}
public static JObject Deserialize(this FormData form)
{
return JsonConvert.DeserializeObject<JObject>(form.Config);
}
public static string Serialize(this JObject form)
{

View file

@ -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>() { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
Fields = new List<Field> { Field.Create("Enter your email", "buyerEmail", null, true, null, "email") }
};
public static readonly Form StaticFormAddress = new()
{
Fields = new List<Field>()
Fields = new List<Field>
{
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<string, (string selectText, string name, Form form)> _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<SelectList> 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<List<FormData>> 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<FormData?> 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<FormData?> 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(),
};
}
}

View file

@ -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<string, string> RouteParameters { get; set; } = new();
}

View file

@ -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; }
}

View file

@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string, string>();
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});
}
}

View file

@ -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<IActionResult> 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<string, string> { { "appId", appId } },
AspController = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture),
FormParameters = new MultiValueDictionary<string, string>(Request.Form.Select(pair => new KeyValuePair<string, IReadOnlyCollection<string>>(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<string> { 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<IActionResult> POSForm(string appId)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null)
return NotFound();
var settings = app.GetSettings<PointOfSaleSettings>();
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<string, string> { { "appId", appId } },
});
}
[HttpPost("/apps/{appId}/pos/form/submit")]
public async Task<IActionResult> POSFormSubmit(string appId, FormViewModel viewModel)
{
var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null)
return NotFound();
var settings = app.GetSettings<PointOfSaleSettings>();
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<IActionResult> 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<PointOfSaleSettings>();
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, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
builder.AppendLine($" <input type=\"hidden\" name=\"amount\" value=\"100\" />");
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
@ -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;

View file

@ -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;

View file

@ -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<SelectListItem>
{
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() };
}

View file

@ -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)
{
<fieldset>
<legend class="h3 mt-4 mb-3">@Model.Label</legend>

View file

@ -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;
}
<div class="form-group">
@if (Model.Required)
{
<label class="form-label" for="@Model.Name" data-required>
@Model.Label
</label>
}
else
{
<label class="form-label" for="@Model.Name">
@Model.Label
</label>
}
<input class="form-control @(Model.IsValid() ? "" : "is-invalid")" id="@Model.Name" type="@Model.Type" required="@Model.Required" name="@Model.Name" value="@Model.Value" aria-describedby="@("HelpText" + Model.Name)"/>
@if(isInvalid)
{
<span class="text-danger">@error</span>
}
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Model.Label
</label>
<input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="@Model.Value" data-val="true"
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")
}
@if (Model.Required)
{
@Safe.Raw($" data-val-required=\"{Model.Label} is required.\" required")
}
/>
<span class="text-danger" data-valmsg-for="@Model.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
@if (!string.IsNullOrEmpty(Model.HelpText))
{
<div id="@("HelpText" + Model.Name)" class="form-text">@Model.HelpText</div>
<div id="@($"HelpText-{Model.Name}")" class="form-text">@Model.HelpText</div>
}
</div>

View file

@ -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);
}
<form method="post">

View file

@ -7,6 +7,6 @@
{
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
{
<partial name="@partial.View" for="@field"></partial>
<partial name="@partial.View" for="@field" />
}
}

View file

@ -1,4 +1,16 @@
<script src="~/vendor/jquery-validate/jquery.validate.js" asp-append-version="true"></script>
<script src="~/vendor/jquery-validate-unobtrusive/jquery.validate.unobtrusive.js" asp-append-version="true"></script>
<script>
$.validator.setDefaults({
errorClass: '',
validClass: '',
highlight: function (element) {
$(element).addClass('is-invalid').removeClass('is-valid');
$(element.form).find(`[data-valmsg-for="${element.id}"]`).addClass('invalid-feedback');
},
unhighlight: function (element) {
$(element).addClass("is-valid").removeClass("is-invalid");
$(element.form).find(`[data-valmsg-for="${element.id}"]`).removeClass('invalid-feedback');
}
});
</script>

View file

@ -0,0 +1,55 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.Models
@model List<BTCPayServer.Data.Data.FormData>
@{
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../UIStores/_Nav";
ViewData.SetActivePage(StoreNavPages.Forms, "Forms");
var storeId = Context.GetCurrentStoreId();
}
<div class="row">
<div class="col-xxl-constrain col-xl-10">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<a asp-action="Create" asp-route-storeId="@storeId" class="btn btn-primary mt-3 mt-sm-0" role="button" id="CreateForm">
<span class="fa fa-plus"></span>
Create Form
</a>
</div>
@if (Model.Any())
{
<table class="table table-hover table-responsive-md">
<thead>
<tr>
<th>Name</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>
<a asp-action="Modify" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Edit-@item.Name">@item.Name</a>
</td>
<td class="text-end">
<a asp-action="Remove" asp-route-storeId="@item.StoreId" asp-route-id="@item.Id" id="Remove-@item.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-confirm-input="DELETE">Remove</a> -
<a asp-action="ViewPublicForm" asp-route-formId="@item.Id" id="View-@item.Name">View</a>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-secondary mt-3">
There are no forms yet.
</p>
}
</div>
</div>
<partial name="_Confirm" model="@(new ConfirmModel("Delete form", "This form will be removed from this store.", "Delete"))" />

View file

@ -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 {
<partial name="_ValidationScriptsPartial"/>
<script>
document.addEventListener("DOMContentLoaded", () => {
const $config = document.getElementById("FormConfig");
delegate("click", "[data-form-template]", e => {
const { formTemplate: id } = e.target.dataset
const $template = document.getElementById(`form-template-${id}`)
$config.value = $template.innerHTML.trim()
})
})
</script>
}
<template id="form-template-email">
@Json.Serialize(FormDataService.StaticFormEmail, new JsonSerializerSettings()
{
Formatting = Formatting.Indented
})
</template>
<template id="form-template-address">
@Json.Serialize(FormDataService.StaticFormAddress, new JsonSerializerSettings()
{
Formatting = Formatting.Indented
})
</template>
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">@ViewData["Title"]</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
@if (!isNew)
{
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
}
</div>
</div>
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required/>
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="d-flex align-items-center mb-4 gap-3">
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
<div>
<label asp-for="Public"></label>
<div class="form-text" style="max-width:27rem">
Standalone mode, which can be used to generate invoices
independent of payment requests or apps.
</div>
</div>
</div>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="FormConfig" class="form-label" data-required></label>
<div class="d-flex align-items-center gap-2 mb-2">
<span>Templates:</span>
<button type="button" class="btn btn-link p-0" data-form-template="email">Email</button>
<button type="button" class="btn btn-link p-0" data-form-template="address">Address</button>
</div>
</div>
<textarea asp-for="FormConfig" class="form-control" rows="10" cols="21"></textarea>
<span asp-validation-for="FormConfig" class="text-danger"></span>
</div>
</div>
</div>
</form>

View file

@ -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;
}
<!DOCTYPE html>
<html lang="en" @(env.IsDeveloping ? " data-devenv" : "")>
<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "")>
<head>
<partial name="LayoutHead"/>
<partial name="LayoutHead" />
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
<meta name="robots" content="noindex,nofollow">
</head>
<body class="min-vh-100">
<div class="public-page-wrap flex-column">
<main class="flex-grow-1">
<div class="container" style="max-width:720px;">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) {{"Margin", "mb-4"}})"/>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
}
<partial name="_FormTopMessages" model="@Model.Form"/>
<div class="d-flex flex-column justify-content-center gap-4">
<h1 class="h3 text-center">@ViewData["Title"]</h1>
<div class="bg-tile p-3 p-sm-4 rounded">
<form asp-action="SubmitForm" asp-route-formId="@Model.FormData.Id">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
@if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
{
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
}
else
{
<h1 class="h3 text-center mt-3">@ViewData["Title"]</h1>
}
<main class="flex-grow-1 container" style="max-width:576px">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
}
<partial name="_FormTopMessages" model="@Model.Form" />
<div class="d-flex flex-column justify-content-center gap-4">
<div class="bg-tile p-3 p-sm-4 rounded">
@if (string.IsNullOrEmpty(Model.AspAction))
{
<form method="post" novalidate="novalidate">
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl"/>
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
}
<partial name="_Form" model="@Model.Form"/>
<input type="submit" class="btn btn-primary" name="command" value="Submit"/>
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form>
</div>
}
else
{
<form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
@if (!string.IsNullOrEmpty(Model.RedirectUrl))
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
}
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form>
}
</div>
</div>
</main>
@ -43,6 +62,7 @@
</a>
</footer>
</div>
<partial name="LayoutFoot"/>
<partial name="LayoutFoot" />
<partial name="_ValidationScriptsPartial"/>
</body>
</html>

View file

@ -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);
}

View file

@ -18,6 +18,7 @@
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Webhooks))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Webhooks)" asp-controller="UIStores" asp-action="Webhooks" asp-route-storeId="@storeId">Webhooks</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.PayoutProcessors))" class="nav-link @ViewData.IsActivePage(StoreNavPages.PayoutProcessors)" asp-controller="UIPayoutProcessors" asp-action="ConfigureStorePayoutProcessors" asp-route-storeId="@storeId">Payout Processors</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Emails))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Emails)" asp-controller="UIStores" asp-action="StoreEmailSettings" asp-route-storeId="@storeId">Emails</a>
<a permission="@Policies.CanModifyStoreSettings" id="SectionNav-@(nameof(StoreNavPages.Forms))" class="nav-link @ViewData.IsActivePage(StoreNavPages.Forms)" asp-controller="UIForms" asp-action="FormsList" asp-route-storeId="@storeId">Forms</a>
<vc:ui-extension-point location="store-nav" model="@Model"/>
</div>
</nav>