210 lines
8.7 KiB
Go
210 lines
8.7 KiB
Go
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/<customer>`), 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/<customer> 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")
|
|
}
|
|
}
|