Forms
BarakoCMS.Forms
Lets a public visitor submit a form. The form is a content type: its fields, types and required flags are the form, and a submission is an ordinary entry of that type. A widget reads the form definition and draws the inputs, and a workflow on Created for the type sends the notification.
Install
dotnet add package BarakoCMS.Forms
builder.Services.AddBarakoCMS(builder.Configuration);
var app = builder.Build();
app.UseBarakoCMS();
The package reference plus a restart is the install. BarakoCMS:Modules:Enabled decides which
modules run (BarakoCMS__Modules__Enabled=Forms). To name it by hand, put
modules.Add(new BarakoCMS.Forms.FormsModule()) in the AddBarakoCMS callback. See MODULES.md
in the repository.
Making a content type a form
Create the type as usual, then mark it:
PUT /api/forms/contact-request
Authorization: Bearer <token with manage_forms>
{ "enabled": true }
{ "enabled": false } turns it off again. GET /api/forms lists the types that accept
submissions. Admin is granted manage_forms when the module seeds.
A visitor can fill in a field only when it is Public and its type is one of string, text,
int, decimal, money, bool, date, datetime, time, email, url or choice (and their
aliases). A choice value has to be one of the field's options, and a multiple choice field takes a
list.
A field named slug is never submittable. Enabling a type is refused with 400 when a required field
is not submittable, or when the type is a singleton.
Endpoints
| Method | Route | Who | Purpose |
|---|---|---|---|
| GET | /api/public/forms/{slug} |
anyone | the fields a widget draws |
| POST | /api/public/forms/{slug} |
anyone, rate limited | submit |
| POST | /api/public/forms/{slug}/email-code |
anyone, rate limited | email a one-time code, for a form that verifies an email field |
| PUT | /api/forms/{contentType} |
manage_forms |
mark a type as a form, or unmark it |
| GET | /api/forms |
manage_forms |
list forms, paged |
The slug is the content type name. A type that is not marked as a form answers 404 on both public routes.
Submit
{
"data": { "name": "Ana", "email": "ana@example.com", "message": "Hello" },
"honeypot": "",
"turnstileToken": null
}
- 202
{ "accepted": true }. The entry is stored as Draft, document sensitivity Sensitive, with no owner, so the delivery API never serves it. Nothing in the request can change those three. - 400 ProblemDetails. A field failure is named
data.<field>; an unknown or non-submittable field is refused with the same message either way. A failed Turnstile check is namedturnstileToken. - 404 the slug is not a form.
- 429 too many submissions from this client IP.
A non-empty honeypot gets the same 202 and nothing is stored.
Verifying an email field
A form can refuse a submission until the visitor has proved they read mail at the address in one of its email fields. It is off unless the form turns it on, and a form that has not behaves as above.
PUT /api/forms/race-signup
Authorization: Bearer <token with manage_forms>
{ "enabled": true, "verifyEmailField": "email" }
verifyEmailField has to name an email field a visitor can fill in, or the request is refused with
400. Left out or null, the form keeps what it has; an empty string turns verification off. Turning
the form itself off and on again keeps the setting too, so a console that does not know the field
cannot drop verification by toggling the form. If the field has stopped being an email field in the
meantime, turning the form on is refused with 400 until the request says what to verify.
GET /api/forms and the form definition both report verifyEmailField, and the definition marks
that field required whatever the type says.
The visitor asks for a code, then sends it with the submission:
POST /api/public/forms/race-signup/email-code
{ "email": "ana@example.com", "honeypot": "", "turnstileToken": null }
- 202
{ "accepted": true }. The same answer whether the mail went out, the provider refused it or the provider did not answer withinSendTimeoutSeconds, and for a filled honeypot, which sends nothing. A failed send is logged with the tenant, the form and the kind of failure, never the address. - 400 the address is not one bare mailbox (see below), or the Turnstile check failed.
- 404 the slug is not a form, or the form does not verify an email field.
- 429 past a limit, see below. Nothing is stored or sent.
{
"data": { "name": "Ana", "email": "ana@example.com" },
"emailVerificationCode": "482913"
}
The submission is accepted only with the live code for the address in the verified field. Any other
case is a 400 named emailVerificationCode with one message, whether the code is wrong, expired,
already used, guessed at too often, sent to another address, sent for another form or never sent. A
missing address is a 400 named data.<field>. The code is checked last, after every other check
has passed, so a submission that fails for another reason does not use up an attempt.
Both routes take the address only as one bare mailbox: ASCII letters, digits and
.!#$%&'*+/=?^_`{|}~- before the @ (at most 64 characters, no dot first, last or doubled), a
dotted host name of letters, digits and hyphens after it, and at most 254 characters in all. A
display name (Ana <ana@example.com>), a comment, quotes, a trailing dot, an address literal and
anything outside ASCII are refused with 400, on the code route as email and on submit as
data.<field>. The email field type itself is looser, and a mail library reads several of those
spellings as the same mailbox, so without this one mailbox could be sent a fresh five codes under
each spelling. An alias the mail provider resolves, such as a plus tag, is still a different address
here.
What a code is:
- six digits from a cryptographic random source, stored only as a BCrypt hash
- good for 10 minutes and for one accepted submission
- dead after 5 checks, right or wrong, counted before the comparison and under a lock per address
- bound to the tenant, the form and the address it was sent to. The address is trimmed and lowercased the same way when the code is sent and when it is checked.
- replaced by the next code sent to the same address, on any form of the tenant
A refusal with no live code runs the same hash comparison as a wrong guess, but a wrong guess also writes the attempt, so the two can differ by a few milliseconds. Someone who measures that learns whether a code is outstanding for an address, and each such probe uses one of that code's five attempts.
An accepted submission adds an audit event, form.email.verified, whose target is the entry. It
holds the form and the field name, not the address and not the code. These come from anonymous
traffic, one per accepted submission, and the audit chain can fork when two are written at the same
moment, as it can for any two audited actions.
Limits on sending, each answered with 429:
| Limit | Default | Counted |
|---|---|---|
| per client IP | 5 per 600 seconds | in memory, across every form |
| per address | 5 per 60 minutes | stored, per tenant, across every form |
| per form | 100 per 60 minutes | stored, per tenant, across every address |
So one form sends at most 100 messages an hour whatever addresses and IPs a caller cycles through, and one address gets at most 5 an hour from a tenant. Past the per form limit nobody can get a code for that form until the window moves, real visitors included, so raise it for a busy sign-up and turn Turnstile on, which this route checks too when it is enabled. The per address 429 tells a caller that five codes were asked for at that address in the last hour. No other answer on this route depends on the address.
The limits can be turned on a known address. Five requests in an hour stop that address getting another code until the window moves, and five wrong submissions kill its live code. Either costs the caller their own per client budget, and Turnstile when it is on.
The per client limit counts the address the connection comes from. Behind a proxy with forwarded headers not configured, every visitor shares one budget, as they already do for submissions.
The mail is built from the form's display name and the code. Nothing from the request goes into it.
Two tables hold this: form_email_verifications, one row per address, and form_email_budgets, one
row per form, which also remembers the verified field while the form is off. The address row's id
is a SHA-256 of the address with no key and no salt. That keeps addresses out of a plain read of the
table. It does not stop someone with database access testing whether a given address has a row,
which includes an address that asked for a code and never submitted. Neither table has an index:
rows are loaded by id, and the cleanup reads one tenant's rows by time on every send, a scan the
limits above keep small. Rows whose code and window have both passed are removed, up to 200 at a
time, whenever a code is sent in the same tenant. Nothing removes them in a tenant where no code is
asked for again.
Definition
{
"slug": "contact-request",
"displayName": "Contact request",
"description": "",
"fields": [
{ "name": "email", "displayName": "Email", "type": "email", "required": true, "validationRules": {}, "options": [], "multiple": false },
{ "name": "topic", "displayName": "Topic", "type": "choice", "required": true, "validationRules": {},
"options": [ { "value": "sales", "label": "Sales" }, { "value": "support", "label": "Support" } ],
"multiple": false }
]
}
options lists a choice field's options in display order and is empty for any other type.
multiple says whether the field takes a list.
Every field also carries currency and scale. Both are null except on a money field that
declares a currency, where currency is its ISO 4217 code and scale is the most decimal places an
amount may carry: the field's own if it declares one, the currency's otherwise. Such a field takes
the amount as a JSON number or as the plain decimal text an input holds ("12.50"), and stores a
number either way. An amount with more decimal places than scale, or text with a thousands
separator, a currency symbol or an exponent, is a 400. See docs/money-fields.md in the core
repository.
Configuration
Section Modules:Forms:
| Key | Default | Meaning |
|---|---|---|
PermitLimit |
5 |
submissions per client IP per window, across every form |
WindowSeconds |
600 |
the window |
PerForm:{slug}:PermitLimit |
the shared PermitLimit |
submissions per client IP per window to that one form |
PerForm:{slug}:WindowSeconds |
the shared WindowSeconds |
the window for that form |
MaxFieldLength |
10000 |
the longest string a field may hold |
Turnstile:Enabled |
false |
require a Cloudflare Turnstile token |
Turnstile:SecretKey |
none | the Turnstile secret; set it through the environment |
EmailVerification:CodeLifetimeMinutes |
10 |
how long a code works |
EmailVerification:MaxAttempts |
5 |
checks one code survives |
EmailVerification:RequestsPerClient |
5 |
code requests per client IP per window, across every form |
EmailVerification:RequestWindowSeconds |
600 |
that window |
EmailVerification:CodesPerAddress |
5 |
codes sent to one address per window, per tenant |
EmailVerification:CodesPerForm |
100 |
codes one form sends per window |
EmailVerification:WindowMinutes |
60 |
the window for the two limits above |
EmailVerification:SendTimeoutSeconds |
10 |
how long a code request waits for the email provider |
With Turnstile enabled and no secret set, every submission is refused and an error is logged. An
EmailVerification value below 1 stops the host at startup with an error naming the setting: a
limit cannot be turned off by setting it to zero. The send deadline works by cancelling the
provider, so it holds for a provider that honours cancellation, which the Resend and SMTP modules
do.
A limit for one form
No form has its own limit unless PerForm names it. A busy form can be given one:
- Modules__Forms__PerForm__registration__PermitLimit=20
- Modules__Forms__PerForm__registration__WindowSeconds=60
Submissions to registration are then counted per client IP against those numbers, and no longer
against the shared limit. Every other form stays on the shared one. The limit applies to the submit
route only; the definition route is under the API's global limit, as before. The email code route
has a policy of its own, forms-email-code, per client IP across every form with the
EmailVerification:RequestsPerClient numbers, and PerForm does not change it: what bounds one
form's sending is EmailVerification:CodesPerForm.
A value left out takes the shared one as configured. A value that is not a whole number above zero
stops the host at startup with the setting named. A slug with a dash cannot be exported as an
environment variable from a POSIX shell; set it in appsettings.json or in the compose file.
The slug is matched in any case, and a request for another spelling of it is counted in the same
bucket. The tenant is not part of the bucket, because a caller chooses X-Tenant and the host: two
tenants with a form of the same slug share the numbers, and one client IP has one count across both.
FormRateLimitTests covers this.
A limit for another route of this module
A new route gets its own policy the way submit does: register it in FormsModule.ConfigureServices
with services.Configure<RateLimiterOptions>(o => o.AddPolicy("forms-<purpose>", ...)), read its
numbers from FormsOptions, and name it on the endpoint with RequireRateLimiting. Key it on
something the caller cannot vary per request, such as the client IP or a configured slug. The
limiter runs before the body is read, so a value from the body (an email address, say) cannot be a
key there; count that inside the endpoint. Do not take a name the core reserves (auth,
telemetry, registration, site-share, logout, delivery).
Notification
Not this module's job. Add a workflow on Created for the form's content type with an Email
action. The submission is written through IContentWriter like any other entry, so the workflow
fires for it.
If barakoCMS is useful to you, a star on the repository helps other people find it.
License
MIT © BaryoDev