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 } 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" }