package web import ( "net/http" "strings" "github.com/skip2/go-qrcode" "golang.org/x/crypto/bcrypt" "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" ) // Guest launcher share handlers (v0.165.0). See share.go for the security model. This file holds the // HTTP surface: the pre-auth guest pages (/s/) and the admin share-management POSTs // (/launcher/share/*, session-authed). // shareMinPassword is the minimum length of the OPTIONAL per-share password. const shareMinPassword = 8 // setGuestHeaders stamps the guest launcher responses so search engines never index a capability URL, // referrers never leak the token to an opened app, and no intermediary caches the page. func (s *Server) setGuestHeaders(w http.ResponseWriter) { h := w.Header() h.Set("X-Robots-Tag", "noindex, nofollow") h.Set("Referrer-Policy", "no-referrer") h.Set("Cache-Control", "no-store") } // share404 answers exactly like the mux default 404 (http.NotFound) so a wrong or disabled token is // byte-for-byte indistinguishable from any unknown route. The token is redacted from the log. func (s *Server) share404(w http.ResponseWriter, r *http.Request) { s.logger.Printf("[WARN] [web] 404 Not Found: %s /s/", r.Method) http.NotFound(w, r) } // GuestLauncherApp is one tile on the standalone guest launcher. It carries ONLY what a guest may see // — never the internal state vocabulary (stopped/exited/degraded/unhealthy). Clickable apps render as // links to their public URL; the rest are greyed with a calm, non-technical Hungarian label. type GuestLauncherApp struct { DisplayName string Slug string BrandColor string Clickable bool Href string // set only when Clickable Label string // set only when NOT Clickable } // guestLauncherApps maps the shared launcherApps() slice into the guest view. Clickable ⇒ the app is // operational AND its public route is actually published (a healthy, reachable app). isOperational // alone would let an unhealthy/restarting/degraded app through — its URL 404s at Traefik, so a guest // tap would dead-end; routeUnpublished screens exactly those. The label rides the v0.164.0 ruling: // StateStopped is a deliberate owner action ("A tulajdonos leállította"); any other non-clickable // state is a transient the guest need not understand ("Átmenetileg nem elérhető"). func (s *Server) guestLauncherApps() []GuestLauncherApp { return buildGuestApps(s.launcherApps(), s.cfg.Customer.Domain) } // buildGuestApps is the pure mapping from the shared launcher slice to the guest view (no manager, no // request) — the tested seam for the clickability rule and the guest label vocabulary. func buildGuestApps(apps []LauncherApp, domain string) []GuestLauncherApp { out := make([]GuestLauncherApp, 0, len(apps)) for _, a := range apps { g := GuestLauncherApp{DisplayName: a.DisplayName, Slug: a.Slug, BrandColor: a.BrandColor} switch { case isOperationalState(a.State) && !routeUnpublished(a.State): g.Clickable = true g.Href = "https://" + a.Subdomain + "." + domain + a.OpenPath case a.State == stacks.StateStopped: g.Label = "A tulajdonos leállította" default: g.Label = "Átmenetileg nem elérhető" } out = append(out, g) } return out } // shareGuestHandler serves GET /s/: the standalone read-only launcher, or the password gate // when a share password is set and no valid cookie is present. An unknown token → share404. func (s *Server) shareGuestHandler(w http.ResponseWriter, r *http.Request) { token := strings.TrimPrefix(r.URL.Path, "/s/") stored := s.settings.GetLauncherShareToken() if !shareTokenMatches(stored, token) { s.share404(w, r) return } pwHash := s.settings.GetLauncherSharePasswordHash() if pwHash != "" && !s.shareCookieValid(r, stored, pwHash) { s.renderSharePasswordPage(w, r, "") return } s.renderShareGuestPage(w, r) } // shareGuestPasswordHandler handles POST /s/: the optional share-password gate. On success it // sets the signed, ~30-day gate cookie (bound to token|passwordHash). Pre-auth HMAC CSRF + a per-IP // 5/1-min limiter (own map) protect it. func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Request) { token := strings.TrimPrefix(r.URL.Path, "/s/") stored := s.settings.GetLauncherShareToken() if !shareTokenMatches(stored, token) { s.share404(w, r) return } pwHash := s.settings.GetLauncherSharePasswordHash() if pwHash == "" { // No gate — a stray POST just returns to the page (renders directly). s.renderShareGuestPage(w, r) return } _ = r.ParseForm() if !s.validShareCSRF(r) { s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.") return } ip := clientIP(r) if s.shareRateLimited(ip) { s.logger.Printf("[WARN] [web] share password rate limited for %s", ip) s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva") return } if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil { s.shareRegisterFailure(ip) s.renderSharePasswordPage(w, r, "Hibás jelszó") return } s.shareClearFailures(ip) http.SetCookie(w, &http.Cookie{ Name: shareCookieName, Value: s.shareCookieValue(stored, pwHash), Path: "/s/", MaxAge: int(shareCookieMaxAge.Seconds()), HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", }) // Redirect back to the same URL so a reload/back does not re-POST; the cookie now passes the gate. http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) } // renderShareGuestPage renders the standalone guest launcher (own minimal , no admin chrome). func (s *Server) renderShareGuestPage(w http.ResponseWriter, r *http.Request) { s.setGuestHeaders(w) data := map[string]interface{}{ "Domain": s.cfg.Customer.Domain, "Apps": s.guestLauncherApps(), } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "launcher_shared", data); err != nil { s.logger.Printf("[ERROR] [web] Template error (launcher_shared): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // renderSharePasswordPage renders the standalone one-field password gate and sets the pre-auth CSRF // cookie the POST validates. func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request, errMsg string) { s.setGuestHeaders(w) csrf := s.setShareCSRFCookie(w, r) data := map[string]interface{}{ "Action": r.URL.Path, // /s/ — the guest already holds this token in their URL bar "CSRF": csrf, "Error": errMsg, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, "launcher_share_password", data); err != nil { s.logger.Printf("[ERROR] [web] Template error (launcher_share_password): %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) } } // ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ───────── // launcherShareRedirect returns to /launcher with a Hungarian flash (reuses urlQueryEscape). func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) { http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), http.StatusSeeOther) } // launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so // an unauthenticated request → login redirect). The token is never logged. func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request) { token := s.settings.GetLauncherShareToken() if token == "" { http.NotFound(w, r) return } png, err := qrcode.Encode("https://"+r.Host+"/s/"+token, qrcode.Medium, 256) if err != nil { s.logger.Printf("[ERROR] [web] share: QR encode failed: %v", err) http.Error(w, "QR error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "image/png") w.Header().Set("Cache-Control", "no-store") w.Header().Set("X-Robots-Tag", "noindex, nofollow") _, _ = w.Write(png) } // launcherShareEnableHandler mints the first token (POST /launcher/share/enable). func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) { if s.settings.GetLauncherShareToken() != "" { s.launcherShareRedirect(w, r, "A megosztás már be van kapcsolva.") return } tok, err := newShareToken() if err != nil { s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err) s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.") return } if err := s.settings.SetLauncherShareToken(tok); err != nil { s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err) s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.") return } s.logger.Printf("[INFO] [web] launcher share link enabled") s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.") } // launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and // every outstanding guest cookie is invalidated (both are bound to the token). func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) { if s.settings.GetLauncherShareToken() == "" { s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.") return } tok, err := newShareToken() if err != nil { s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err) s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.") return } if err := s.settings.SetLauncherShareToken(tok); err != nil { s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err) s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.") return } s.logger.Printf("[INFO] [web] launcher share link rotated") s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.") } // launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) — // a clean slate so a later re-enable never inherits a stale gate. All /s/ paths then 404. func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) { if err := s.settings.SetLauncherShareToken(""); err != nil { s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err) s.launcherShareRedirect(w, r, "A megosztás kikapcsolása nem sikerült.") return } if err := s.settings.SetLauncherSharePasswordHash(""); err != nil { s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err) } s.logger.Printf("[INFO] [web] launcher share link disabled") s.launcherShareRedirect(w, r, "A megosztás kikapcsolva.") } // launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST // /launcher/share/password). action=clear removes it; otherwise a min-length password is bcrypt-hashed // into its OWN settings field (never the admin hash). Either change invalidates outstanding cookies. func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) { _ = r.ParseForm() if s.settings.GetLauncherShareToken() == "" { s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.") return } if r.FormValue("action") == "clear" { if err := s.settings.SetLauncherSharePasswordHash(""); err != nil { s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err) s.launcherShareRedirect(w, r, "A jelszó törlése nem sikerült.") return } s.logger.Printf("[INFO] [web] launcher share password cleared") s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.") return } pw := r.FormValue("password") if len(pw) < shareMinPassword { s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.") return } hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10) if err != nil { s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err) s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.") return } if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil { s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err) s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.") return } s.logger.Printf("[INFO] [web] launcher share password set") s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.") }