f30a80841e
gates / gates (push) Successful in 50s
FileBrowser (and traefik, cloudflared, samba) has no app.yaml, so Deployed is false. Since v0.315.0 the app page read Deployed alone and showed FileBrowser "not installed", with Install and without its login. A protected infra stack is now installed by definition on the app page (Deployed || Protected, as every other surface already counted): settings/guide/log tabs, Restart only, no install page, no data tab. The Install press would have deployed the infra stack as an app: DeployStack and the deploy API now refuse a protected stack. In the operator state (the box does not know FileBrowser's password - both demo boxes) the login panel offers "Új jelszó beállítása": the file browser's own CLI with the server stopped (spiked on demo-hp: new 200, old 401, users/shares/settings unchanged), verified, stored encrypted, state generated. Every installed app's settings tab has the login panel with exactly one answer: the box's fields, the login the app wrote (InitialCreds), or a sentence chosen by the catalog's new app_info.login_kind (first_visit default, email, none; none behind the family gate says the family sign-in is the login). "Fájlok ↗" opens the library folder, else the first declared folder, else a media app's first present drive's media folder. The data tab lists each drive's media folders; a folder whose drive is not bound into FileBrowser is text, never a link. Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
192 lines
7.4 KiB
Go
192 lines
7.4 KiB
Go
package web
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
|
)
|
|
|
|
// THE LOGIN PANEL AND THE „CIM ES KULCSOK" ROWS (v0.319.0) — the settings tab's view of what was
|
|
// set at install.
|
|
//
|
|
// Until v0.318.0 the settings tab re-rendered the install wizard's form with every input disabled:
|
|
// the generated login name sat in „Automatikusan generalt ertekek" next to a disabled Domain input,
|
|
// and the password sat three sections lower among the other install answers. The household had to
|
|
// find two halves of one login. Now one panel holds the pair, and the rest are label/value rows.
|
|
//
|
|
// THE EMPTY-VALUE RULE. A field the box holds no value for is SAID, never offered an eye: the reveal
|
|
// endpoint answers 404 for it („Ehhez a mezohoz nincs mentett ertek"), which is a refusal the
|
|
// household could only meet by pressing. Measured 2026-10-16 on demo-hp: Calibre-Web was installed
|
|
// on 2026-08-21, its template gained ADMIN_PASSWORD on 2026-09-29, and InjectMissingFields never
|
|
// fills a `type: password` field — so the box never had one (audits/app-page-tuning-2026-10-16/P0).
|
|
// The page and the endpoint decide „stored" with ONE function, storedValuePresent, so the page
|
|
// cannot offer what the endpoint would refuse. Pinned by TestAppPage_NoStoredValueShowsSentence.
|
|
//
|
|
// SECRETS. A `type: secret` name and every `type: password` value stay off the page (R-254, R-709);
|
|
// only the boolean Stored reaches the template. A `type: text` name is not a secret — the old
|
|
// disabled input carried it as its value — and is shown as text.
|
|
|
|
// storedValuePresent is the one definition of „the box holds a value for this field", shared by the
|
|
// settings tab and appAutoFieldRevealHandler's 404 branch.
|
|
func storedValuePresent(val string) bool { return strings.TrimSpace(val) != "" }
|
|
|
|
// AppLoginField is one half of the login panel. Value is set ONLY for a plain-text name; a secret
|
|
// or a password never carries its value here.
|
|
type AppLoginField struct {
|
|
EnvVar string
|
|
Label string
|
|
IsName bool // the name half; false for the password half
|
|
OnDemand bool // fetched on demand through /stacks/{name}/auto-field/reveal
|
|
Value string // a `type: text` name only
|
|
Stored bool // the box holds a non-blank value
|
|
Restored bool // R-694: a restore generated it, so it is not the app's login and is never shown
|
|
}
|
|
|
|
// AppSettingRow is one label/value row of „Cim es kulcsok". An OnDemand row carries no value; the
|
|
// eye fetches it.
|
|
type AppSettingRow struct {
|
|
EnvVar string
|
|
Label string
|
|
Value string
|
|
OnDemand bool
|
|
}
|
|
|
|
// loginNameSuffixes are the env-var endings the catalog uses for the name next to a password:
|
|
// ADMIN_USER + ADMIN_PASSWORD (calibre-web), ADMIN_EMAIL + ADMIN_PASSWORD (kimai),
|
|
// NEXTCLOUD_ADMIN_USER + NEXTCLOUD_ADMIN_PASSWORD, and so on — measured over the catalog on
|
|
// 2026-10-16, every pair shares the prefix.
|
|
var loginNameSuffixes = []string{"USER", "USERNAME", "EMAIL"}
|
|
|
|
// loginFieldIndexes returns, in deploy_fields order, the indexes of the login fields: every
|
|
// `type: password` field (not asked after start), each preceded by its name field when the
|
|
// template has one with the same prefix and type `secret` or `text`. A password with no clean
|
|
// pair stands alone.
|
|
func loginFieldIndexes(meta *stacks.Metadata) []int {
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
byEnv := make(map[string]int, len(meta.DeployFields))
|
|
for i, f := range meta.DeployFields {
|
|
byEnv[f.EnvVar] = i
|
|
}
|
|
var out []int
|
|
for i, f := range meta.DeployFields {
|
|
if f.Type != "password" || f.AfterStart {
|
|
continue
|
|
}
|
|
// ADMIN_PASSWORD -> prefix "ADMIN_" -> ADMIN_USER / ADMIN_USERNAME / ADMIN_EMAIL.
|
|
prefix := strings.TrimSuffix(f.EnvVar, "PASSWORD")
|
|
if prefix != f.EnvVar {
|
|
for _, suf := range loginNameSuffixes {
|
|
if j, ok := byEnv[prefix+suf]; ok {
|
|
if t := meta.DeployFields[j].Type; (t == "secret" || t == "text") && !meta.DeployFields[j].AfterStart {
|
|
out = append(out, j)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
out = append(out, i)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// appLoginFields builds the panel from the DECRYPTED env (before any value is removed from it).
|
|
func appLoginFields(meta *stacks.Metadata, env map[string]string, restored map[string]bool) []AppLoginField {
|
|
var out []AppLoginField
|
|
for _, i := range loginFieldIndexes(meta) {
|
|
f := meta.DeployFields[i]
|
|
lf := AppLoginField{EnvVar: f.EnvVar, Label: f.Label, IsName: true, Stored: storedValuePresent(env[f.EnvVar])}
|
|
switch f.Type {
|
|
case "password":
|
|
lf.IsName, lf.OnDemand, lf.Restored = false, true, restored[f.EnvVar]
|
|
case "secret":
|
|
lf.OnDemand = true
|
|
default:
|
|
lf.Value = env[f.EnvVar]
|
|
}
|
|
out = append(out, lf)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// appSettingRows is everything else an installed app was given at install, as rows: the generated
|
|
// secrets (eye) and the plain answers (text). Skipped, because the page says them elsewhere: the
|
|
// domain and the subdomain (the „Cim" row), the data folder (the „Tarhely" row), the login fields
|
|
// (the login panel), and a field asked after start (its own card).
|
|
func appSettingRows(meta *stacks.Metadata, env map[string]string, yes, no string) []AppSettingRow {
|
|
if meta == nil {
|
|
return nil
|
|
}
|
|
login := map[int]bool{}
|
|
for _, i := range loginFieldIndexes(meta) {
|
|
login[i] = true
|
|
}
|
|
var out []AppSettingRow
|
|
for i, f := range meta.DeployFields {
|
|
if login[i] || f.AfterStart {
|
|
continue
|
|
}
|
|
val := env[f.EnvVar]
|
|
switch f.Type {
|
|
case "domain", "subdomain", "path", "password":
|
|
continue
|
|
case "secret":
|
|
if storedValuePresent(val) {
|
|
out = append(out, AppSettingRow{EnvVar: f.EnvVar, Label: f.Label, OnDemand: true})
|
|
}
|
|
continue
|
|
case "select":
|
|
for _, o := range f.Options {
|
|
if o.Value == val {
|
|
val = o.Label
|
|
break
|
|
}
|
|
}
|
|
case "boolean":
|
|
if val == "true" {
|
|
val = yes
|
|
} else {
|
|
val = no
|
|
}
|
|
}
|
|
if !storedValuePresent(val) {
|
|
continue
|
|
}
|
|
out = append(out, AppSettingRow{EnvVar: f.EnvVar, Label: f.Label, Value: val})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// THE LOGIN ANSWER (v0.320.0) — every installed app's settings tab says where its login is, in ONE panel with ONE
|
|
// answer. The box can show a password only if it knows it, so the answers are, in this order:
|
|
//
|
|
// fields a `type: password` field (and its name): the value fetched on demand, or — when the box holds none —
|
|
// the v0.319.0 sentence (the empty-value rule above)
|
|
// initial the app wrote a login at its first start and the box reads it (InitialCreds); FileBrowser's own
|
|
// (R-513) is this answer too, and in its `operator` state it is a sentence and the „new password" press
|
|
// first_visit the household makes the admin on the first visit — the catalog's app_info.login_kind, default
|
|
// email the app signs in by an e-mailed link
|
|
// none the app has no sign-in
|
|
//
|
|
// An infra stack with no login of its own (traefik, cloudflared, samba) gets no panel: it is not an app anyone signs
|
|
// into. Pinned by TestAppPage_EveryInstalledAppHasOneLoginAnswer.
|
|
const (
|
|
loginAnswerFields = "fields"
|
|
loginAnswerInitial = "initial"
|
|
)
|
|
|
|
// loginAnswerFor picks the answer from the page data the tab already assembled ("" = no panel).
|
|
func loginAnswerFor(data map[string]interface{}, meta *stacks.Metadata) string {
|
|
if f, _ := data["LoginFields"].([]AppLoginField); len(f) > 0 {
|
|
return loginAnswerFields
|
|
}
|
|
if ic, _ := data["InitialCreds"].(*stacks.ExtractedCreds); ic != nil {
|
|
return loginAnswerInitial
|
|
}
|
|
if infra, _ := data["Infra"].(bool); infra || meta == nil {
|
|
return ""
|
|
}
|
|
return meta.AppInfo.EffectiveLoginKind()
|
|
}
|