Files
felhom-controller/controller/internal/web/share_handlers.go
T
admin 5270bad76e
gates / gates (push) Successful in 23s
v0.252.0 — the sentences the program builds follow the language (R-557 slice 2 release A)
Slice 1 translated the dashboard's markup. The sentences the program BUILDS were still
Hungarian literals in Go, so an English household clicked an English button and was
answered in Hungarian. 226 of them move into the bundle here.

A flash was the hard part: it travels inside the redirect URL and is rendered by a
DIFFERENT request, so it now carries a bundle key plus its parameters. A link minted by
an older controller carries prose and is shown verbatim — never a raw key, never dropped.

Also converted: page data and view-model text, the internal/api JSON answers, the alert
banners (Alert.MessageKey, rendered on the way out of GetAlerts), 237 country names at
display, and the four page titles built around an app name (R-566 closed).

Hungarian is byte-identical, and that is measured rather than read:
scripts/i18n_go_parity.py freezes every Go literal at the base commit (7 467) and refuses
a key whose Hungarian is not that text, byte for byte. Three decoys, each seen to convict.
Its own first version filtered the capture through an ASCII-Hungarian word list and missed
seven real literals — the R-565 class. The filter is gone.

Nothing on the wire moved, and wire goldens now hold it there: the report's health
warnings and every notify event message stay Hungarian, because the hub MAILS the
controller's sentence when it has no entry of its own. Slice 3 (R-558) owns those.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 10:15:56 +02:00

300 lines
12 KiB
Go

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/<token>) 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/<redacted>", 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/<token>: 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/<token>: 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 <html>, 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.executeTemplateLang(w, r, "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/<token> — 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.executeTemplateLang(w, r, "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 flash.
//
// `key` is a bundle KEY, not a sentence (v0.252.0, R-557): the redirect is read by a DIFFERENT
// request, and only that request knows the household's language. The launcher resolves it with
// s.flashText, which shows a sentence minted by an older controller verbatim — so a link already in
// a customer's tab still reads correctly.
func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, key string) {
http.Redirect(w, r, "/launcher?"+flashQuery("flash", key), 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, "flash.share.already_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.enable_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link enabled")
s.launcherShareRedirect(w, r, "flash.share.enabled")
}
// 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, "flash.share.not_on")
return
}
tok, err := newShareToken()
if err != nil {
s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
if err := s.settings.SetLauncherShareToken(tok); err != nil {
s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err)
s.launcherShareRedirect(w, r, "flash.share.relink_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share link rotated")
s.launcherShareRedirect(w, r, "flash.share.relinked")
}
// 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, "flash.share.disable_failed")
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, "flash.share.disabled")
}
// 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, "flash.share.not_on")
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, "flash.share.pw_clear_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password cleared")
s.launcherShareRedirect(w, r, "flash.share.pw_cleared")
return
}
pw := r.FormValue("password")
if len(pw) < shareMinPassword {
s.launcherShareRedirect(w, r, "flash.share.pw_too_short")
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, "flash.share.pw_set_failed")
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, "flash.share.pw_set_failed")
return
}
s.logger.Printf("[INFO] [web] launcher share password set")
s.launcherShareRedirect(w, r, "flash.share.pw_set")
}