package web import ( "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "gitea.dooplex.hu/admin/felhom-hub/internal/offsitekeys" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // The one-shot clean-up window on the customer page (R-1299). Until hub v0.174.0 the grant was an address only a // script could call (`POST /offsite/window-grant/`), so on 2026-10-26 the operator had to mint a one-purpose // token to grant demo-hp one window (D55). The customer page's Backup card now shows the window state and a form: an // optional limit, a confirm step that names the customer and the limit, then the same route under the same rule // (`wr(grpOffsite)` — a session, or a token with offsite:write), with the page's CSRF token. Pinned by // offsite_window_test.go. // eventWindowGrant records every grant and every cancel: who, which customer, which limit. Hub-origin, operator-only // (notify.operatorOnlyEvents), severity info — recorded, never mailed. The raised-cap grant keeps its own warning // mail as well (offsitekeys.EventWindowLargeGrant). const eventWindowGrant = "offsite_window_grant" // windowCardView is the customer page's clean-up window block. type windowCardView struct { WeeklyOn bool // the fleet-wide weekly window switch Pending bool // a one-shot grant waits for the box's next off-site run PendingMax int // its raised limit, 0 = the normal cap Last *store.OffsiteWindow // the latest window, nil = never CapKnown bool // the snapshot count is known, so the normal cap is NormalCap int // offsitekeys.MaxRemove(snapshots): half, at least 5 Bound int // the highest limit the form accepts (windowGrantBound) // Confirm is the second step: the page was opened with ?window=confirm; ConfirmLimit is the limit to confirm ("" = // the normal cap), already checked — an invalid one renders the refusal instead (LimitError). Confirm bool ConfirmLimit string LimitError string } // windowGrantBound is the highest raised limit the hub accepts for this customer: never above the store's own // snapshot count when the box has measured it (a window cannot remove more than exist, so a larger number is a typo), // and never above offsitekeys.MaxRemoveGrantCeiling. known=false: only the ceiling applies. func windowGrantBound(card backupCardView) (bound int, known bool) { bound = offsitekeys.MaxRemoveGrantCeiling if card.OffsiteReported && card.StatsKnown && card.Snapshots > 0 && card.Snapshots < bound { return card.Snapshots, true } return bound, false } // checkWindowLimit parses an optional limit: "" = the normal cap (0). The error is the sentence the page shows. func checkWindowLimit(raw string, card backupCardView) (int, error) { raw = strings.TrimSpace(raw) if raw == "" { return 0, nil } n, err := strconv.Atoi(raw) if err != nil || n < 1 { return 0, fmt.Errorf("the limit must be a whole number of 1 or more, or empty for the normal cap") } if bound, known := windowGrantBound(card); n > bound { if known { return 0, fmt.Errorf("the limit %d is above the store's %d snapshot(s) — a window cannot remove more than exist", n, bound) } return 0, fmt.Errorf("the limit %d is above the ceiling %d", n, bound) } return n, nil } func (s *Server) customerBackupCard(customerID string) backupCardView { c, _ := s.store.GetCustomer(customerID) if c == nil { return backupCardView{} } return reportBackupCard(c.ReportJSON) } // buildWindowCard resolves the block for the page (the confirm step reads ?window=confirm&window_max=N). func (s *Server) buildWindowCard(customerID string, card backupCardView, q url.Values) windowCardView { v := windowCardView{WeeklyOn: s.store.OffsiteWindowsEnabled()} v.Pending, v.PendingMax = s.store.PendingOffsiteWindowGrant(customerID) v.Last, _ = s.store.LatestOffsiteWindow(customerID) if card.StatsKnown { v.CapKnown, v.NormalCap = true, offsitekeys.MaxRemove(card.Snapshots) } v.Bound, _ = windowGrantBound(card) if q.Get("window") == "confirm" { if _, err := checkWindowLimit(q.Get("window_max"), card); err != nil { v.LimitError = err.Error() } else { v.Confirm, v.ConfirmLimit = true, strings.TrimSpace(q.Get("window_max")) } } return v } // actorOf names who made a request: the token's name, or the operator's session. func actorOf(r *http.Request) string { if t := requestToken(r); t != nil { return "token " + t.Name } return "operator session" } // handleOffsiteWindowGrant serves POST /offsite/window-grant/ and POST /offsite/windows-enabled // (decision 68; R-833; R-1299). A script gets JSON as before; the page's form (from=page) gets a redirect with a flash. // // max_remove optional raised limit for this ONE window (bounded: windowGrantBound) // cancel=1 remove a waiting grant instead // confirm=1 required from the page: the form's second step func (s *Server) handleOffsiteWindowGrant(w http.ResponseWriter, r *http.Request, path string) { if r.Method != http.MethodPost || s.offsiteWindowGrant == nil { http.Error(w, "unavailable", http.StatusServiceUnavailable) return } if path == "/offsite/windows-enabled" { if err := s.offsiteWindowSwitch(r.FormValue("on") == "1"); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("{\"ok\":true}\n")) return } cid := strings.TrimPrefix(path, "/offsite/window-grant/") page := r.FormValue("from") == "page" answer := func(code int, flash, msg string) { if page { http.Redirect(w, r, "/customers/"+url.PathEscape(cid)+"?flash="+flash+"#tab=backup", http.StatusSeeOther) return } if code >= 400 { http.Error(w, msg, code) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte("{\"ok\":true}\n")) } if cfg, _ := s.store.GetCustomerConfig(cid); cfg == nil { answer(http.StatusNotFound, "window_unknown_customer", "no customer "+cid) return } who := actorOf(r) if r.FormValue("cancel") == "1" { cancelled, err := s.store.CancelOffsiteWindowGrant(cid) if err != nil { answer(http.StatusInternalServerError, "window_failed", err.Error()) return } if !cancelled { answer(http.StatusConflict, "window_none_waiting", "no grant is waiting for "+cid) return } s.logger.Printf("[INFO] offsite window: the waiting grant for %s was CANCELLED by %s", cid, who) s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: the waiting one-time window was cancelled (by %s).", who), map[string]any{"cancelled": true, "by": who}) answer(http.StatusOK, "window_cancelled", "") return } if page && r.FormValue("confirm") != "1" { answer(http.StatusBadRequest, "window_needs_confirm", "confirm=1 required") return } limit, err := checkWindowLimit(r.FormValue("max_remove"), s.customerBackupCard(cid)) if err != nil { s.logger.Printf("[WARN] offsite window: grant for %s REFUSED (%v) — asked by %s", cid, err, who) answer(http.StatusBadRequest, "window_limit_refused", err.Error()) return } if limit > 0 { if s.offsiteWindowGrantMax == nil { answer(http.StatusServiceUnavailable, "window_failed", "a raised limit is unavailable") return } err = s.offsiteWindowGrantMax(cid, limit) } else { err = s.offsiteWindowGrant(cid) } if err != nil { answer(http.StatusBadRequest, "window_failed", err.Error()) return } lim := "the normal cap" if limit > 0 { lim = fmt.Sprintf("a limit of %d", limit) } s.logger.Printf("[INFO] offsite window: ONE clean-up window granted for %s with %s, by %s", cid, lim, who) s.emitWindowEvent(cid, fmt.Sprintf("Off-site clean-up: one window granted with %s (by %s); the box uses it on its next off-site run.", lim, who), map[string]any{"max_remove": limit, "by": who}) answer(http.StatusOK, "window_granted", "") } // emitWindowEvent WRITES the event row (the record the brief asks for) and then hands it to the dispatcher. The // dispatcher only routes mail — it writes no row, and an info event routes nowhere — so the row is this function's job, // as it is offsitekeys.Service.event's. Hub 0.174.0 called only the dispatcher and recorded nothing (seen live on // tester-1 2026-10-27; the test then asserted the call, not the row). Pinned by the TestR1299_* row checks. func (s *Server) emitWindowEvent(customerID, msg string, details map[string]any) { b, _ := json.Marshal(details) if _, err := s.store.SaveEvent(customerID, eventWindowGrant, "info", msg, string(b), "hub"); err != nil { s.logger.Printf("[WARN] offsite window: saving the %s event for %s: %v", eventWindowGrant, customerID, err) } if s.emit != nil { s.emit(customerID, eventWindowGrant, "info", msg, string(b), "hub") } }