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."}
}
+174
View File
@@ -0,0 +1,174 @@
package mail
import (
"crypto/tls"
"fmt"
"mime"
"net"
"net/smtp"
"os"
"strings"
"time"
)
const timeout = 15 * time.Second
// Config is read once at boot. When Host is empty the service still accepts and
// stores submissions; it just does not email them.
type Config struct {
Host string
Port string
Username string
Password string
From string
To string
}
func FromEnv() Config {
return Config{
Host: os.Getenv("SMTP_HOST"),
Port: envOr("SMTP_PORT", "587"),
Username: os.Getenv("SMTP_USERNAME"),
Password: os.Getenv("SMTP_PASSWORD"),
From: os.Getenv("SMTP_FROM"),
To: envOr("SMTP_TO", "support@hostxtra.co.uk"),
}
}
func (c Config) Enabled() bool {
return c.Host != "" && c.From != "" && c.To != ""
}
// Port 465 uses implicit TLS; any other port starts plain and upgrades with
// STARTTLS when the server advertises it. Dial and connection deadlines keep an
// unreachable host from hanging the caller until the OS TCP timeout.
// Send delivers a plain-text message. replyTo, when set, becomes the Reply-To
// header so hitting reply in a mail client answers the person who filled in the
// form rather than the service's own sending address. The envelope sender stays
// as From, so a submitted address can never affect SPF or DMARC alignment.
func (c Config) Send(subject, body, replyTo string) error {
return c.sendTo(c.To, subject, body, replyTo)
}
// sendTo delivers to an explicit recipient. Contact enquiries go to the support
// inbox (c.To); verification links go to the person signing up.
func (c Config) sendTo(to, subject, body, replyTo string) error {
if !c.Enabled() {
return fmt.Errorf("smtp: not configured")
}
if to == "" {
return fmt.Errorf("smtp: no recipient")
}
addr := net.JoinHostPort(c.Host, c.Port)
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return fmt.Errorf("smtp: dial %s: %w", addr, err)
}
_ = conn.SetDeadline(time.Now().Add(timeout))
if c.Port == "465" {
conn = tls.Client(conn, &tls.Config{ServerName: c.Host})
}
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
conn.Close()
return fmt.Errorf("smtp: client: %w", err)
}
defer client.Close()
if c.Port != "465" {
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(&tls.Config{ServerName: c.Host}); err != nil {
return fmt.Errorf("smtp: starttls: %w", err)
}
}
}
if c.Username != "" {
if err := client.Auth(smtp.PlainAuth("", c.Username, c.Password, c.Host)); err != nil {
return fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(c.From); err != nil {
return fmt.Errorf("smtp: mail from: %w", err)
}
for _, rcpt := range recipients(to) {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("smtp: rcpt %s: %w", rcpt, err)
}
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("smtp: data: %w", err)
}
if _, err := w.Write(message(c.From, to, subject, body, replyTo)); err != nil {
return fmt.Errorf("smtp: write: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("smtp: close data: %w", err)
}
return client.Quit()
}
func recipients(to string) []string {
parts := strings.Split(to, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// message builds the MIME body. The subject is encoded rather than interpolated
// raw, and headers are stripped of CR/LF so submitted content cannot inject
// extra headers.
func message(from, to, subject, body, replyTo string) []byte {
var b strings.Builder
b.WriteString("From: " + sanitizeHeader(from) + "\r\n")
b.WriteString("To: " + sanitizeHeader(to) + "\r\n")
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(body)
return []byte(b.String())
}
func sanitizeHeader(v string) string {
return strings.NewReplacer("\r", " ", "\n", " ").Replace(v)
}
// SendVerification emails the one-time link that completes a signup. The
// address is the person signing up, not the support inbox, so To is overridden
// for this one message.
func (c Config) SendVerification(to, orgName, link string, ttl time.Duration) error {
body := fmt.Sprintf(`Confirm your email to finish creating %s on Vantage.
Open this link:
%s
The link works once and expires in %d hours. Until you use it, no account
exists — nothing has been created and the address is not registered.
If you did not request this, ignore this email and nothing will happen.
`, orgName, link, int(ttl.Hours()))
return c.sendTo(to, "Confirm your Vantage organisation", body, "")
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+54
View File
@@ -0,0 +1,54 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
/*
Org and User mirror server/internal/models field for field, because sitesvc
writes into the same collections the control plane reads.
These two structs and the rules in internal/provision are the only places
sitesvc duplicates control-plane logic. If the control plane's shape changes,
these must change with it.
*/
type Org struct {
ID bson.ObjectID `bson:"_id,omitempty"`
OrgID string `bson:"org_id"`
Name string `bson:"name"`
Slug string `bson:"slug"`
CreatedAt time.Time `bson:"created_at"`
}
type User struct {
ID bson.ObjectID `bson:"_id,omitempty"`
UserID string `bson:"user_id"`
OrgID string `bson:"org_id"`
Email string `bson:"email"`
PasswordHash string `bson:"password_hash,omitempty"`
Role string `bson:"role"`
AuthSource string `bson:"auth_source"`
CreatedAt time.Time `bson:"created_at"`
LastLogin *time.Time `bson:"last_login,omitempty"`
}
// PendingSignup is sitesvc's own record, in its own collection. It holds a
// signup between the form being submitted and the email link being clicked.
//
// Nothing is written to orgs or users until verification succeeds, so an
// unverified address can never occupy an email, hold a slug, or sign in. The
// password is bcrypt-hashed here exactly as it would be in users, so the
// plaintext never rests anywhere.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
OrgName string `bson:"org_name"`
Email string `bson:"email"`
PasswordHash string `bson:"password_hash"`
TokenHash string `bson:"token_hash"`
CreatedAt time.Time `bson:"created_at"`
ExpiresAt time.Time `bson:"expires_at"`
}
+66
View File
@@ -0,0 +1,66 @@
package provision
import (
"fmt"
"regexp"
"strings"
)
/*
Slug rules mirrored from the control plane (server/internal/services: Slugify in
stepscan.go, reservedSlugs and CreateOrg in orgs.go).
They live here rather than being imported because sitesvc is a separate module
with no dependency on the server. That is a deliberate trade: sitesvc stays
small and independent, at the cost of this one duplicated rule set.
Keep the two in step. If the control plane's slug handling, reserved names or
bcrypt cost change, change them here in the same commit — nothing enforces the
match automatically, and a divergence would create tenants under rules the app
does not agree with.
*/
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12 // matches services.CreateUser
)
var slugStrip = regexp.MustCompile(`[^a-z0-9]+`)
// ReservedSlugs are names that would collide with a route or a host label.
// Mirrored from services.reservedSlugs.
var ReservedSlugs = map[string]bool{
"www": true, "api": true, "app": true, "admin": true, "auth": true,
"install": true, "static": true, "_next": true, "default": true,
}
func Slugify(name string) string {
s := strings.ToLower(name)
s = slugStrip.ReplaceAllString(s, "-")
return strings.Trim(s, "-")
}
// BaseSlug derives and validates the slug for an organisation name, returning
// the same errors the control plane's CreateOrg would.
func BaseSlug(name string) (string, error) {
base := Slugify(name)
if len(base) < MinSlugLength {
return "", fmt.Errorf("organisation name too short (slug must be at least %d characters)", MinSlugLength)
}
if len(base) > MaxSlugLength {
base = base[:MaxSlugLength]
}
if ReservedSlugs[base] {
return "", fmt.Errorf("that organisation name is reserved")
}
return base, nil
}
// NextSlug is the collision suffix scheme: base, base-2, base-3, ...
func NextSlug(base string, attempt int) string {
if attempt < 2 {
return base
}
return fmt.Sprintf("%s-%d", base, attempt)
}
+290
View File
@@ -0,0 +1,290 @@
package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"github.com/mrhid6/vantage/sitesvc/internal/provision"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"go.mongodb.org/mongo-driver/v2/x/mongo/driver/connstring"
"golang.org/x/crypto/bcrypt"
)
// PendingTTL is how long a verification link stays valid.
const PendingTTL = 24 * time.Hour
var (
ErrEmailTaken = errors.New("email already registered")
ErrBadToken = errors.New("verification link is invalid or has expired")
ErrNameRejected = errors.New("organisation name rejected")
)
var database *mongo.Database
// Connect dials MongoDB and selects the database named in the connection
// string, e.g. mongodb://host:27017/vantage. The name is parsed with the
// driver's own connection-string parser rather than by hand, so seed lists,
// mongodb+srv, percent-escaping and auth options all behave as the driver
// expects.
//
// A URI with no database is a configuration error worth failing on: defaulting
// would silently provision tenants into the wrong database, where the control
// plane would never see them.
func Connect(uri string) error {
cs, err := connstring.ParseAndValidate(uri)
if err != nil {
return fmt.Errorf("parse MONGO_URI: %w", err)
}
if cs.Database == "" {
return errors.New("MONGO_URI must name a database, e.g. mongodb://host:27017/vantage")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri))
if err != nil {
return err
}
if err := client.Ping(ctx, nil); err != nil {
return err
}
database = client.Database(cs.Database)
return nil
}
// DatabaseName reports the database in use, for startup logging.
func DatabaseName() string {
if database == nil {
return ""
}
return database.Name()
}
func col(name string) *mongo.Collection { return database.Collection(name) }
// EnsureIndexes builds the constraints sitesvc depends on.
//
// The unique indexes on users.email and orgs.slug are the same ones the control
// plane builds at boot, and they are a security property rather than an
// optimisation: without them a duplicate email lets an unscoped user lookup
// match the wrong account, and a duplicate slug makes host-based org resolution
// pick one at random. They are (re)declared here so sitesvc does not depend on
// the server having started first. Creating an existing index is a no-op.
func EnsureIndexes() error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if _, err := col("users").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("users.email index: %w", err)
}
if _, err := col("orgs").Indexes().CreateOne(ctx, mongo.IndexModel{
Keys: bson.D{{Key: "slug", Value: 1}},
Options: options.Index().SetUnique(true),
}); err != nil {
return fmt.Errorf("orgs.slug index: %w", err)
}
_, err := col("site_pending_signups").Indexes().CreateMany(ctx, []mongo.IndexModel{
{
Keys: bson.D{{Key: "token_hash", Value: 1}},
Options: options.Index().SetUnique(true),
},
{Keys: bson.D{{Key: "email", Value: 1}}},
// Mongo removes expired pending signups on its own, so an abandoned
// signup does not keep a password hash around indefinitely.
{
Keys: bson.D{{Key: "expires_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(0),
},
})
if err != nil {
return fmt.Errorf("pending signup indexes: %w", err)
}
return nil
}
// EmailTaken reports whether an address already has an account. It is a
// courtesy check for a clear error message; the unique index is what actually
// enforces uniqueness at verification time.
func EmailTaken(ctx context.Context, email string) (bool, error) {
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
return n > 0, err
}
// CreatePending stores an unverified signup and returns the raw token for the
// email link. Only the token's SHA-256 hash is persisted, so a leaked database
// does not yield working verification links.
func CreatePending(ctx context.Context, orgName, email, password string) (string, error) {
if _, err := provision.BaseSlug(orgName); err != nil {
return "", fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
taken, err := EmailTaken(ctx, email)
if err != nil {
return "", err
}
if taken {
return "", ErrEmailTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), provision.BcryptCost)
if err != nil {
return "", err
}
raw, err := randomToken()
if err != nil {
return "", err
}
// A second attempt for the same address replaces the first, so the newest
// email is the one that works and old links stop functioning.
if _, err := col("site_pending_signups").DeleteMany(ctx, bson.M{"email": email}); err != nil {
return "", err
}
now := time.Now().UTC()
pending := models.PendingSignup{
PendingID: uuid.NewString(),
OrgName: strings.TrimSpace(orgName),
Email: email,
PasswordHash: string(hash),
TokenHash: hashToken(raw),
CreatedAt: now,
ExpiresAt: now.Add(PendingTTL),
}
if _, err := col("site_pending_signups").InsertOne(ctx, pending); err != nil {
return "", err
}
return raw, nil
}
// Verify consumes a token and provisions the organisation and its owner.
//
// The pending record is deleted first and atomically, so a token can only ever
// be spent once even if the link is clicked twice at the same moment: the
// second delete matches nothing and stops here.
func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
var pending models.PendingSignup
err := col("site_pending_signups").FindOneAndDelete(ctx, bson.M{
"token_hash": hashToken(rawToken),
"expires_at": bson.M{"$gt": time.Now().UTC()},
}).Decode(&pending)
if errors.Is(err, mongo.ErrNoDocuments) {
return nil, ErrBadToken
}
if err != nil {
return nil, err
}
org, err := createOrg(ctx, pending.OrgName)
if err != nil {
return nil, err
}
user := models.User{
UserID: uuid.NewString(),
OrgID: org.OrgID,
Email: pending.Email,
PasswordHash: pending.PasswordHash,
Role: "owner",
AuthSource: "local",
CreatedAt: time.Now().UTC(),
}
if _, err := col("users").InsertOne(ctx, user); err != nil {
// An org with no owner is unreachable and holds a slug nobody can
// reuse, so take it back out. Losing the pending record here is
// acceptable: the address is already registered, which is what the
// duplicate error means.
if rbErr := rollbackOrg(ctx, org.OrgID); rbErr != nil {
log.Printf("verify: failed to roll back org %s: %v", org.OrgID, rbErr)
}
if mongo.IsDuplicateKeyError(err) {
return nil, ErrEmailTaken
}
return nil, err
}
return org, nil
}
// createOrg mirrors services.CreateOrg: derive the slug, resolve collisions by
// suffixing, and let the unique index settle any race.
func createOrg(ctx context.Context, name string) (*models.Org, error) {
base, err := provision.BaseSlug(name)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrNameRejected, err.Error())
}
for attempt := 1; attempt <= 50; attempt++ {
slug := provision.NextSlug(base, attempt)
n, err := col("orgs").CountDocuments(ctx, bson.M{"slug": slug})
if err != nil {
return nil, err
}
if n > 0 {
continue
}
org := models.Org{
OrgID: uuid.NewString(),
Name: name,
Slug: slug,
CreatedAt: time.Now().UTC(),
}
if _, err := col("orgs").InsertOne(ctx, org); err != nil {
// Another signup took this slug between the count and the insert.
// Try the next suffix rather than failing the whole signup.
if mongo.IsDuplicateKeyError(err) {
continue
}
return nil, err
}
return &org, nil
}
return nil, fmt.Errorf("%w: could not find a free slug for %q", ErrNameRejected, name)
}
// rollbackOrg removes an org that never got an owner. It refuses to touch one
// that has users, so a mistaken call can never delete a live tenant.
func rollbackOrg(ctx context.Context, orgID string) error {
n, err := col("users").CountDocuments(ctx, bson.M{"org_id": orgID})
if err != nil {
return err
}
if n > 0 {
return fmt.Errorf("refusing to roll back org %s: it has %d user(s)", orgID, n)
}
_, err = col("orgs").DeleteOne(ctx, bson.M{"org_id": orgID})
return err
}
func randomToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func hashToken(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}