@@ -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."}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user