Files
felhom-controller/controller/internal/web/app_page.go
T
admin 7e791d9b5d
gates / gates (push) Successful in 43s
v0.321.0: FileBrowser's stale password and its own banners; one backup section on the Data tab; install address of an installed app
R-1101: the change-it line is gone for FileBrowser; in the generated state the page asks FileBrowser whether
the stored password still logs in (only its own 401/403 counts) and, refused, says so and offers the
new-password press. The rendered FileBrowser config switches off its update notice and its Install-app
prompt. The Data tab's backup part is one section in plain words (no off-drive, no PBS); the export
password eye is a text button. R-1095: /apps/{slug}/install of an installed app 302s to its settings tab.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-16 17:17:26 +02:00

352 lines
15 KiB
Go

package web
import (
"net/http"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// THE APP PAGE (v0.315.0) — one page per app, four tabs, each tab its own URL.
//
// Until v0.314.0 one app was spread over six pages: the details page, the install/settings page, the
// logs page, the export page, the per-app backup panel, and a details card for moving data. Every
// „Vissza" on them went to the app LIST, so there was no way back to the app, and Start/Restart/Stop
// sat in a page header looking like menu buttons.
//
// /apps/{slug} the default tab — settings when installed, the guide when not
// /apps/{slug}/settings „Beallitasok" — Mukodes, the cards that need the household, the form
// /apps/{slug}/data „Adatok es mentes" — data folders, move, backup settings, export
// /apps/{slug}/guide „Utmutato" — description, screenshots, first steps, guide, FAQ
// /apps/{slug}/logs „Naplo" — the log, polled only here
// /apps/{slug}/install the install wizard (a page, not a tab)
//
// WHY SERVER-RENDERED TABS AND NOT PANELS. Links in alerts and e-mails have to land ON a tab, and the
// log poller must not run on the other three. One page with hidden panels gives neither.
//
// THE DEFAULT TAB IS RENDERED, NOT REDIRECTED. `/apps/{slug}` serves the settings tab's content with
// the URL unchanged, so the address an alert, a bookmark or the app list carries stays the app's one
// address.
//
// THE OLD URLS STILL WORK, AND ONLY FOR READS. Every `/stacks/{name}/…` GET answers 302 to its tab —
// never 301, which browsers cache beyond anyone's reach. Every POST on those paths is untouched: the
// backup form, the app-email toggle and the reveal endpoints keep their URLs and their handlers.
const (
appTabSettings = "settings"
appTabData = "data"
appTabGuide = "guide"
appTabLogs = "logs"
appTabInstall = "install"
)
// appTabOrder is the tab bar, left to right. The guide is last of the three „about" tabs and the log
// after it, because the household opens the log least often and the order must not move under them.
var appTabOrder = []string{appTabSettings, appTabData, appTabGuide, appTabLogs}
// appPageHandler serves `/apps/{slug}` and `/apps/{slug}/{tab}`.
//
// A NOT-INSTALLED app has one view and one URL: every tab suffix answers 302 to the base, because a
// settings tab for an app with no settings and a log tab for a container that does not exist are
// four ways to reach the same empty page.
func (s *Server) appPageHandler(w http.ResponseWriter, r *http.Request, slug, tab string) {
found, data, ok := s.appCommonData(w, r, slug)
if !ok {
return
}
// v0.320.0: a protected infra stack is installed by definition — it is part of the box (appCommonData sets
// Infra). It gets the settings, guide and log tabs; never the data tab (its export, move and backup are not the
// household's), and never the install wizard. Pinned by TestAppPage_InfraAppHasNoInstall.
infra, _ := data["Infra"].(bool)
installed := found.Deployed || infra
if !installed {
if tab != "" {
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
return
}
tab = appTabGuide
}
if infra && tab == appTabData {
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
return
}
if tab == "" {
tab = appTabSettings
}
data["ActiveTab"] = tab
tabs := s.appTabLinks(r, slug, installed, tab)
if infra {
kept := tabs[:0]
for _, t := range tabs {
if t.Key != appTabData {
kept = append(kept, t)
}
}
tabs = kept
}
data["AppTabs"] = tabs
data["Deployed"] = installed
// The shell's „Megnyitas" shows only for an app that is actually up — a link to a stopped app's
// address lands on the catch-all page, which is a worse answer than no link.
data["AppOperational"] = installed && isOperationalState(found.State)
switch tab {
case appTabSettings:
s.renderAppSettingsTab(w, r, found, data)
case appTabData:
s.renderAppDataTab(w, r, found, data)
case appTabGuide:
s.renderAppGuideTab(w, r, found, data)
case appTabLogs:
s.renderAppLogsTab(w, r, found, data)
default:
http.NotFound(w, r)
}
}
// AppTabLink is one entry of the tab bar.
type AppTabLink struct {
Key string // the ASCII tab id, also the `data-tab` attribute a test asserts on
Href string
Label string // filled per request, in the household's language
Active bool
}
// appTabLinks builds the tab bar. Empty for a not-installed app: it has one view, so a bar of one
// tab would be furniture.
//
// The LABEL is taken here and not in the template, with a literal key per tab, because a template
// that composed its key (`T (printf "app_page.tab_%s" .Key)`) would be invisible to the
// marker-coverage gate — the key would exist in no greppable form and a missing translation would
// render the key itself to the household.
func (s *Server) appTabLinks(r *http.Request, slug string, deployed bool, active string) []AppTabLink {
return s.appTabLinksLang(s.langFor(r), slug, deployed, active)
}
// appTabLinksLang is appTabLinks for a language that is already known — the i18n parity harness
// renders one case in both languages from one request, so it cannot go through langFor.
func (s *Server) appTabLinksLang(lang, slug string, deployed bool, active string) []AppTabLink {
if !deployed {
return nil
}
labels := map[string]string{
appTabSettings: s.msgLang(lang, "app_page.tab_settings"),
appTabData: s.msgLang(lang, "app_page.tab_data"),
appTabGuide: s.msgLang(lang, "app_page.tab_guide"),
appTabLogs: s.msgLang(lang, "app_page.tab_logs"),
}
base := s.cfg.AppPageURL(slug)
out := make([]AppTabLink, 0, len(appTabOrder))
for _, key := range appTabOrder {
out = append(out, AppTabLink{Key: key, Href: base + "/" + key, Label: labels[key], Active: key == active})
}
return out
}
// renderAppSettingsTab — „Mukodes" first (the three buttons that used to sit in the page header),
// then the cards that still want something from the household, then the install-time and optional
// settings the install page used to show in its deployed mode.
func (s *Server) renderAppSettingsTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
// The form half of this tab is deployPageData's — the same keys the install page gets, with
// AlreadyDeployed true. Merged OVER the common data, except the four keys that say which page
// this is: those belong to the tab, not to the form.
//
// v0.320.0: an infra stack has no install form, no app.yaml and no catalog fields — its tab is „Mukodes"
// (restart only) and, for FileBrowser, the login. deployPageData is not asked.
if infra, _ := data["Infra"].(bool); !infra {
if form, ok := s.deployPageData(w, r, found.Name); ok {
mergeAppPageData(data, form)
} else {
return
}
data["AfterStartFields"] = s.afterStartFields(found)
}
data["LoginAnswer"] = loginAnswerFor(data, &found.Meta)
data["Title"] = found.Meta.DisplayName
data["TitleKey"], data["TitleArgs"] = "page.title.app_settings", []interface{}{found.Meta.DisplayName}
s.executeTemplate(w, r, "app_settings", data)
}
// renderAppDataTab — where the files are, how to move them, how they are backed up, how to take them
// out. The three used to be a card on the details page, a panel reached from the backup page, and a
// page of its own.
func (s *Server) renderAppDataTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
data["StackName"] = found.Name
data["DisplayName"] = found.Meta.DisplayName
if s.backupMgr != nil {
info := s.backupMgr.Tier2Info(found.Name)
data["Tier2"] = info
if !info.IsHDDApp {
// R-499: say where THIS box's whole-system backup goes, not where one usually does.
data["SystemBackup"] = systemBackupFact(s.resolveBackupTargetState(r.Context()))
}
}
if s.appExporter != nil {
data["ExportOffered"] = true
data["Drives"] = s.storageDriveList()
}
if flash := s.flashFrom(r, "flash"); flash != "" {
data["Flash"] = flash
}
if flashErr := s.flashFrom(r, "flash_error"); flashErr != "" {
data["FlashError"] = flashErr
}
data["Title"] = found.Meta.DisplayName
data["TitleKey"], data["TitleArgs"] = "page.title.app_data", []interface{}{found.Meta.DisplayName}
s.executeTemplate(w, r, "app_data", data)
}
// renderAppGuideTab — the app's own copy: what it is for, what it looks like, the first steps, the
// guide, the FAQ and the docs link. For a not-installed app this is the whole page, with „Telepites".
func (s *Server) renderAppGuideTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
// A translation longer than the Hungarian guide is dropped silently by the overlay — say so once
// here, where there is a logger and a request language.
if secs, faq := found.Meta.GuideOverlayExtras(s.langFor(r)); secs > 0 || faq > 0 {
s.logger.Printf("[WARN] [web] guide overlay for app %q in %q carries %d section(s) and %d FAQ entr(y/ies) beyond the Hungarian count — the extras are not rendered", found.Meta.Slug, s.langFor(r), secs, faq)
}
data["Title"] = found.Meta.DisplayName
data["TitleKey"], data["TitleArgs"] = "page.title.app_guide", []interface{}{found.Meta.DisplayName}
s.executeTemplate(w, r, "app_guide", data)
}
// renderAppLogsTab — today's logs page inside the shell. `?raw=1` still answers plain text, because
// that is what the tab's own poller asks for (and what the old /stacks/{name}/logs?raw=1 asked for,
// whose redirect carries the query through).
func (s *Server) renderAppLogsTab(w http.ResponseWriter, r *http.Request, found *stacks.Stack, data map[string]interface{}) {
logs, err := s.stackMgr.GetLogs(found.Name, 200)
if err != nil {
logs = s.msg(r, "logs.fetch_failed", err)
}
if r.URL.Query().Get("raw") == "1" {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(logs))
return
}
data["Logs"] = logs
data["Title"] = found.Meta.DisplayName
data["TitleKey"], data["TitleArgs"] = "page.title.logs", []interface{}{found.Meta.DisplayName}
s.executeTemplate(w, r, "app_logs", data)
}
// appInstallPageHandler is `/apps/{slug}/install` — the install wizard, reached from the guide tab's
// „Telepites" button. It renders what the deploy page always rendered for a not-installed app; the
// difference is where its Vissza goes, which is now the app, not the list.
func (s *Server) appInstallPageHandler(w http.ResponseWriter, r *http.Request, slug string) {
name := s.stackNameForSlug(slug)
if name == "" {
http.NotFound(w, r)
return
}
// v0.320.0: part of the box, never installed — the address answers with the app's page, like a tab of a
// not-installed app does. The deploy POST refuses it too (api deployStack, stacks.DeployStack).
if s.cfg.IsProtectedStack(name) {
http.Redirect(w, r, s.cfg.AppPageURL(slug), http.StatusFound)
return
}
// v0.321.0 (R-1095): an INSTALLED app's install address — typed, bookmarked, or an old link — goes to its
// settings tab, the redirectStackDeployGET shape. It used to render the install form read-only, with no button.
if st := s.stackBySlug(slug); st != nil && st.Deployed {
http.Redirect(w, r, s.cfg.AppPageURL(slug)+"/"+appTabSettings, http.StatusFound)
return
}
s.deployHandler(w, r, name)
}
// appPageDataKeysOwnedByTab are the keys that say WHICH page this is. deployPageData sets them for
// the install page; on a tab they would overwrite the tab's own title and nav highlight, so the merge
// keeps the tab's.
var appPageDataKeysOwnedByTab = map[string]bool{
"Page": true, "Title": true, "TitleKey": true, "TitleArgs": true,
"ActiveTab": true, "AppTabs": true, "Deployed": true, "AppOperational": true,
}
// mergeAppPageData copies src into dst, leaving the tab-owned keys alone. A key src does not set is
// never written, so the common data's value survives — which is what keeps EffectiveSubdomain (set
// always by appCommonData, only sometimes by deployPageData) correct on the settings tab.
func mergeAppPageData(dst, src map[string]interface{}) {
for k, v := range src {
if appPageDataKeysOwnedByTab[k] {
continue
}
dst[k] = v
}
}
// ── the old URLs ──────────────────────────────────────────────────────────────────────────────────
// redirectStackGETToAppTab answers an old `/stacks/{name}/{suffix}` GET with a 302 to the matching
// tab. The query string is carried through — `?raw=1` on the logs URL is the poller of a page that
// was open when the box updated, and `?flash=…` comes back from a save.
//
// 302, never 301: a 301 is cached by the browser for as long as it likes, and a wrong one cannot be
// taken back. These paths will be redirects for one release, not forever.
func (s *Server) redirectStackGETToAppTab(w http.ResponseWriter, r *http.Request, name, tab string) {
slug := s.slugForStackName(name)
if slug == "" {
http.NotFound(w, r)
return
}
dest := s.cfg.AppPageURL(slug)
if tab != "" {
dest += "/" + tab
}
if q := r.URL.RawQuery; q != "" {
dest += "?" + q
}
http.Redirect(w, r, dest, http.StatusFound)
}
// redirectStackDeployGET sends the old settings/install URL to whichever of the two it now is: an
// installed app has a settings tab, a not-installed one has the install wizard.
func (s *Server) redirectStackDeployGET(w http.ResponseWriter, r *http.Request, name string) {
st := s.stackBySlug(s.slugForStackName(name))
if st == nil {
http.NotFound(w, r)
return
}
tab := appTabInstall
if st.Deployed {
tab = appTabSettings
}
s.redirectStackGETToAppTab(w, r, name, tab)
}
// appTabPathSuffix splits `{slug}` or `{slug}/{tab}` out of the path after `/apps/`, and reports
// whether the suffix is one this router knows. An unknown suffix is NOT a tab and must not be read as
// part of a slug — `/apps/grocy/nonsense` is a 404, not the app page for „grocy/nonsense".
func appTabPathSuffix(rest string) (slug, tab string, known bool) {
rest = strings.Trim(rest, "/")
if rest == "" {
return "", "", false
}
slug, tail, found := strings.Cut(rest, "/")
if !found {
return slug, "", true
}
switch tail {
case appTabSettings, appTabData, appTabGuide, appTabLogs, appTabInstall:
return slug, tail, true
}
return slug, tail, false
}
// appPageURLFor is the household-facing URL of one tab, for a handler that redirects after a save.
func (s *Server) appPageURLFor(slug, tab string) string {
dest := s.cfg.AppPageURL(slug)
if tab != "" {
dest += "/" + tab
}
return dest
}
// appPageURLForName is appPageURLFor keyed by stack name, for the two save handlers that know the
// name and not the slug. It falls back to the app list rather than inventing a URL.
func (s *Server) appPageURLForName(name, tab string) string {
if slug := s.slugForStackName(name); slug != "" {
return s.appPageURLFor(slug, tab)
}
return "/stacks"
}