package web import ( "crypto/sha256" "encoding/hex" "net/http" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" ) // selfbind_mint.go — the OPERATOR side of customer self-bind (v0.66.0, R-27 slice 1): the "Send // self-bind link" button on the customer page mints a 7-day capability token and emails the customer // a public bind link. The customer side (the public /bind/ page) lives in selfbind.go. No hub // customer-login exists — the emailed link IS the auth model. // selfBindTTL is the capability link's lifetime. After it, self-bind falls back to operator-bind // unchanged (the token simply reads as expired; nothing else regresses). const selfBindTTL = 7 * 24 * time.Hour // selfBindBaseURL is the hub's public origin, matching the hardcoded origin used elsewhere // (configgen). The link is https://hub.felhom.eu/bind/. const selfBindBaseURL = "https://hub.felhom.eu" // SelfBindMailer delivers the self-bind capability link to the registered customer address. The // notify.Dispatcher implements it (a sibling of the claim mailer — NOT routed through claim). The // signature takes plain strings so the implementation needs no import of this package. type SelfBindMailer interface { SendSelfBindEmail(customerID, email, link string) error } // SetSelfBindMailer wires the self-bind link sender (v0.66.0). Absent → the button returns 502. func (s *Server) SetSelfBindMailer(m SelfBindMailer) { s.selfBindMailer = m } func selfBindHash(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // handleSelfBindLinkSend — POST /customers/{id}/selfbind-link. Mints a single-active capability token // for the customer and emails the public bind link. Honesty rules: // - F1: no registered email → nothing is minted, LOUD flash (a link no one can receive is useless). // - F2: email send fails → the just-minted token is deleted (not left silently live), LOUD flash. // // The plaintext token exists only between minting and the send; it is never logged (only an 8-char // hash prefix) and never persisted (only its sha256). func (s *Server) handleSelfBindLinkSend(w http.ResponseWriter, r *http.Request, customerID string) { if s.selfBindMailer == nil { http.Error(w, "Self-bind mailer is not configured on this hub", http.StatusBadGateway) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } // F1: refuse to mint a link that cannot be delivered. if cfg.Email == "" { s.logger.Printf("[WARN] self-bind link for %s NOT sent: customer has no registered email", customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-no-email#tab=setup", http.StatusSeeOther) return } token, err := configgen.RandomHex(32) // 256-bit capability token if err != nil { s.logger.Printf("[ERROR] self-bind link for %s: token generation: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } hash := selfBindHash(token) if err := s.store.MintSelfBindToken(customerID, hash, selfBindTTL); err != nil { s.logger.Printf("[ERROR] self-bind link for %s: minting token: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } link := selfBindBaseURL + "/bind/" + token if err := s.selfBindMailer.SendSelfBindEmail(customerID, cfg.Email, link); err != nil { // F2: delivery failed — do not leave a live capability token behind (the plaintext link is // already gone from memory, so nobody could ever use it; delete it and surface the failure). if derr := s.store.DeleteSelfBindTokens(customerID); derr != nil { s.logger.Printf("[ERROR] self-bind link for %s: send failed AND cleanup failed: send=%v cleanup=%v", customerID, err, derr) } else { s.logger.Printf("[ERROR] self-bind link for %s: email send failed, token invalidated (hash %s…): %v", customerID, hash[:8], err) } http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-send-failed#tab=setup", http.StatusSeeOther) return } if err := s.store.MarkSelfBindEmailed(hash); err != nil { s.logger.Printf("[WARN] self-bind link sent to %s but emailed_at not recorded: %v", customerID, err) } s.logger.Printf("[INFO] self-bind link (hash %s…, valid 7 days) emailed to the registered address of %s", hash[:8], customerID) http.Redirect(w, r, "/customers/"+customerID+"?flash=selfbind-sent#tab=setup", http.StatusSeeOther) }