Compare commits

...

2 Commits

Author SHA1 Message Date
7aa1cd6ac3 Custom Forms 2023-01-24 16:37:37 +01:00
11fd7ecedd Revert "Remove untested and unfinished code"
This reverts commit 54b9b3aaa565612065d641f3a603e12147728905.
2023-01-23 13:56:01 +01:00
20 changed files with 721 additions and 201 deletions

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AngleSharp.Common;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Newtonsoft.Json.Linq;
@ -105,12 +106,17 @@ public class Form
}
}
}
public void ApplyValuesFromForm(IFormCollection form)
public void ApplyValuesFromForm(IFormCollection form, string ignorePrefix = null)
{
var names = GetAllNames();
foreach (var name in names)
{
if (ignorePrefix is not null && name.StartsWith(ignorePrefix))
{
continue;
}
var field = GetFieldByName(name);
if (field is null || !form.TryGetValue(name, out var val))
{
@ -121,6 +127,39 @@ public class Form
}
}
public void SetValues(Dictionary<string, object> values)
{
SetValues(values, null, null);
}
private void SetValues(Dictionary<string, object> values, List<Field> fields = null,string prefix = null)
{
foreach (var v in values)
{
var field = GetFieldByName(v.Key, fields?? Fields, prefix);
if (field is null )
{
continue;
}
if (field.Fields.Any())
{
if (v.Value is Dictionary<string, object> dict)
{
SetValues(dict, field.Fields, field.Name + "_");
}else if (v.Value is JObject jObject)
{
dict = jObject.ToObject<Dictionary<string, object>>();
SetValues(dict, field.Fields, field.Name + "_");
}
}
else
{
field.Value = (string) v.Value;
}
}
}
public Dictionary<string, object> GetValues()
{
return GetValues(Fields);

View File

@ -69,6 +69,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)
{
@ -122,6 +123,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,25 @@ 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; }
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

@ -50,6 +50,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,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20220919105333_FormBuilder")]
public partial class FormBuilder : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Forms",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: true),
StoreId = table.Column<string>(type: "TEXT", nullable: true),
Config = table.Column<string>(type: migrationBuilder.IsNpgsql() ? "JSONB" : "TEXT", nullable: true)
},
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,28 @@ 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<string>("StoreId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("StoreId");
b.ToTable("Forms");
});
modelBuilder.Entity("BTCPayServer.Data.Data.PayoutProcessorData", b =>
{
b.Property<string>("Id")
@ -1129,6 +1151,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 +1551,8 @@ namespace BTCPayServer.Migrations
b.Navigation("CustodianAccounts");
b.Navigation("Forms");
b.Navigation("Invoices");
b.Navigation("LightningAddresses");

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)]
@ -193,7 +197,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)
@ -202,42 +206,33 @@ 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 = prFormId is null ? null : (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 (await FormDataService.Validate(form, ModelState))
{ prBlob.FormResponse = JObject.FromObject(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")]
@ -266,6 +261,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 = result.FormId is null ? null : (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

@ -2,16 +2,34 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Form;
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
{
private readonly ApplicationDbContextFactory _applicationDbContextFactory;
private readonly FormComponentProviders _formProviders;
public FormDataService(
ApplicationDbContextFactory applicationDbContextFactory,
FormComponentProviders formProviders)
{
_applicationDbContextFactory = applicationDbContextFactory;
_formProviders = formProviders;
}
public static readonly Form StaticFormEmail = new()
{
@ -32,4 +50,72 @@ public class FormDataService
Field.Create("Country", "buyerCountry", null, true, null)
}
};
private static 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)
{
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
};
}
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 async Task<bool> Validate(Form form, ModelStateDictionary modelState)
{
return _formProviders.Validate(form, modelState);
}
}

View File

@ -1,13 +1,15 @@
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 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

@ -1,109 +1,196 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
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.Invoices;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
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 FormComponentProviders FormProviders { get; }
public UIFormsController(FormComponentProviders formProviders)
public UIFormsController(FormComponentProviders formProviders, FormDataService formDataService)
{
FormProviders = formProviders;
_formDataService = formDataService;
}
[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() });
}
[HttpPost("~/stores/{storeId}/forms/modify/{id?}")]
public async Task<IActionResult> Modify(string storeId, string? id, ModifyForm modifyForm)
{
if (id is not null)
{
var form = await _formDataService.GetForm(storeId, id);
if (form is null)
{
return NotFound();
}
}
try
{
modifyForm.FormConfig = Form.Parse(modifyForm.FormConfig).ToString();
}
catch (Exception ex)
{
ModelState.AddModelError(nameof(modifyForm.FormConfig), $"Form config was invalid: {ex.Message}");
}
if (!ModelState.IsValid)
{
return View(modifyForm);
}
try
{
var form = new FormData
{
Id = id,
StoreId = storeId,
Name = modifyForm.Name,
Config = modifyForm.FormConfig
};
var isNew = id is null;
await _formDataService.AddOrUpdateForm(form);
TempData.SetStatusMessageModel(new StatusMessageModel
{
Severity = StatusMessageModel.StatusSeverity.Success,
Message = $"Form {(isNew ? "created": "updated")} successfully."
});
if (isNew)
{
return RedirectToAction("Modify", new { storeId, id = form.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 (!IsValidRedirectUri(redirectUrl))
// return BadRequest();
FormData? formData = string.IsNullOrEmpty(formId) ? null : await _formDataService.GetForm(formId);
if (formData == null)
{
return string.IsNullOrEmpty(redirectUrl)
? NotFound()
: Redirect(redirectUrl);
return NotFound();
}
return GetFormView(formData, redirectUrl);
return GetFormView(formData);
}
ViewResult GetFormView(FormData formData, string? redirectUrl)
ViewResult GetFormView(FormData formData)
{
return View("View", new FormViewModel { FormData = formData, RedirectUrl = redirectUrl });
return View("View", new FormViewModel {
FormName = formData.Name,
Form = Form.Parse(formData.Config) });
}
ViewResult GetFormView(Form form, string name)
{
return View("View", new FormViewModel {
FormName = name,
Form = form});
}
[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);
return GetFormView(formData);
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);
var form = Form.Parse(formData.Config);
form.ApplyValuesFromForm(Request.Form);
if (!await _formDataService.Validate(form, ModelState))
return GetFormView(form, formData.Name);
// With redirect, the form comes from another entity that we need to send the data back to
if (!string.IsNullOrEmpty(redirectUrl))
// Create invoice after public form has been filled
var store = await storeRepository.FindStore(formData.StoreId);
if (store is null)
return NotFound();
var amt = form.GetFieldByName("internal_amount")?.Value;
var request = new CreateInvoiceRequest
{
return View("PostRedirect", new PostRedirectViewModel
{
FormUrl = redirectUrl,
FormParameters = form
});
}
return NotFound();
}
internal static FormData? GetFormData(string id)
{
FormData? form = id switch
{
{ } 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
Currency = form.GetFieldByName("internal_currency")?.Value ?? store.GetStoreBlob().DefaultCurrency,
Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture),
Metadata = JObject.FromObject( form.GetValues())
};
return form;
}
var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
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));
return RedirectToAction("Checkout", "UIInvoice", new { invoiceId = inv.Id });
}
}

