e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
171 lines
6.9 KiB
Go
171 lines
6.9 KiB
Go
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 `<slug>/<ENV_VAR>` 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 ""
|
|
}
|