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:
mrhid6
2026-07-26 13:41:47 +01:00
co-authored by Claude Opus 5
parent 9cf04a6940
commit b86d9ddd86
5 changed files with 2 additions and 405 deletions
+1 -4
View File
@@ -43,12 +43,9 @@ func main() {
if mailCfg.Enabled() {
log.Printf("smtp enabled (%s) contact form delivers to %s", mailCfg.Host, mailCfg.To)
} else {
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact and signup forms will refuse submissions")
log.Println("warning: SMTP_HOST/SMTP_FROM not set the contact form will refuse submissions")
}
if os.Getenv("PUBLIC_URL") == "" {
log.Println("warning: PUBLIC_URL is unset verification links will be relative and will not work")
}
if os.Getenv("SITE_ORIGIN") == "" {
log.Println("warning: SITE_ORIGIN is unset cross-origin browser requests will be refused")
}
-9
View File
@@ -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"})
})
-235
View File
@@ -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,
})
}
-24
View File
@@ -1,24 +0,0 @@
package models
import (
"time"
"go.mongodb.org/mongo-driver/v2/bson"
)
// PendingSignup lives here rather than in the shared module because only
// sitesvc writes site_pending_signups. The control plane does not know the
// collection exists.
//
// Org and User used to be mirrored here by hand. They now come from
// github.com/mrhid6/vantage/shared/models, which is the only copy.
type PendingSignup struct {
ID bson.ObjectID `bson:"_id,omitempty"`
PendingID string `bson:"pending_id"`
InstanceName string `bson:"instance_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"`
}
+1 -133
View File
@@ -2,35 +2,15 @@ package store
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"github.com/mrhid6/vantage/shared/indexes"
sharedmodels "github.com/mrhid6/vantage/shared/models"
"github.com/mrhid6/vantage/shared/provision"
"github.com/mrhid6/vantage/sitesvc/internal/models"
"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"
)
const PendingTTL = 24 * time.Hour
// ErrEmailTaken and ErrNameRejected are aliases of the shared errors so
// errors.Is keeps working for existing callers in internal/api.
var (
ErrEmailTaken = provision.ErrEmailTaken
ErrBadToken = errors.New("verification link is invalid or has expired")
ErrNameRejected = provision.ErrNameRejected
)
var database *mongo.Database
@@ -74,26 +54,7 @@ func EnsureIndexes() error {
// users.email and instances.slug are declared in the shared module so both
// services agree. Re-declaring at boot means sitesvc does not depend on the
// control plane having started first.
if err := indexes.EnsureCoreIndexes(ctx, database); err != nil {
return 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}}},
{
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
return indexes.EnsureCoreIndexes(ctx, database)
}
// RequireMigratedDatabase refuses to start against a control-plane database
@@ -129,96 +90,3 @@ func RequireMigratedDatabase(ctx context.Context) error {
return nil
}
func EmailTaken(ctx context.Context, email string) (bool, error) {
n, err := col("users").CountDocuments(ctx, bson.M{"email": email})
return n > 0, err
}
func CreatePending(ctx context.Context, instanceName, email, password string) (string, error) {
if _, err := provision.BaseSlug(instanceName); 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
}
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(),
InstanceName: strings.TrimSpace(instanceName),
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
}
func Verify(ctx context.Context, rawToken string) (*sharedmodels.Instance, 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
}
inst, err := provision.CreateInstance(ctx, database, pending.InstanceName)
if err != nil {
return nil, err
}
// The password was hashed when the signup was recorded; only the hash
// survives to this point.
_, err = provision.CreateUserWithHash(ctx, database, inst.InstanceID, pending.Email,
pending.PasswordHash, sharedmodels.RoleOwner, "local")
if err != nil {
// Leaving an instance behind would permanently occupy a slug nobody owns.
if rbErr := provision.RollbackInstance(ctx, database, inst.InstanceID); rbErr != nil {
log.Printf("verify: failed to roll back instance %s: %v", inst.InstanceID, rbErr)
}
return nil, err
}
return inst, nil
}
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[:])
}