feat: Marketing site
Server Deploy / deploy (push) Successful in 5m51s

This commit is contained in:
2026-07-22 16:50:13 +01:00
parent 7a3b8cb700
commit 693d59a3e2
51 changed files with 10656 additions and 12 deletions
+258
View File
@@ -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{"13", "425", "26100", "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)
}
}
+66
View File
@@ -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
}
+233
View File
@@ -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,
})
}
+69
View File
@@ -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."}
}