hub v0.126.0: fresh connect link from the old one (R-719); day-one mails (R-723); bind-page wording (R-725); volunteer guide current (R-722); ep0 cleanup and release evidence
gates / gates (push) Successful in 29s
gates / gates (push) Successful in 29s
Red-proofs RP40-RP42. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -97,7 +97,7 @@ func bindClientIP(r *http.Request) string {
|
||||
// --- the page ---
|
||||
|
||||
type bindPageData struct {
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked"
|
||||
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
|
||||
Token string // echoed into the form action (the capability itself; already in the URL)
|
||||
Failed bool // generic factor-check failure (form state only)
|
||||
// Lang is the language to render in. It is the CUSTOMER'S CREATION-TIME language and nothing
|
||||
@@ -157,6 +157,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
token := strings.TrimPrefix(r.URL.Path, "/bind/")
|
||||
// R-719 (v0.126.0): „Új linket kérek" on an expired or used link.
|
||||
if t, ok := strings.CutSuffix(token, "/resend"); ok && r.Method == http.MethodPost && t != "" && !strings.Contains(t, "/") {
|
||||
s.handleBindResend(w, t)
|
||||
return
|
||||
}
|
||||
// A trailing segment only — reject anything with further path structure (defence in depth atop
|
||||
// the ServeMux path-clean; the token is a flat hex string).
|
||||
if token == "" || strings.Contains(token, "/") {
|
||||
@@ -194,10 +199,11 @@ func (s *Server) handleBind(w http.ResponseWriter, r *http.Request) {
|
||||
// (nil) is folded into "expired": no oracle for "was this link ever real".
|
||||
switch {
|
||||
case tok == nil || tok.Expired(now):
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default})
|
||||
// The token is echoed for the resend form whether or not it is real: the page must not differ.
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "expired", Lang: i18n.Default, Token: token})
|
||||
return
|
||||
case tok.Consumed():
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang})
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "consumed", Lang: lang, Token: token})
|
||||
return
|
||||
case tok.Locked:
|
||||
s.renderBind(w, http.StatusOK, bindPageData{State: "locked", Lang: lang})
|
||||
@@ -327,15 +333,60 @@ const bindPageHTML = `<!DOCTYPE html>
|
||||
{{else if eq .State "consumed"}}
|
||||
<p class="lead">{{T "bind.consumed.lead"}}</p>
|
||||
<p>{{T "bind.consumed.body"}}</p>
|
||||
<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>
|
||||
{{else if eq .State "resent"}}
|
||||
<p class="lead">{{T "bind.resent.lead"}}</p>
|
||||
<p>{{T "bind.resent.body"}}</p>
|
||||
{{else if eq .State "locked"}}
|
||||
<p class="lead">{{T "bind.locked.lead"}}</p>
|
||||
<p>{{T "bind.locked.body"}}</p>
|
||||
{{else}}
|
||||
<p class="lead">{{T "bind.invalid.lead"}}</p>
|
||||
<p>{{T "bind.invalid.body"}}</p>
|
||||
{{if .Token}}<form method="POST" action="/bind/{{.Token}}/resend"><p class="hint">{{T "bind.resend.hint"}}</p><button type="submit">{{T "bind.resend.button"}}</button></form>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<p class="foot">Felhom.eu</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// ── R-719 (v0.126.0): a returning customer asks for a fresh link from the old one ─────────────────────
|
||||
//
|
||||
// A box that registers is UNCLAIMED — the hub cannot know whose it is until the bind (measured 2026-09-30:
|
||||
// the registration carries uuid, MACs, host keys and hardware, nothing of a customer). So "send the link
|
||||
// when their box registers" cannot be built without mailing every waiting customer. What the returning
|
||||
// customer DOES have is their old mail: its link now answers „expired" (7-day TTL) or „already used". That
|
||||
// page offers one press; the hub mints and mails a fresh link to the address REGISTERED for that link's
|
||||
// customer, and only when the customer has no box (the same guard as every other auto-send).
|
||||
//
|
||||
// No oracle: the answer is the same „resent" page, in the default language, whether the token was real,
|
||||
// unknown, still live, or the customer already has a box. Limits: the per-IP bind limiter, and one mail
|
||||
// per customer per bindResendEvery. Pinned by internal/web/selfbind_resend_test.go.
|
||||
|
||||
const bindResendEvery = time.Hour
|
||||
|
||||
func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
|
||||
defer s.renderBind(w, http.StatusOK, bindPageData{State: "resent", Lang: i18n.Default})
|
||||
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
|
||||
if err != nil || tok == nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
if !tok.Expired(now) && !tok.Consumed() {
|
||||
return // a live link needs no replacement
|
||||
}
|
||||
s.bindResendMu.Lock()
|
||||
last := s.bindResendAt[tok.CustomerID]
|
||||
if now.Sub(last) < bindResendEvery {
|
||||
s.bindResendMu.Unlock()
|
||||
s.logger.Printf("[INFO] self-bind: fresh link for %s NOT sent — one was sent %s ago (limit %s)", tok.CustomerID, now.Sub(last).Round(time.Second), bindResendEvery)
|
||||
return
|
||||
}
|
||||
if s.bindResendAt == nil {
|
||||
s.bindResendAt = map[string]time.Time{}
|
||||
}
|
||||
s.bindResendAt[tok.CustomerID] = now
|
||||
s.bindResendMu.Unlock()
|
||||
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user