hub (unreleased): R-922 option A — a household's clear deletes its notification address (email_cleared); MAIL-HOLD — a restored hub sends no mail until released; two log lines drop the address; runbooks: mail hold is restore step 1; 07 §6.4 R-921 pre-check; R-921/R-922 narrowed
gates / gates (push) Successful in 5m25s
gates / gates (push) Successful in 5m25s
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:
@@ -20,6 +20,7 @@ import (
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/claim"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/gitea"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/intent"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/mailhold"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/poke"
|
||||
@@ -124,13 +125,20 @@ type Server struct {
|
||||
// beforeCreateSave is a TEST seam: called in handleConfigCreate after the duplicate check, before the
|
||||
// save. nil in production.
|
||||
beforeCreateSave func(customerID string)
|
||||
// mailHold is the hub-wide mail gate (internal/mailhold); nil never holds.
|
||||
mailHold *mailhold.Hold
|
||||
// cfAPIBase is a TEST seam for the Cloudflare token reach check (R-138 option C): "" = the real API.
|
||||
cfAPIBase string
|
||||
}
|
||||
|
||||
// New creates a new web server.
|
||||
func New(store *store.Store, passwordHash, apiKey, version string, staleThreshold time.Duration, logger *log.Logger) *Server {
|
||||
// srv is assigned below; template funcs that read server state close over it (they run at render time only).
|
||||
var srv *Server
|
||||
funcMap := template.FuncMap{
|
||||
// mailHeld drives the MAIL-HOLD banner on every operator page (internal/mailhold). Read at render time, so the
|
||||
// banner disappears the moment the marker is gone. Pinned by TestMailHoldBanner_*.
|
||||
"mailHeld": func() bool { return srv != nil && srv.mailHold.Held() },
|
||||
"timeAgo": timeAgo,
|
||||
"timeAgoPtr": func(t *time.Time) string {
|
||||
if t == nil {
|
||||
@@ -160,7 +168,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
|
||||
tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))
|
||||
|
||||
return &Server{
|
||||
srv = &Server{
|
||||
store: store,
|
||||
configPasswordHash: passwordHash,
|
||||
apiKey: apiKey,
|
||||
@@ -171,6 +179,7 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol
|
||||
sessions: make(map[string]*hubSession),
|
||||
bindLimiter: newBindRateLimiter(30), // public /bind/ surface: 30 req/min/IP burst (R-27)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
// effectivePasswordHash returns the operator login password bcrypt hash in force: the UI-set DB
|
||||
@@ -406,6 +415,29 @@ func (s *Server) artifactChoices(ctx context.Context, pkg, file string) []artifa
|
||||
}
|
||||
|
||||
// SetEventEmitter wires the notification dispatcher (R-604). INIT-ONLY.
|
||||
// SetMailHold wires the hub-wide mail gate (internal/mailhold): the banner on every operator page and the release
|
||||
// endpoint. nil = never held (no banner, the release is a no-op).
|
||||
func (s *Server) SetMailHold(m *mailhold.Hold) {
|
||||
s.mailHold = m
|
||||
}
|
||||
|
||||
// handleMailHoldRelease lifts the MAIL-HOLD: it removes the marker, and the hub sends e-mail again from the next mail
|
||||
// on. Mails dropped during the hold are NOT re-sent (internal/mailhold explains why). Operator-only: it sits behind
|
||||
// RequireAuth and the R-135 CSRF gate in ServeHTTP like every other state-changing route.
|
||||
func (s *Server) handleMailHoldRelease(w http.ResponseWriter, r *http.Request) {
|
||||
if s.mailHold == nil || !s.mailHold.Held() {
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_not_held", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := s.mailHold.Release(); err != nil {
|
||||
s.logger.Printf("[ERROR] MAIL-HOLD: release failed — the marker is still in place: %v", err)
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_release_failed", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] MAIL-HOLD released by the operator from %s — the hub sends e-mail again; mail held until now was dropped", r.RemoteAddr)
|
||||
http.Redirect(w, r, "/configuration?flash=mail_hold_released", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) SetEventEmitter(f func(customerID, eventType, severity, message, detailsJSON, source string)) {
|
||||
s.emit = f
|
||||
}
|
||||
@@ -668,6 +700,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/mail-hold/release":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleMailHoldRelease(w, r)
|
||||
} else {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
case path == "/configuration/password":
|
||||
if r.Method == http.MethodPost {
|
||||
s.handleChangePassword(w, r)
|
||||
|
||||
Reference in New Issue
Block a user