mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-05 16:02:48 +00:00
558 lines
20 KiB
Go
558 lines
20 KiB
Go
package models
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
)
|
|
|
|
// Form is a hosted lead-capture form: an ordered field list plus a design
|
|
// theme, published at a public URL and embeddable on any website. A
|
|
// submission creates or updates a contact, files it under the form's
|
|
// categories and optionally adds it to a campaign.
|
|
type Form struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
|
// PublicID is the unguessable token in the public URL and embed codes.
|
|
PublicID string `json:"public_id"`
|
|
Name string `json:"name"`
|
|
Status FormStatus `json:"status"`
|
|
Fields []FormField `json:"fields"`
|
|
Design FormDesign `json:"design"`
|
|
SuccessMessage string `json:"success_message"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
|
CategoryIDs []uuid.UUID `json:"category_ids"`
|
|
AllowedDomains []string `json:"allowed_domains"`
|
|
CaptchaEnabled bool `json:"captcha_enabled"`
|
|
// LogoURL, CoverURL and BackgroundURL are uploaded brand assets (public
|
|
// object URLs).
|
|
LogoURL string `json:"logo_url"`
|
|
CoverURL string `json:"cover_url"`
|
|
BackgroundURL string `json:"background_url"`
|
|
|
|
ViewsCount int64 `json:"views_count"`
|
|
SubmissionsCount int64 `json:"submissions_count"`
|
|
LastSubmissionAt *time.Time `json:"last_submission_at,omitempty"`
|
|
PublishedAt *time.Time `json:"published_at,omitempty"`
|
|
|
|
// Event-derived aggregates, populated by List only (nil Trend elsewhere).
|
|
StartsCount int64 `json:"starts_count"`
|
|
IdentifiedCount int64 `json:"identified_count"`
|
|
Trend []int64 `json:"trend,omitempty"`
|
|
|
|
// ShareURL is the hosted page URL, derived from config on read.
|
|
ShareURL string `json:"share_url,omitempty"`
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// FormStatus is the lifecycle of a form. Only published forms render and
|
|
// accept submissions; archiving keeps the data but takes the page offline.
|
|
type FormStatus string
|
|
|
|
const (
|
|
FormStatusDraft FormStatus = "draft"
|
|
FormStatusPublished FormStatus = "published"
|
|
FormStatusArchived FormStatus = "archived"
|
|
)
|
|
|
|
// Valid reports whether the value is one the database accepts.
|
|
func (s FormStatus) Valid() bool {
|
|
switch s {
|
|
case FormStatusDraft, FormStatusPublished, FormStatusArchived:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// FormFieldType is one kind of builder block. Input types collect a value;
|
|
// layout types (heading, paragraph, divider) only render.
|
|
type FormFieldType string
|
|
|
|
const (
|
|
FormFieldText FormFieldType = "text"
|
|
FormFieldEmail FormFieldType = "email"
|
|
FormFieldPhone FormFieldType = "phone"
|
|
FormFieldTextarea FormFieldType = "textarea"
|
|
FormFieldNumber FormFieldType = "number"
|
|
FormFieldSelect FormFieldType = "select"
|
|
FormFieldRadio FormFieldType = "radio"
|
|
FormFieldCheckboxes FormFieldType = "checkboxes"
|
|
FormFieldCheckbox FormFieldType = "checkbox"
|
|
FormFieldDate FormFieldType = "date"
|
|
FormFieldHidden FormFieldType = "hidden"
|
|
FormFieldHeading FormFieldType = "heading"
|
|
FormFieldParagraph FormFieldType = "paragraph"
|
|
FormFieldDivider FormFieldType = "divider"
|
|
// FormFieldPageBreak splits the form into pages; its label is the page title.
|
|
FormFieldPageBreak FormFieldType = "page_break"
|
|
)
|
|
|
|
// Valid reports whether the type is a known block.
|
|
func (t FormFieldType) Valid() bool {
|
|
switch t {
|
|
case FormFieldText, FormFieldEmail, FormFieldPhone, FormFieldTextarea, FormFieldNumber,
|
|
FormFieldSelect, FormFieldRadio, FormFieldCheckboxes, FormFieldCheckbox, FormFieldDate,
|
|
FormFieldHidden, FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// IsInput reports whether the block collects a value on submit.
|
|
func (t FormFieldType) IsInput() bool {
|
|
switch t {
|
|
case FormFieldHeading, FormFieldParagraph, FormFieldDivider, FormFieldPageBreak:
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// HasOptions reports whether the block needs an options list.
|
|
func (t FormFieldType) HasOptions() bool {
|
|
return t == FormFieldSelect || t == FormFieldRadio || t == FormFieldCheckboxes
|
|
}
|
|
|
|
// FormField is one block on the form. ID is a builder-generated slug, stable
|
|
// across edits; submissions key their answers by it.
|
|
type FormField struct {
|
|
ID string `json:"id"`
|
|
Type FormFieldType `json:"type"`
|
|
Label string `json:"label"`
|
|
Placeholder string `json:"placeholder,omitempty"`
|
|
HelpText string `json:"help_text,omitempty"`
|
|
Required bool `json:"required"`
|
|
Options []string `json:"options,omitempty"`
|
|
// MapTo names the contact column the answer fills (first_name, last_name,
|
|
// email, company, phone). Empty means the answer lands in the contact's
|
|
// custom fields under the field label.
|
|
MapTo string `json:"map_to,omitempty"`
|
|
// Value is the constant a hidden field submits, or the body text of a
|
|
// paragraph block.
|
|
Value string `json:"value,omitempty"`
|
|
// Width is full or half; two half fields share a row.
|
|
Width string `json:"width,omitempty"`
|
|
Rows int `json:"rows,omitempty"`
|
|
}
|
|
|
|
// FormDesign is the theme the builder's Design panel edits. Unset values fall
|
|
// back to the defaults in NormalizeFormDesign, so an older row still renders.
|
|
type FormDesign struct {
|
|
FontFamily string `json:"font_family,omitempty"`
|
|
PageBackground string `json:"page_background,omitempty"`
|
|
FormBackground string `json:"form_background,omitempty"`
|
|
TextColor string `json:"text_color,omitempty"`
|
|
LabelColor string `json:"label_color,omitempty"`
|
|
InputBackground string `json:"input_background,omitempty"`
|
|
InputBorderColor string `json:"input_border_color,omitempty"`
|
|
InputTextColor string `json:"input_text_color,omitempty"`
|
|
PlaceholderColor string `json:"placeholder_color,omitempty"`
|
|
AccentColor string `json:"accent_color,omitempty"`
|
|
ButtonBackground string `json:"button_background,omitempty"`
|
|
ButtonTextColor string `json:"button_text_color,omitempty"`
|
|
ButtonText string `json:"button_text,omitempty"`
|
|
ButtonSize string `json:"button_size,omitempty"`
|
|
ButtonFullWidth bool `json:"button_full_width,omitempty"`
|
|
BorderRadius *int `json:"border_radius,omitempty"`
|
|
MaxWidth *int `json:"max_width,omitempty"`
|
|
Spacing string `json:"spacing,omitempty"`
|
|
Shadow *bool `json:"shadow,omitempty"`
|
|
// Theme records which preset seeded the current colors; renderers never
|
|
// read it, so new presets need no backend release.
|
|
Theme string `json:"theme,omitempty"`
|
|
// Layout is card (centered box), wide (no box) or split (cover panel).
|
|
Layout string `json:"layout,omitempty"`
|
|
// Mode is classic (pages of fields) or focus (one question per screen).
|
|
Mode string `json:"mode,omitempty"`
|
|
// PageBackgroundEnd turns the page background into a vertical gradient.
|
|
PageBackgroundEnd string `json:"page_background_end,omitempty"`
|
|
Align string `json:"align,omitempty"`
|
|
ShowProgress *bool `json:"show_progress,omitempty"`
|
|
|
|
// BackgroundSize and BackgroundOverlay style the uploaded background_url:
|
|
// the overlay (0-100) veils the image with the page color so text on top
|
|
// stays legible.
|
|
BackgroundSize string `json:"background_size,omitempty"`
|
|
BackgroundOverlay *int `json:"background_overlay,omitempty"`
|
|
|
|
// A bar carrying the logo and a title: whether it spans the page or sits
|
|
// with the form, how its contents align, and whether it sticks on scroll.
|
|
HeaderEnabled *bool `json:"header_enabled,omitempty"`
|
|
HeaderTitle string `json:"header_title,omitempty"`
|
|
HeaderBackground string `json:"header_background,omitempty"`
|
|
HeaderPlacement string `json:"header_placement,omitempty"`
|
|
HeaderAlign string `json:"header_align,omitempty"`
|
|
HeaderSticky *bool `json:"header_sticky,omitempty"`
|
|
HeaderShowLogo *bool `json:"header_show_logo,omitempty"`
|
|
|
|
// Copy over the split layout's cover panel.
|
|
CoverTitle string `json:"cover_title,omitempty"`
|
|
CoverSubtitle string `json:"cover_subtitle,omitempty"`
|
|
|
|
// How the uploaded logo renders: its height, and whether it sits on the
|
|
// form surface or above the card on the page background.
|
|
LogoSize string `json:"logo_size,omitempty"`
|
|
LogoPosition string `json:"logo_position,omitempty"`
|
|
}
|
|
|
|
// FormWrite is the PATCH payload; every field is optional.
|
|
type FormWrite struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Status *FormStatus `json:"status,omitempty"`
|
|
Fields *[]FormField `json:"fields,omitempty"`
|
|
Design *FormDesign `json:"design,omitempty"`
|
|
SuccessMessage *string `json:"success_message,omitempty"`
|
|
RedirectURL *string `json:"redirect_url,omitempty"`
|
|
CampaignID NullableUUID `json:"campaign_id"`
|
|
CategoryIDs *[]uuid.UUID `json:"category_ids,omitempty"`
|
|
AllowedDomains *[]string `json:"allowed_domains,omitempty"`
|
|
CaptchaEnabled *bool `json:"captcha_enabled,omitempty"`
|
|
}
|
|
|
|
// FormSubmission is one public submit, kept verbatim. Data is keyed by field
|
|
// id; checkbox groups store a string slice, everything else a string.
|
|
type FormSubmission struct {
|
|
ID uuid.UUID `json:"id"`
|
|
FormID uuid.UUID `json:"form_id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
ContactID *uuid.UUID `json:"contact_id,omitempty"`
|
|
// CampaignID is the campaign whose email carried the personalized link
|
|
// the visitor arrived through, when there was one.
|
|
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
|
|
Data map[string]any `json:"data"`
|
|
SourceURL string `json:"source_url"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Contact and campaign summaries for the submissions table, populated by
|
|
// reads.
|
|
ContactEmail string `json:"contact_email,omitempty"`
|
|
ContactName string `json:"contact_name,omitempty"`
|
|
CampaignName string `json:"campaign_name,omitempty"`
|
|
}
|
|
|
|
// Form limits.
|
|
const (
|
|
FormMaxNameLen = 120
|
|
FormsPerOrgMax = 100
|
|
FormMaxFields = 60
|
|
FormMaxLabelLen = 200
|
|
FormMaxTextLen = 2000
|
|
FormMaxOptions = 50
|
|
FormMaxOptionLen = 100
|
|
FormMaxDomains = 20
|
|
FormMaxAnswerLen = 5000
|
|
FormMaxCategories = 20
|
|
FormMinBorderRadius = 0
|
|
FormMaxBorderRadius = 24
|
|
FormMinWidth = 320
|
|
FormMaxWidth = 960
|
|
FormDefaultWidth = 560
|
|
FormDefaultSuccessMsg = "Thanks! Your submission has been received."
|
|
)
|
|
|
|
var (
|
|
formFieldIDRE = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,39}$`)
|
|
formHexRE = regexp.MustCompile(`^#[a-fA-F0-9]{6}$`)
|
|
formThemeRE = regexp.MustCompile(`^[a-z0-9-]{0,40}$`)
|
|
)
|
|
|
|
// FormContactColumns is every contact column a field may map to.
|
|
var FormContactColumns = map[string]bool{
|
|
"first_name": true,
|
|
"last_name": true,
|
|
"email": true,
|
|
"company": true,
|
|
"phone": true,
|
|
}
|
|
|
|
// ValidateFormName trims and bounds the internal name.
|
|
func ValidateFormName(name string) (string, *errx.Error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", errx.New(errx.BadRequest, "form name is required")
|
|
}
|
|
if len(name) > FormMaxNameLen {
|
|
return "", errx.New(errx.BadRequest, fmt.Sprintf("form name must be at most %d characters", FormMaxNameLen))
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// ValidateFormFields checks and normalizes the field list in place.
|
|
func ValidateFormFields(fields []FormField) *errx.Error {
|
|
if len(fields) > FormMaxFields {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("at most %d fields per form", FormMaxFields))
|
|
}
|
|
seenID := make(map[string]bool, len(fields))
|
|
seenMap := make(map[string]bool, len(fields))
|
|
for i := range fields {
|
|
f := &fields[i]
|
|
f.ID = strings.TrimSpace(f.ID)
|
|
f.Label = strings.TrimSpace(f.Label)
|
|
f.Placeholder = strings.TrimSpace(f.Placeholder)
|
|
f.HelpText = strings.TrimSpace(f.HelpText)
|
|
f.MapTo = strings.TrimSpace(f.MapTo)
|
|
f.Value = strings.TrimSpace(f.Value)
|
|
at := fmt.Sprintf("field %d", i+1)
|
|
if !formFieldIDRE.MatchString(f.ID) {
|
|
return errx.New(errx.BadRequest, at+": id must be a short lowercase slug")
|
|
}
|
|
if seenID[f.ID] {
|
|
return errx.New(errx.BadRequest, at+": duplicate id "+f.ID)
|
|
}
|
|
seenID[f.ID] = true
|
|
if !f.Type.Valid() {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown type %q", at, f.Type))
|
|
}
|
|
if len(f.Label) > FormMaxLabelLen {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: label must be at most %d characters", at, FormMaxLabelLen))
|
|
}
|
|
if len(f.Placeholder) > FormMaxLabelLen || len(f.HelpText) > FormMaxLabelLen {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: placeholder and help text must be at most %d characters", at, FormMaxLabelLen))
|
|
}
|
|
if len(f.Value) > FormMaxTextLen {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: text must be at most %d characters", at, FormMaxTextLen))
|
|
}
|
|
if f.Type.IsInput() && f.Type != FormFieldHidden && f.Label == "" {
|
|
return errx.New(errx.BadRequest, at+": a label is required")
|
|
}
|
|
if f.Type.HasOptions() {
|
|
if len(f.Options) == 0 {
|
|
return errx.New(errx.BadRequest, at+": at least one option is required")
|
|
}
|
|
if len(f.Options) > FormMaxOptions {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: at most %d options", at, FormMaxOptions))
|
|
}
|
|
seenOpt := make(map[string]bool, len(f.Options))
|
|
for j, opt := range f.Options {
|
|
opt = strings.TrimSpace(opt)
|
|
f.Options[j] = opt
|
|
if opt == "" || len(opt) > FormMaxOptionLen {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: options must be 1 to %d characters", at, FormMaxOptionLen))
|
|
}
|
|
if seenOpt[opt] {
|
|
return errx.New(errx.BadRequest, at+": duplicate option "+opt)
|
|
}
|
|
seenOpt[opt] = true
|
|
}
|
|
} else {
|
|
f.Options = nil
|
|
}
|
|
if f.MapTo != "" {
|
|
if !f.Type.IsInput() {
|
|
return errx.New(errx.BadRequest, at+": only input fields can map to a contact column")
|
|
}
|
|
if !FormContactColumns[f.MapTo] {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: unknown contact column %q", at, f.MapTo))
|
|
}
|
|
if seenMap[f.MapTo] {
|
|
return errx.New(errx.BadRequest, fmt.Sprintf("%s: another field already maps to %s", at, f.MapTo))
|
|
}
|
|
seenMap[f.MapTo] = true
|
|
}
|
|
if f.Type == FormFieldEmail {
|
|
f.MapTo = "email"
|
|
}
|
|
if f.Width != "" && f.Width != "full" && f.Width != "half" {
|
|
return errx.New(errx.BadRequest, at+": width must be full or half")
|
|
}
|
|
if f.Rows < 0 || f.Rows > 20 {
|
|
return errx.New(errx.BadRequest, at+": rows must be between 0 and 20")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FormEmailField returns the field that fills the contact email, if any.
|
|
func FormEmailField(fields []FormField) *FormField {
|
|
for i := range fields {
|
|
if fields[i].MapTo == "email" || fields[i].Type == FormFieldEmail {
|
|
return &fields[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ValidateFormDesign checks colors and enums; NormalizeFormDesign clamps the
|
|
// numeric knobs and fills defaults.
|
|
func ValidateFormDesign(d *FormDesign) *errx.Error {
|
|
for _, c := range []string{
|
|
d.PageBackground, d.FormBackground, d.TextColor, d.LabelColor, d.InputBackground,
|
|
d.InputBorderColor, d.InputTextColor, d.PlaceholderColor, d.AccentColor,
|
|
d.ButtonBackground, d.ButtonTextColor, d.PageBackgroundEnd, d.HeaderBackground,
|
|
} {
|
|
if c != "" && !formHexRE.MatchString(c) && c != "transparent" {
|
|
return errx.New(errx.BadRequest, "design colors must be #rrggbb values")
|
|
}
|
|
}
|
|
// The list must match FONT_CATALOG in the mirrored designCore.ts.
|
|
switch d.FontFamily {
|
|
case "", "system", "inter", "serif", "mono", "manrope", "sora", "fraunces", "space-grotesk":
|
|
default:
|
|
return errx.New(errx.BadRequest, "font_family is not a supported font")
|
|
}
|
|
switch d.Layout {
|
|
case "", "card", "wide", "split":
|
|
default:
|
|
return errx.New(errx.BadRequest, "layout must be card, wide or split")
|
|
}
|
|
switch d.Mode {
|
|
case "", "classic", "focus":
|
|
default:
|
|
return errx.New(errx.BadRequest, "mode must be classic or focus")
|
|
}
|
|
switch d.Align {
|
|
case "", "left", "center":
|
|
default:
|
|
return errx.New(errx.BadRequest, "align must be left or center")
|
|
}
|
|
switch d.HeaderPlacement {
|
|
case "", "page", "inline":
|
|
default:
|
|
return errx.New(errx.BadRequest, "header_placement must be page or inline")
|
|
}
|
|
switch d.HeaderAlign {
|
|
case "", "left", "center", "between":
|
|
default:
|
|
return errx.New(errx.BadRequest, "header_align must be left, center or between")
|
|
}
|
|
switch d.LogoSize {
|
|
case "", "sm", "md", "lg":
|
|
default:
|
|
return errx.New(errx.BadRequest, "logo_size must be sm, md or lg")
|
|
}
|
|
switch d.LogoPosition {
|
|
case "", "card", "page":
|
|
default:
|
|
return errx.New(errx.BadRequest, "logo_position must be card or page")
|
|
}
|
|
switch d.BackgroundSize {
|
|
case "", "cover", "contain", "tile":
|
|
default:
|
|
return errx.New(errx.BadRequest, "background_size must be cover, contain or tile")
|
|
}
|
|
if d.BackgroundOverlay != nil && (*d.BackgroundOverlay < 0 || *d.BackgroundOverlay > 100) {
|
|
return errx.New(errx.BadRequest, "background_overlay must be between 0 and 100")
|
|
}
|
|
for _, t := range []string{d.HeaderTitle, d.CoverTitle} {
|
|
if len(t) > FormMaxLabelLen {
|
|
return errx.New(errx.BadRequest, "title is too long")
|
|
}
|
|
}
|
|
if len(d.CoverSubtitle) > FormMaxTextLen {
|
|
return errx.New(errx.BadRequest, "cover subtitle is too long")
|
|
}
|
|
if !formThemeRE.MatchString(d.Theme) {
|
|
return errx.New(errx.BadRequest, "theme must be a short lowercase slug")
|
|
}
|
|
switch d.ButtonSize {
|
|
case "", "sm", "md", "lg":
|
|
default:
|
|
return errx.New(errx.BadRequest, "button_size must be sm, md or lg")
|
|
}
|
|
switch d.Spacing {
|
|
case "", "compact", "normal", "relaxed":
|
|
default:
|
|
return errx.New(errx.BadRequest, "spacing must be compact, normal or relaxed")
|
|
}
|
|
if len(d.ButtonText) > FormMaxLabelLen {
|
|
return errx.New(errx.BadRequest, "button text is too long")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NormalizeFormDesign clamps numbers into range. String defaults are applied
|
|
// at render time so a stored row stays sparse.
|
|
func NormalizeFormDesign(d *FormDesign) {
|
|
if d.BorderRadius != nil {
|
|
v := clampInt(*d.BorderRadius, FormMinBorderRadius, FormMaxBorderRadius)
|
|
d.BorderRadius = &v
|
|
}
|
|
if d.MaxWidth != nil {
|
|
v := clampInt(*d.MaxWidth, FormMinWidth, FormMaxWidth)
|
|
d.MaxWidth = &v
|
|
}
|
|
d.ButtonText = strings.TrimSpace(d.ButtonText)
|
|
}
|
|
|
|
func clampInt(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|
|
|
|
// ValidateFormRedirectURL accepts an empty value or an absolute http(s) URL.
|
|
func ValidateFormRedirectURL(raw string) (string, *errx.Error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return "", nil
|
|
}
|
|
u, err := url.Parse(raw)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
|
return "", errx.New(errx.BadRequest, "redirect URL must be an absolute http(s) URL")
|
|
}
|
|
if len(raw) > 2048 {
|
|
return "", errx.New(errx.BadRequest, "redirect URL is too long")
|
|
}
|
|
return raw, nil
|
|
}
|
|
|
|
// ValidateFormDomains normalizes the embed allowlist to bare lowercase hosts.
|
|
// A host admits its subdomains, mirroring website tracking.
|
|
func ValidateFormDomains(domains []string) ([]string, *errx.Error) {
|
|
if len(domains) > FormMaxDomains {
|
|
return nil, errx.New(errx.BadRequest, fmt.Sprintf("at most %d allowed domains", FormMaxDomains))
|
|
}
|
|
out := make([]string, 0, len(domains))
|
|
seen := make(map[string]bool, len(domains))
|
|
for _, d := range domains {
|
|
h := strings.ToLower(strings.TrimSpace(d))
|
|
h = strings.TrimPrefix(h, "https://")
|
|
h = strings.TrimPrefix(h, "http://")
|
|
if i := strings.IndexAny(h, "/?#"); i >= 0 {
|
|
h = h[:i]
|
|
}
|
|
h = strings.TrimSuffix(h, ".")
|
|
if h == "" {
|
|
continue
|
|
}
|
|
if len(h) > 253 || strings.ContainsAny(h, " \t@") {
|
|
return nil, errx.New(errx.BadRequest, fmt.Sprintf("%q is not a valid domain", d))
|
|
}
|
|
if !seen[h] {
|
|
seen[h] = true
|
|
out = append(out, h)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FormDomainAllowed reports whether host is covered by the allowlist. An
|
|
// empty list allows everything.
|
|
func FormDomainAllowed(allowed []string, host string) bool {
|
|
if len(allowed) == 0 {
|
|
return true
|
|
}
|
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
|
if h, _, ok := strings.Cut(host, ":"); ok {
|
|
host = h
|
|
}
|
|
for _, a := range allowed {
|
|
if host == a || strings.HasSuffix(host, "."+a) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|