hub v0.163.0: a rebuilt box gets the household's sealed package back (R-1176); one run per daily job on clock-change days (R-1183); a scoped abandonment purge (R-1182); the new-link page says the old box is still linked (R-1203)
gates / gates (push) Successful in 13m23s

This commit is contained in:
admin committed 2026-10-21 13:36:45 +02:00
1 parent e07b077ac2
commit 9688e76efd
16 files changed
+1018 -29

No files matched your search

+40 -2
View File
@@ -2,6 +2,8 @@ package web
import (
"crypto/subtle"
"encoding/json"
"fmt"
"html/template"
"net/http"
"regexp"
@@ -97,7 +99,7 @@ func bindClientIP(r *http.Request) string {
// --- the page ---
type bindPageData struct {
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent"
State string // "form" | "success" | "expired" | "consumed" | "locked" | "resent" | "oldbox" (R-1203)
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
@@ -334,6 +336,9 @@ const bindPageHTML = `<!DOCTYPE html>
<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 "oldbox"}}
<p class="lead">{{T "bind.oldbox.lead"}}</p>
<p>{{T "bind.oldbox.body"}}</p>
{{else if eq .State "resent"}}
<p class="lead">{{T "bind.resent.lead"}}</p>
<p>{{T "bind.resent.body"}}</p>
@@ -368,7 +373,8 @@ const bindPageHTML = `<!DOCTYPE html>
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})
page := bindPageData{State: "resent", Lang: i18n.Default}
defer func() { s.renderBind(w, http.StatusOK, page) }()
tok, err := s.store.SelfBindTokenByHash(selfBindHash(token))
if err != nil || tok == nil {
return
@@ -377,6 +383,35 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
if !tok.Expired(now) && !tok.Consumed() {
return // a live link needs no replacement
}
// R-1203 (2026-10-21): a household whose old box is still linked waited for a mail that never came (rehearsal
// 2026-10-20 B7, 17 minutes, then a phone call). The link holder is told so in plain words, and the operator gets
// the host name to delete. This answers only a REAL used or expired link — an unknown token still gets the same
// „resent" page, so a stranger learns nothing; the holder of a real link is the household (it came to their mailbox).
if h, herr := s.store.GetHostByCustomer(tok.CustomerID); herr == nil && h != nil {
page = bindPageData{State: "oldbox", Lang: s.store.CustomerLanguage(tok.CustomerID)}
s.bindResendMu.Lock()
key := "oldbox:" + tok.CustomerID
last := s.bindResendAt[key]
if now.Sub(last) < bindResendEvery {
s.bindResendMu.Unlock()
return
}
if s.bindResendAt == nil {
s.bindResendAt = map[string]time.Time{}
}
s.bindResendAt[key] = now
s.bindResendMu.Unlock()
s.logger.Printf("[WARN] self-bind: %s asked for a new link but host %s is still linked — no link sent; the operator is told", tok.CustomerID, h.HostID)
if s.emit != nil {
details, _ := json.Marshal(map[string]any{"host_id": h.HostID})
s.emit(tok.CustomerID, eventBindOldHostLinked, "warning",
fmt.Sprintf("Customer %s asked for a new box link, but host %s is still linked to it, so no link was sent. "+
"If the household's old box is gone (a rebuild), delete host %s on the Hosts page, then send a new link from the customer page. "+
"The household was told the old box is still linked and to write to Felhom.", tok.CustomerID, h.HostID, h.HostID),
string(details), "hub")
}
return
}
s.bindResendMu.Lock()
last := s.bindResendAt[tok.CustomerID]
if now.Sub(last) < bindResendEvery {
@@ -391,3 +426,6 @@ func (s *Server) handleBindResend(w http.ResponseWriter, token string) {
s.bindResendMu.Unlock()
s.autoMintSelfBindIfWaiting(tok.CustomerID, "fresh link asked on an expired or used link")
}
// eventBindOldHostLinked (R-1203): operator-only, registered in notify.operatorOnlyEvents in the same commit.
const eventBindOldHostLinked = "bind_old_host_linked"