View File

@ -7,6 +7,7 @@ using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AngleSharp.Common;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Form;
@ -16,6 +17,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;
@ -27,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using NBitpayClient;
using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits;
@ -42,21 +45,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?}")]
@ -117,14 +119,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)
{
@ -226,44 +229,44 @@ 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 = posFormId is null ? null : (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 formValues = formResponseJObject.ToObject<Dictionary<string, object>>();
var form = Form.Parse(formData.Config);
form.SetValues(formValues);
if (!await 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 = JObject.FromObject(form.GetValues());
break;
}
try
{
var invoice = await _invoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest
@ -293,10 +296,10 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{
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,6 +317,76 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
}
[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 = settings.FormId is null ? null : (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 );
return View("Views/UIForms/View", new FormViewModel()
{
Form = Form.Parse(formData.Config),
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 = settings.FormId is null ? null : (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 (await FormDataService.Validate(form, ModelState))
{
return View("PostRedirect", new PostRedirectViewModel
{
FormUrl = viewModel.RedirectUrl,
FormParameters =
{
{ "formResponse", JObject.FromObject(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)

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,9 +1,7 @@
@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 errors = isInvalid ? this.ViewContext.ModelState[Model.Name].Errors : null;
}
<div class="form-group">
@ -20,10 +18,13 @@
</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)"/>
<input class="form-control @(errors is null ? "" : "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>
@foreach (var error in errors)
{
<span class="invalid-feedback">@error.ErrorMessage</span>
}
}
@if (!string.IsNullOrEmpty(Model.HelpText))
{

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

@ -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,77 @@
@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="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

@ -5,7 +5,7 @@
@model BTCPayServer.Forms.Models.FormViewModel
@{
Layout = null;
ViewData["Title"] = Model.FormData.Name;
ViewData["Title"] = Model.FormName;
}
<!DOCTYPE html>
@ -21,20 +21,37 @@
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) {{"Margin", "mb-4"}})"/>
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div asp-validation-summary="ModelOnly" class="invalid-feedback"></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">
@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 class="bg-tile p-3 p-sm-4 rounded">
@if (string.IsNullOrEmpty(Model.AspAction))
{
<form method="post">
@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>
}
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>
</div>

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>