refactor(sitesvc): remove signup and verification
Account creation moved to admin, which owns accounts, and the marketing form now posts there. sitesvc keeps the contact mailer only. DEPLOY LAST: sitesvc's verify endpoint must stay live until every outstanding pending signup has expired, or an in-flight verification link breaks. Do not roll this out until the site change has been live 24 hours and site_pending_signups is empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,31 +23,22 @@ const (
|
||||
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)
|
||||
|
||||
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"})
|
||||
})
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
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 {
|
||||
InstanceName string `json:"instance_name"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Website string `json:"website"`
|
||||
}
|
||||
|
||||
func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
|
||||
var body signupBody
|
||||
if !decode(w, r, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(body.Website) != "" {
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"status": "check_email"})
|
||||
return
|
||||
}
|
||||
|
||||
var problems []fieldError
|
||||
|
||||
instanceName, nameErr := text("instance_name", body.InstanceName, 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, instanceName, 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: "instance_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, instanceName, link, store.PendingTTL); err != nil {
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
inst, 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 instance %s (%s)", inst.Slug, inst.InstanceID)
|
||||
|
||||
if login := s.loginURL(inst.Slug); login != "" {
|
||||
http.Redirect(w, r, login, 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.", inst.Name))
|
||||
}
|
||||
|
||||
// loginURL is the instance-specific sign-in URL a verified owner is sent to. Each
|
||||
// instance lives on its own subdomain (<slug>.vantage.hostxtra.co.uk), so the slug
|
||||
// must be substituted per signup rather than pointing at one shared address.
|
||||
//
|
||||
// APP_LOGIN_URL is a template. A "{slug}" placeholder is replaced with the
|
||||
// instance's slug; a value without one is treated as a literal (a single shared
|
||||
// login page) so a plain URL still works. An empty value falls back to the
|
||||
// confirmation page.
|
||||
func (s *Server) loginURL(slug string) string {
|
||||
if s.appLoginURL == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(s.appLoginURL, "{slug}", url.PathEscape(slug))
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user