feat: Removed comments
Server Deploy / deploy (push) Failing after 1m59s

This commit is contained in:
2026-07-24 09:51:30 +01:00
parent 3b52bcbeb8
commit 1a6cf03c03
94 changed files with 772 additions and 937 deletions
+9 -9
View File
@@ -16,11 +16,11 @@ import (
"github.com/mrhid6/vantage/sitesvc/internal/store"
)
// sitesvc backs the public marketing site. It owns two jobs end to end:
// emailing the contact form, and provisioning an organisation once its owner
// has verified their email address. It shares MongoDB with the control plane —
// that is how the new tenant becomes visible to the app — but shares no code
// and no process with it.
func main() {
godotenv.Load()
@@ -33,10 +33,10 @@ func main() {
}
log.Printf("connected to MongoDB (database %q)", store.DatabaseName())
// The unique indexes on users.email and orgs.slug are a security property,
// not an optimisation, so a failure to build them is fatal rather than a
// warning: provisioning tenants without them risks duplicate accounts and
// ambiguous host-based org resolution.
if err := store.EnsureIndexes(); err != nil {
log.Fatalf("failed to ensure indexes: %v", err)
}
+21 -21
View File
@@ -15,14 +15,14 @@ import (
)
const (
maxBodyBytes = 32 << 10 // 32 KiB is far more than this form needs
maxBodyBytes = 32 << 10
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
@@ -49,7 +49,7 @@ 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"})
@@ -57,7 +57,7 @@ func (s *Server) Routes() http.Handler {
return s.withCORS(mux)
}
// ---------------------------------------------------------------- middleware
func parseOrigins(raw string) map[string]bool {
out := map[string]bool{}
@@ -69,10 +69,10 @@ func parseOrigins(raw string) map[string]bool {
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")
@@ -91,9 +91,9 @@ func (s *Server) withCORS(next http.Handler) http.Handler {
})
}
// 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 != "" {
@@ -110,7 +110,7 @@ func (s *Server) clientIP(r *http.Request) string {
return host
}
// ------------------------------------------------------------------- handler
type contactBody struct {
Name string `json:"name"`
@@ -118,7 +118,7 @@ type contactBody struct {
Servers string `json:"servers"`
Topic string `json:"topic"`
Message string `json:"message"`
Website string `json:"website"` // honeypot: real people leave this empty
Website string `json:"website"`
}
var (
@@ -137,8 +137,8 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
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
@@ -201,9 +201,9 @@ func (s *Server) handleContact(w http.ResponseWriter, r *http.Request) {
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{
@@ -233,7 +233,7 @@ func plainBody(addr string, fields map[string]string) string {
return b.String()
}
// ------------------------------------------------------------------- helpers
func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
+6 -6
View File
@@ -5,10 +5,10 @@ import (
"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
@@ -51,8 +51,8 @@ func (l *limiter) allow(key string) bool {
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
+15 -15
View File
@@ -25,20 +25,20 @@ type signupBody struct {
OrgName string `json:"org_name"`
Email string `json:"email"`
Password string `json:"password"`
Website string `json:"website"` // honeypot
Website string `json:"website"`
}
// 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
@@ -114,9 +114,9 @@ func (s *Server) handleSignup(w http.ResponseWriter, r *http.Request) {
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.",
@@ -132,9 +132,9 @@ func (s *Server) verifyURL(token string) string {
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 == "" {
@@ -178,9 +178,9 @@ func (s *Server) handleVerify(w http.ResponseWriter, r *http.Request) {
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")
+8 -8
View File
@@ -7,8 +7,8 @@ import (
"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 (
@@ -23,9 +23,9 @@ type fieldError struct {
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 == "" {
@@ -55,9 +55,9 @@ func email(name, value string) (string, *fieldError) {
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 {
+24 -24
View File
@@ -15,8 +15,8 @@ import (
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
@@ -41,19 +41,19 @@ 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")
@@ -127,9 +127,9 @@ func recipients(to string) []string {
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")
@@ -137,10 +137,10 @@ func message(from, to, subject, body, replyTo string) []byte {
if replyTo != "" {
b.WriteString("Reply-To: " + sanitizeHeader(replyTo) + "\r\n")
}
// Date and Message-ID are RFC 5322 essentials. Without them many servers
// accept the message at SMTP time and then silently junk or drop it, and
// SpamAssassin scores MISSING_DATE and MISSING_MID heavily — the message
// "sends" but never lands in the inbox.
b.WriteString("Date: " + time.Now().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: " + messageID(from) + "\r\n")
b.WriteString("Subject: " + mime.QEncoding.Encode("utf-8", sanitizeHeader(subject)) + "\r\n")
@@ -151,9 +151,9 @@ func message(from, to, subject, body, replyTo string) []byte {
return []byte(b.String())
}
// messageID builds a unique <id@domain>, taking the domain from the From
// address so the identifier matches the sending domain. Falls back to the host
// name when From has no domain part.
func messageID(from string) string {
domain := "vantage.local"
if at := strings.LastIndex(from, "@"); at >= 0 && at < len(from)-1 {
@@ -170,9 +170,9 @@ 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.
+7 -7
View File
@@ -35,13 +35,13 @@ type User struct {
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"`
+6 -6
View File
@@ -23,13 +23,13 @@ does not agree with.
const (
MinSlugLength = 3
MaxSlugLength = 40
BcryptCost = 12 // matches services.CreateUser
BcryptCost = 12
)
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,
@@ -41,8 +41,8 @@ func Slugify(name string) string {
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 {
@@ -57,7 +57,7 @@ func BaseSlug(name string) (string, error) {
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
+30 -30
View File
@@ -21,7 +21,7 @@ import (
"golang.org/x/crypto/bcrypt"
)
// PendingTTL is how long a verification link stays valid.
const PendingTTL = 24 * time.Hour
var (
@@ -32,15 +32,15 @@ var (
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 {
@@ -64,7 +64,7 @@ func Connect(uri string) error {
return nil
}
// DatabaseName reports the database in use, for startup logging.
func DatabaseName() string {
if database == nil {
return ""
@@ -98,8 +98,8 @@ func EnsureIndexes() error {
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),
@@ -139,8 +139,8 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
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
}
@@ -161,11 +161,11 @@ func CreatePending(ctx context.Context, orgName, email, password string) (string
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{
@@ -194,10 +194,10 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
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)
}
@@ -210,8 +210,8 @@ func Verify(ctx context.Context, rawToken string) (*models.Org, error) {
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 {
@@ -236,8 +236,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
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
}
@@ -248,8 +248,8 @@ func createOrg(ctx context.Context, name string) (*models.Org, error) {
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 {