@@ -0,0 +1,258 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/mail"
|
||||
)
|
||||
|
||||
const (
|
||||
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
|
||||
perIPLimit = 5
|
||||
perIPWindow = 10 * time.Minute
|
||||
)
|
||||
|
||||
// Server backs the marketing site's two forms: contact, which is emailed and
|
||||
// never stored, and signup, which provisions an organisation and its owner
|
||||
// after the address has been verified.
|
||||
type Server struct {
|
||||
mail mail.Config
|
||||
limiter *limiter
|
||||
signups *limiter
|
||||
allowOrigin map[string]bool
|
||||
trustProxy bool
|
||||
publicURL string
|
||||
appLoginURL string
|
||||
}
|
||||
|
||||
func New(mailCfg mail.Config) *Server {
|
||||
return &Server{
|
||||
mail: mailCfg,
|
||||
limiter: newLimiter(perIPLimit, perIPWindow),
|
||||
signups: newLimiter(signupPerIPLimit, signupPerIPWindow),
|
||||
allowOrigin: parseOrigins(os.Getenv("SITE_ORIGIN")),
|
||||
trustProxy: os.Getenv("TRUST_PROXY") == "true",
|
||||
publicURL: os.Getenv("PUBLIC_URL"),
|
||||
appLoginURL: os.Getenv("APP_LOGIN_URL"),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/contact", s.handleContact)
|
||||
mux.HandleFunc("POST /api/signup", s.handleSignup)
|
||||
// Opened from an email client, so it is a GET that renders a page.
|
||||
mux.HandleFunc("GET /api/verify", s.handleVerify)
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
})
|
||||
return s.withCORS(mux)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- middleware
|
||||
|
||||
func parseOrigins(raw string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, o := range strings.Split(raw, ",") {
|
||||
if o = strings.TrimSpace(o); o != "" {
|
||||
out[o] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withCORS reflects only origins named in SITE_ORIGIN. It never answers with a
|
||||
// wildcard: this endpoint sends mail, and an unset SITE_ORIGIN should fail
|
||||
// closed for cross-origin callers rather than open to every site on the
|
||||
// internet.
|
||||
func (s *Server) withCORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && s.allowOrigin[origin] {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
w.Header().Set("Access-Control-Max-Age", "600")
|
||||
}
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP prefers the left-most X-Forwarded-For entry, but only when the
|
||||
// service is explicitly told it sits behind a proxy. Trusting the header
|
||||
// unconditionally would let any caller spoof its way past the rate limiter.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if s.trustProxy {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if first, _, ok := strings.Cut(xff, ","); ok {
|
||||
return strings.TrimSpace(first)
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- handler
|
||||
|
||||
type contactBody struct {
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
Servers string `json:"servers"`
|
||||
Topic string `json:"topic"`
|
||||
Message string `json:"message"`
|
||||
Website string `json:"website"` // honeypot: real people leave this empty
|
||||
}
|
||||
|
||||
var (
|
||||
serverBands = []string{"1–3", "4–25", "26–100", "More than 100"}
|
||||
topics = []string{
|
||||
"Evaluating Vantage",
|
||||
"Self-hosted licensing",
|
||||
"Migrating from something else",
|
||||
"Security disclosure",
|
||||
}
|
||||
)
|
||||
|
||||
func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
|
||||
var body contactBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer exactly as we would on success so it
|
||||
// learns nothing, and send nothing.
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
|
||||
return
|
||||
}
|
||||
|
||||
fields := map[string]string{}
|
||||
var problems []fieldError
|
||||
|
||||
if v, err := text("name", body.Name, true, maxShort); err != nil {
|
||||
problems = append(problems, *err)
|
||||
} else {
|
||||
fields["name"] = v
|
||||
}
|
||||
|
||||
if v, err := text("message", body.Message, true, maxLong); err != nil {
|
||||
problems = append(problems, *err)
|
||||
} else {
|
||||
fields["message"] = v
|
||||
}
|
||||
|
||||
addr, emailErr := email("email", body.Email)
|
||||
if emailErr != nil {
|
||||
problems = append(problems, *emailErr)
|
||||
}
|
||||
|
||||
if v, err := oneOf("servers", body.Servers, serverBands); err != nil {
|
||||
problems = append(problems, *err)
|
||||
} else {
|
||||
fields["servers"] = v
|
||||
}
|
||||
|
||||
if v, err := oneOf("topic", body.Topic, topics); err != nil {
|
||||
problems = append(problems, *err)
|
||||
} else {
|
||||
fields["topic"] = v
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
sort.Slice(problems, func(i, j int) bool { return problems[i].Field < problems[j].Field })
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "Some fields need another look.",
|
||||
"fields": problems,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.limiter.allow(s.clientIP(r)) {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(perIPWindow.Seconds())))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "That is a lot of messages in a short time. Try again shortly.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mail.Enabled() {
|
||||
log.Println("contact submission dropped: smtp is not configured")
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "The contact form is unavailable right now. Email support@hostxtra.co.uk directly.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Nothing is stored, so the send has to succeed before we can tell someone
|
||||
// their message arrived. This is the one place where a mail failure is the
|
||||
// caller's problem.
|
||||
if err := s.mail.Send(subject(addr, fields), plainBody(addr, fields), addr); err != nil {
|
||||
log.Printf("contact send: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send that. Try again, or email support@hostxtra.co.uk directly.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "received"})
|
||||
}
|
||||
|
||||
func subject(addr string, fields map[string]string) string {
|
||||
return fmt.Sprintf("[Vantage] %s — %s", fields["topic"], addr)
|
||||
}
|
||||
|
||||
func plainBody(addr string, fields map[string]string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("New contact enquiry from the Vantage site.\n\n")
|
||||
fmt.Fprintf(&b, "Name: %s\n", fields["name"])
|
||||
fmt.Fprintf(&b, "Email: %s\n", addr)
|
||||
fmt.Fprintf(&b, "Servers: %s\n", fields["servers"])
|
||||
fmt.Fprintf(&b, "Topic: %s\n", fields["topic"])
|
||||
fmt.Fprintf(&b, "Received: %s\n\n", time.Now().UTC().Format(time.RFC1123))
|
||||
b.WriteString("Message:\n")
|
||||
b.WriteString(fields["message"])
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "We could not read that submission.",
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
log.Printf("write response: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// limiter is a fixed-window counter keyed by client IP. It exists to blunt
|
||||
// automated submission floods, not to be a precise quota: the window resets
|
||||
// wholesale, and state is per-process, so it is a speed bump rather than a
|
||||
// guarantee. The per-email check in the handler backs it up.
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]*window
|
||||
limit int
|
||||
window time.Duration
|
||||
lastGC time.Time
|
||||
}
|
||||
|
||||
type window struct {
|
||||
count int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
func newLimiter(limit int, per time.Duration) *limiter {
|
||||
return &limiter{
|
||||
hits: make(map[string]*window),
|
||||
limit: limit,
|
||||
window: per,
|
||||
lastGC: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (l *limiter) allow(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
l.gc(now)
|
||||
|
||||
w, ok := l.hits[key]
|
||||
if !ok || now.Sub(w.start) > l.window {
|
||||
l.hits[key] = &window{count: 1, start: now}
|
||||
return true
|
||||
}
|
||||
|
||||
if w.count >= l.limit {
|
||||
return false
|
||||
}
|
||||
w.count++
|
||||
return true
|
||||
}
|
||||
|
||||
// gc drops expired windows so a long-running process does not accumulate an
|
||||
// entry for every IP that ever hit it. Caller must hold the lock.
|
||||
func (l *limiter) gc(now time.Time) {
|
||||
if now.Sub(l.lastGC) < l.window {
|
||||
return
|
||||
}
|
||||
for key, w := range l.hits {
|
||||
if now.Sub(w.start) > l.window {
|
||||
delete(l.hits, key)
|
||||
}
|
||||
}
|
||||
l.lastGC = now
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mrhid6/vantage/sitesvc/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
minPasswordLength = 12
|
||||
signupPerIPLimit = 3
|
||||
signupPerIPWindow = time.Hour
|
||||
)
|
||||
|
||||
type signupBody struct {
|
||||
OrgName string `json:"org_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"` // honeypot
|
||||
}
|
||||
|
||||
// handleSignup records an unverified signup and emails the confirmation link.
|
||||
// Nothing is created in orgs or users until that link is opened, so an address
|
||||
// nobody controls can never occupy an email or hold an organisation slug.
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
// A filled honeypot is a bot. Answer as we would on success so it learns
|
||||
// nothing, and record nothing.
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
}
|
||||
|
||||
var problems []fieldError
|
||||
|
||||
orgName, nameErr := text("org_name", body.OrgName, true, maxShort)
|
||||
if nameErr != nil {
|
||||
problems = append(problems, *nameErr)
|
||||
}
|
||||
|
||||
addr, emailErr := email("email", body.Email)
|
||||
if emailErr != nil {
|
||||
problems = append(problems, *emailErr)
|
||||
}
|
||||
|
||||
if utf8.RuneCountInString(body.Password) < minPasswordLength {
|
||||
problems = append(problems, fieldError{
|
||||
Field: "password",
|
||||
Message: fmt.Sprintf("Use at least %d characters.", minPasswordLength),
|
||||
})
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
writeFieldErrors(w, problems)
|
||||
return
|
||||
}
|
||||
|
||||
if !s.signups.allow(s.clientIP(r)) {
|
||||
w.Header().Set("Retry-After", fmt.Sprintf("%d", int(signupPerIPWindow.Seconds())))
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{
|
||||
"error": "Too many organisations created from here recently. Try again later.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !s.mail.Enabled() {
|
||||
log.Println("signup refused: smtp is not configured, so no verification email could be sent")
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Signup is unavailable right now. Email support@hostxtra.co.uk and we will set you up.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
token, err := store.CreatePending(ctx, orgName, addr, body.Password)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "That email already has an account.",
|
||||
"fields": []fieldError{{
|
||||
Field: "email",
|
||||
Message: "This address is already registered. Sign in instead.",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
writeFieldErrors(w, []fieldError{{
|
||||
Field: "org_name",
|
||||
Message: strings.TrimPrefix(err.Error(), store.ErrNameRejected.Error()+": "),
|
||||
}})
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("signup: create pending: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "We could not start that signup. Try again in a moment.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
link := s.verifyURL(token)
|
||||
if err := s.mail.SendVerification(addr, orgName, link, store.PendingTTL); err != nil {
|
||||
// The pending record is useless without its email, and the address is
|
||||
// not registered, so the caller must be told rather than left waiting
|
||||
// for a message that will never arrive.
|
||||
log.Printf("signup: send verification to %s: %v", addr, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "We could not send the confirmation email. Check the address, or email support@hostxtra.co.uk.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
}
|
||||
|
||||
func (s *Server) verifyURL(token string) string {
|
||||
base := strings.TrimSuffix(s.publicURL, "/")
|
||||
return fmt.Sprintf("%s/api/verify?token=%s", base, url.QueryEscape(token))
|
||||
}
|
||||
|
||||
// handleVerify consumes the token and provisions the organisation. It is opened
|
||||
// from an email client, so it answers with a page rather than JSON, and
|
||||
// redirects to the app's sign-in page on success when one is configured.
|
||||
func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
s.verifyPage(w, http.StatusBadRequest, "Link incomplete",
|
||||
"That link is missing its token. Copy the whole address from the email and try again.")
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
org, err := store.Verify(ctx, token)
|
||||
switch {
|
||||
case errors.Is(err, store.ErrBadToken):
|
||||
s.verifyPage(w, http.StatusGone, "Link expired",
|
||||
"This link has already been used or has expired. Start the signup again and we will send a new one.")
|
||||
return
|
||||
case errors.Is(err, store.ErrEmailTaken):
|
||||
s.verifyPage(w, http.StatusConflict, "Already registered",
|
||||
"That address already has an account. Sign in instead.")
|
||||
return
|
||||
case errors.Is(err, store.ErrNameRejected):
|
||||
s.verifyPage(w, http.StatusUnprocessableEntity, "Name unavailable",
|
||||
"We could not use that organisation name. Start the signup again with a different one.")
|
||||
return
|
||||
case err != nil:
|
||||
log.Printf("verify: %v", err)
|
||||
s.verifyPage(w, http.StatusInternalServerError, "Something went wrong",
|
||||
"We could not finish creating your organisation. Email support@hostxtra.co.uk and we will sort it out.")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("verify: provisioned org %s (%s)", org.Slug, org.OrgID)
|
||||
|
||||
if s.appLoginURL != "" {
|
||||
http.Redirect(w, r, s.appLoginURL, http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
s.verifyPage(w, http.StatusOK, "Organisation ready",
|
||||
fmt.Sprintf("%s is set up and you are its owner. You can sign in now.", org.Name))
|
||||
}
|
||||
|
||||
// verifyPage renders a minimal self-contained page. Everything interpolated is
|
||||
// escaped: the only dynamic value is an organisation name the visitor supplied
|
||||
// themselves, but it still reaches a browser as HTML.
|
||||
func (s *Server) verifyPage(w http.ResponseWriter, status int, heading, detail string) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
w.WriteHeader(status)
|
||||
|
||||
page := fmt.Sprintf(`<!doctype html>
|
||||
<html lang="en-GB">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>%s — Vantage</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
body {
|
||||
margin: 0; min-height: 100vh; display: grid; place-items: center;
|
||||
background: #eaedf3; color: #0a1b33; padding: 2rem;
|
||||
font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
}
|
||||
main { max-width: 46ch; }
|
||||
h1 { font-size: 1.6rem; letter-spacing: -0.03em; margin: 0 0 0.6rem; }
|
||||
p { margin: 0; color: #41556f; }
|
||||
a { color: #0b2a58; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #071628; color: #e4ecf6; }
|
||||
p { color: #9fb3ca; }
|
||||
a { color: #5b9be8; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>%s</h1>
|
||||
<p>%s</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>`, html.EscapeString(heading), html.EscapeString(heading), html.EscapeString(detail))
|
||||
|
||||
if _, err := w.Write([]byte(page)); err != nil {
|
||||
log.Printf("write verify page: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFieldErrors(w http.ResponseWriter, problems []fieldError) {
|
||||
writeJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"error": "Some fields need another look.",
|
||||
"fields": problems,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Deliberately loose: the only thing worth rejecting here is something that
|
||||
// cannot be an address at all. Anything stricter starts refusing valid mail.
|
||||
var emailRe = regexp.MustCompile(`^[^@\s]+@[^@\s.]+\.[^@\s]+$`)
|
||||
|
||||
const (
|
||||
maxShort = 200
|
||||
maxLong = 4000
|
||||
)
|
||||
|
||||
type fieldError struct {
|
||||
Field string `json:"field"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e fieldError) Error() string { return e.Field + ": " + e.Message }
|
||||
|
||||
// text trims, rejects empties when required, and caps length. The cap is on
|
||||
// runes rather than bytes so a multi-byte message is not silently truncated
|
||||
// mid-character.
|
||||
func text(name, value string, required bool, max int) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
if v == "" {
|
||||
if required {
|
||||
return "", &fieldError{Field: name, Message: "This field is required."}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
if utf8.RuneCountInString(v) > max {
|
||||
return "", &fieldError{
|
||||
Field: name,
|
||||
Message: fmt.Sprintf("Keep this under %d characters.", max),
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func email(name, value string) (string, *fieldError) {
|
||||
v, err := text(name, value, true, maxShort)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
v = strings.ToLower(v)
|
||||
if !emailRe.MatchString(v) {
|
||||
return "", &fieldError{Field: name, Message: "Enter an email address we can reply to."}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// oneOf constrains a value to a known set. Submitted values for dropdowns are
|
||||
// as attacker-controlled as any other field, so they are checked rather than
|
||||
// trusted and stored.
|
||||
func oneOf(name, value string, allowed []string) (string, *fieldError) {
|
||||
v := strings.TrimSpace(value)
|
||||
for _, a := range allowed {
|
||||
if v == a {
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
return "", &fieldError{Field: name, Message: "Choose one of the listed options."}
|
||||
}
|
||||
Reference in New Issue
Block a user