package web import ( "encoding/json" "errors" "net/http" "strings" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // AFTER-START FIELDS (v0.315.0, D2 = A) — a value the household can only give once the app RUNS. // // Plex was the first: its claim code is minted on plex.tv and handed to the server that is already // up, so the install wizard cannot ask for it (R-1028/R-1060). The catalog marks such a field // `after_start: true`, the wizard skips it, and the app page's „Beallitasok" tab grows a card for it. // // WHAT THE CATALOG MAY SAY, AND WHAT IT MAY NOT. The catalog declares THAT a field is asked after the // start, and its label, description and placeholder. It does NOT declare what happens to the value. // The action is Go code in the registry below, keyed by slug and env var, and that is the whole point // of the decision: the household's text is an ARGUMENT to a function this repository wrote, never a // fragment of a command, a URL or a container name a template chose. A catalog file cannot make the // box run anything, and adding a template cannot add an action. // // AN UNREGISTERED FIELD IS INERT AND SAID SO. A template that declares `after_start` for an app with // no entry here renders no card and POSTs 404 — and the render logs one WARN naming the slug and the // env var KEY, because a field that silently does nothing is exactly the kind of absence nobody // notices. The VALUE is never logged (it is a credential: Plex's code claims a server). // // Pinned by TestAfterStart_PlexThroughRouter (the production router, the real registry, a fake Plex) // and TestAfterStart_NoActionNoCard (the inert case, with a fake that must record zero calls). // afterStartError is an action's refusal, carrying the HTTP status and the locale key the page shows // inline. An action returns one of these so the route stays copy-free: the sentence the household // reads belongs to the app, not to the plumbing. type afterStartError struct { Status int MsgKey string } func (e *afterStartError) Error() string { return e.MsgKey } // afterStartAction is one registered field: when to ask, and what to do with the answer. type afterStartAction struct { // Needed reports whether the household still has to give this value. False hides the card. Needed func(st *stacks.Stack) bool // Apply does the app-specific thing with the value. It must never log the value. Apply func(s *Server, r *http.Request, st *stacks.Stack, value string) error } // afterStartRegistry maps `/` to its action. Adding an app means adding Go here. var afterStartRegistry = map[string]afterStartAction{ "plex/PLEX_CLAIM": { // Today's condition, unchanged: the card stands while the setup gate does (v0.280.0 decision 46). Needed: func(st *stacks.Stack) bool { return st != nil && st.Deployed && st.AppConfig != nil && st.AppConfig.SetupGate.Closed() }, Apply: func(s *Server, _ *http.Request, _ *stacks.Stack, value string) error { return s.applyPlexClaim(value) }, }, } // afterStartKey is the registry key for one app's field. func afterStartKey(slug, envVar string) string { return slug + "/" + envVar } // afterStartFields returns the after-start fields of this app that have a registered action AND // currently need an answer — the cards the settings tab renders, in catalog order. // // It logs one WARN per render for a declared field with no action (the inert case above). Per // render and not once at load: the catalog is re-synced every fifteen minutes, so there is no // "load" to hang the warning on, and a page nobody opens needs no warning. func (s *Server) afterStartFields(st *stacks.Stack) []stacks.DeployField { if st == nil || !st.Deployed { return nil } var out []stacks.DeployField for _, f := range st.Meta.DeployFields { if !f.AfterStart { continue } act, ok := afterStartRegistry[afterStartKey(st.Meta.Slug, f.EnvVar)] if !ok { // KEY only — never the value, and never the household's text. s.logger.Printf("[WARN] [web] after-start field %s declared for app %q has no registered action: no card is shown and nothing runs", f.EnvVar, st.Meta.Slug) continue } if !act.Needed(st) { continue } out = append(out, f) } return out } // appAfterStartHandler is POST /apps/{slug}/after-start/{ENV}: the household's answer, routed to the // registered action. An unknown app, an unknown field or an unregistered one is a 404 and nothing // runs. JSON in, JSON out, like the press handlers beside it. func (s *Server) appAfterStartHandler(w http.ResponseWriter, r *http.Request, slug, envVar string) { st := s.stackBySlug(slug) if st == nil { http.NotFound(w, r) return } // The field must be DECLARED by the catalog as well as registered here — one of the two alone is // not an invitation to run anything. declared := false for _, f := range st.Meta.DeployFields { if f.AfterStart && f.EnvVar == envVar { declared = true break } } act, ok := afterStartRegistry[afterStartKey(slug, envVar)] if !declared || !ok { s.logger.Printf("[WARN] [web] after-start POST refused for app %q field %s (declared=%v registered=%v)", slug, envVar, declared, ok) http.NotFound(w, r) return } value := strings.TrimSpace(afterStartValueFrom(r)) if err := act.Apply(s, r, st, value); err != nil { var ae *afterStartError if errors.As(err, &ae) { escrowJSON(w, ae.Status, nil, s.msg(r, ae.MsgKey)) return } s.logger.Printf("[WARN] [web] after-start %s/%s failed: %v", slug, envVar, err) escrowJSON(w, http.StatusConflict, nil, s.msg(r, "err.after_start.failed")) return } escrowJSON(w, http.StatusOK, map[string]any{"ok": true}, "") } // afterStartValueFrom reads the single `value` field the card's script POSTs. A body that is not // JSON, or carries no value, yields the empty string — which every action then refuses on its own // terms, so a malformed request cannot reach an action as if it were a valid blank. func afterStartValueFrom(r *http.Request) string { if r == nil || r.Body == nil { return "" } var req struct { Value string `json:"value"` } _ = json.NewDecoder(r.Body).Decode(&req) return req.Value } // stackNameForSlug maps an app's slug to its stack NAME — the identity the /stacks/ POST routes and // the backup engine use. The two are equal for every app in the catalog today, and that is exactly // why it is resolved rather than assumed: a redirect built on the assumption would 404 the first time // they differ, on a page nobody tests. Empty when no stack carries the slug. func (s *Server) stackNameForSlug(slug string) string { if st := s.stackBySlug(slug); st != nil { return st.Name } return "" } // slugForStackName is stackNameForSlug the other way round: it turns the name in an old // /stacks/{name}/… URL into the slug the new app page is keyed by. Empty when unknown. func (s *Server) slugForStackName(name string) string { if name == "" || s.stackMgr == nil { return "" } if st, ok := s.stackMgr.GetStack(name); ok && st != nil { return st.Meta.Slug } return "" }