diff --git a/sitesvc/cmd/main.go b/sitesvc/cmd/main.go index 1dbd2d5..6f2a492 100644 --- a/sitesvc/cmd/main.go +++ b/sitesvc/cmd/main.go @@ -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") } diff --git a/sitesvc/internal/api/api.go b/sitesvc/internal/api/api.go index 28d1b70..2a2baa3 100644 --- a/sitesvc/internal/api/api.go +++ b/sitesvc/internal/api/api.go @@ -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"}) }) diff --git a/sitesvc/internal/api/signup.go b/sitesvc/internal/api/signup.go deleted file mode 100644 index 930eac8..0000000 --- a/sitesvc/internal/api/signup.go +++ /dev/null @@ -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 (.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(` - - - - - -%s Vantage - - - -
-

%s

-

%s

-
- -`, 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, - }) -} diff --git a/sitesvc/internal/models/models.go b/sitesvc/internal/models/models.go deleted file mode 100644 index 57341be..0000000 --- a/sitesvc/internal/models/models.go +++ /dev/null @@ -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"` -} diff --git a/sitesvc/internal/store/store.go b/sitesvc/internal/store/store.go index e3b90b8..555f787 100644 --- a/sitesvc/internal/store/store.go +++ b/sitesvc/internal/store/store.go @@ -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[:]) -}