Files
felhom-controller/controller/internal/web/share_handlers.go
T
admin 15206314ab v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)
Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
2026-07-24 12:08:43 +02:00

295 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.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/<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.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.")
}