Ctrl K
ComunidadGuías…

internal/server/server.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// Package server is the shell: routing, layout, settings.
//
// It knows nothing about any particular system. Navigation is assembled from
// what the systems declare, and the settings form is generated from their
// config schemas, so adding a system requires no change in this package.
package server

import (
	"bytes"
	"context"
	"fmt"
	"html/template"
	"log/slog"
	"net/http"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"armdash/internal/auth"
	"armdash/internal/config"
	"armdash/internal/forwarded"
	"armdash/internal/links"
	"armdash/internal/system"
	"armdash/internal/version"
	"armdash/web"
)

const (
	prometheusURLKey  = "core.prometheus_url"
	dataDirKey        = "core.data_dir"
	trustedProxiesKey = "core.trusted_proxies"
)

// themeCookie holds a light/dark override, or is absent to follow the browser.
//
// A cookie rather than localStorage because the page is server rendered: the
// theme arrives as an attribute in the HTML itself, so there is no moment where
// a dark page has already painted white and waits for a script to correct it.
// localStorage is invisible to the server and would need a blocking script in
// the head to avoid exactly that flash.
const themeCookie = "armdash_theme"

// theme returns "light", "dark", or "" to follow the browser.
//
// The value is whitelisted rather than passed through: it ends up in an
// attribute, and a cookie is client-controlled input like any other.
func theme(r *http.Request) string {
	c, err := r.Cookie(themeCookie)
	if err != nil {
		return ""
	}
	switch c.Value {
	case "light", "dark":
		return c.Value
	}
	return ""
}

type Server struct {
	cfg      *config.Config
	log      *slog.Logger
	tmpl     *template.Template
	files    []string
	sessions *auth.Sessions
	limiter  *auth.Limiter
	xorigin  *http.CrossOriginProtection
	trusted  forwarded.Trusted
	// loginOff is AD_CORE_AUTH_MODE=none: whoever reaches the page is the
	// owner, because something in front of armdash already checked.
	loginOff bool

	// saveMu serialises saving, so two forms submitted at once cannot each
	// build a tree from half of the other's values.
	saveMu sync.Mutex
	cur    atomic.Pointer[tree]
}

// tree is everything built from the current settings: the routes, the links
// and a fresh instance of every system. A save builds a new one and swaps it
// in; requests already running finish on the old one. Sessions, the login
// limiter and the templates live on the Server, outside it, so a save logs
// nobody out.
type tree struct {
	*Server
	mux     *http.ServeMux
	links   []links.Link
	systems []system.System
}

func New(cfg *config.Config, log *slog.Logger, files []string) (*Server, error) {
	tmpl, err := template.New("").Funcs(system.FuncMap()).
		ParseFS(web.Templates, "templates/*.html")
	if err != nil {
		return nil, fmt.Errorf("parse templates: %w", err)
	}
	loginOff, err := authMode(cfg)
	if err != nil {
		return nil, err
	}
	if err := checkLogin(cfg, loginOff); err != nil {
		return nil, err
	}
	trusted, err := forwarded.Parse(cfg.Get(trustedProxiesKey))
	if err != nil {
		return nil, fmt.Errorf("%s: %w", config.EnvName(trustedProxiesKey), err)
	}
	s := &Server{cfg: cfg, log: log, tmpl: tmpl, files: files,
		sessions: auth.NewSessions(auth.SessionTTL),
		limiter:  auth.NewLimiter(maxFailures, failWindow, lockFor),
		xorigin:  http.NewCrossOriginProtection(),
		trusted:  trusted,
		loginOff: loginOff}
	if err := s.rebuild(); err != nil {
		return nil, err
	}
	switch {
	case loginOff:
	case s.setupMode():
		log.Info("no login yet, open /settings to create one")
	case !s.hasLogin():
		log.Info("no login and no data directory, editing is off")
	}
	return s, nil
}

// rebuild swaps in a tree built from the current settings, or keeps the old
// one and reports why the new one could not be built.
func (s *Server) rebuild() error {
	ls, err := links.Parse(s.cfg)
	if err != nil {
		return err
	}
	t := &tree{Server: s, mux: http.NewServeMux(), links: ls, systems: system.New()}
	t.routes()
	s.cur.Store(t)
	return nil
}

func (s *Server) tree() *tree { return s.cur.Load() }

// setupExempt is what a visitor may still reach before the first login
// exists: the setup form itself, its assets and the Prometheus scrape.
func setupExempt(path string) bool {
	return path == "/settings" || path == "/metrics" || strings.HasPrefix(path, "/settings/") ||
		strings.HasPrefix(path, "/static/")
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if safeMethod(r.Method) && !setupExempt(r.URL.Path) && s.setupMode() {
		http.Redirect(w, r, "/settings", http.StatusFound)
		return
	}
	s.tree().mux.ServeHTTP(w, s.withSession(r))
}

// PromURL is handed to systems so they always read the current value rather
// than a copy taken at startup.
func (s *Server) PromURL() string { return s.cfg.Get(prometheusURLKey) }

func (t *tree) routes() {
	s := t.Server
	t.mux.Handle("GET /static/", http.FileServerFS(web.Static))
	t.mux.HandleFunc("GET /{$}", t.handleRoot)
	t.mux.HandleFunc("GET /settings", t.handleSettings)
	t.mux.HandleFunc("GET /settings/status", t.handleStatus)
	type route struct {
		path string
		h    http.HandlerFunc
	}
	login := []route{
		{"POST /settings/setup", t.handleSetup},
		{"POST /settings/login", t.handleChangeLogin},
		{"POST /login", t.handleLogin},
		{"POST /logout", t.handleLogout},
	}
	// Registered as 404 rather than left out, which would answer 405 because
	// GET /login exists.
	if s.loginOff {
		for i := range login {
			login[i].h = http.NotFound
		}
	}
	for _, route := range append(login,
		route{"POST /settings/core", t.handleSaveCore},
		route{"POST /settings/system/{id}", t.handleSaveSystem},
		route{"POST /settings/links", t.handleSaveLinks},
	) {
		t.mux.Handle(route.path, s.xorigin.Handler(route.h))
	}
	t.mux.HandleFunc("GET /login", t.handleLoginPage)
	t.mux.HandleFunc("GET /metrics", t.handleMetrics)
	t.mux.HandleFunc("GET /s/{system}/{$}", t.handleSystemDefault)
	t.mux.HandleFunc("GET /s/{system}/{slug}", t.handleSystemPage)
	t.mux.HandleFunc("GET /l/{link}/{rest...}", t.handleLink)

	// Every method, not only GET: saving a wiki page is a POST.
	for _, l := range t.links {
		if l.Mode == links.ModeProxy {
			t.mux.Handle(l.Prefix()+"/", l.Handler(s.log.With("link", l.ID), s.trusted))
		}
	}

	// Each system gets its own subtree for fragments and JSON.
	dataDir := DataDir(s.cfg)
	for _, sys := range t.systems {
		prefix := "/s/" + sys.ID() + "/api/"
		deps := system.Deps{
			Config:  s.cfg.Scoped(sys.ID()),
			Log:     s.log.With("system", sys.ID()),
			PromURL: s.PromURL,
			Save:    s.saveScoped("system." + sys.ID() + "."),
		}
		if dataDir != "" {
			deps.DataDir = filepath.Join(dataDir, sys.ID())
		}
		api := http.NewServeMux()
		sys.Register(api, prefix, deps)
		t.mux.Handle(prefix, s.api(sys.ID(), api))
	}
}

// api guards a system's own endpoints. The checks sit here rather than in each
// system, so a new write endpoint is covered without anyone having to remember
// it: a write needs a session, and it has to come from this site. A request
// with neither Sec-Fetch-Site nor Origin, curl for instance, is not a browser
// being tricked and passes the second check, never the first. With the login
// off only the second is left, so it stays outside the edit mark.
func (s *Server) api(id string, h http.Handler) http.Handler {
	protected := s.xorigin.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch {
		case safeMethod(r.Method):
		case !s.loginOff && !s.hasLogin():
			s.refuse(w, r, http.StatusForbidden, "Editing is off",
				"No login exists yet, create one on the settings page.")
			return
		case !system.CanEdit(r):
			s.refuse(w, r, http.StatusUnauthorized, "Not logged in", "Log in to make changes.")
			return
		}
		h.ServeHTTP(w, r)
	}))
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !s.cfg.Enabled(id) {
			http.NotFound(w, r)
			return
		}
		protected.ServeHTTP(w, r)
	})
}

// DataDir is where systems keep what people change through a page. systemd
// passes StateDirectory= in $STATE_DIRECTORY, which can list several
// colon-separated paths; the first is ours.
func DataDir(cfg *config.Config) string {
	if d := cfg.Get(dataDirKey); d != "" {
		return d
	}
	d, _, _ := strings.Cut(os.Getenv("STATE_DIRECTORY"), ":")
	return d
}

// enabled returns the systems that should be visible right now.
func (t *tree) enabled() []system.System {
	var out []system.System
	for _, sys := range t.systems {
		if t.cfg.Enabled(sys.ID()) {
			out = append(out, sys)
		}
	}
	return out
}

func (t *tree) find(id string) system.System {
	for _, sys := range t.enabled() {
		if sys.ID() == id {
			return sys
		}
	}
	return nil
}

func (t *tree) handleRoot(w http.ResponseWriter, r *http.Request) {
	if en := t.enabled(); len(en) > 0 {
		http.Redirect(w, r, "/s/"+en[0].ID()+"/", http.StatusFound)
		return
	}
	for _, l := range t.links {
		if l.Mode != links.ModeTab {
			http.Redirect(w, r, l.Page(), http.StatusFound)
			return
		}
	}
	// Nothing to show: send the user somewhere useful rather than 404.
	http.Redirect(w, r, "/settings", http.StatusFound)
}

func (t *tree) findLink(id string) *links.Link {
	for i := range t.links {
		if t.links[i].ID == id {
			return &t.links[i]
		}
	}
	return nil
}

func (t *tree) handleLink(w http.ResponseWriter, r *http.Request) {
	l := t.findLink(r.PathValue("link"))
	if l == nil || l.Mode == links.ModeTab {
		http.NotFound(w, r)
		return
	}
	// The escaped path, not the path value, so an encoded ? or / in a wiki page
	// name survives the trip into the iframe source.
	rest := strings.TrimPrefix(r.URL.EscapedPath(), l.Page())
	if rest != "" && l.Mode != links.ModeProxy {
		http.NotFound(w, r)
		return
	}

	d := t.layout(r, nil, "")
	for i := range d.Top {
		d.Top[i].Active = d.Top[i].Href == l.Page()
	}
	d.PageTitle = l.Title
	d.Frame = l.Src(rest, r.URL.RawQuery)
	if l.Mode == links.ModeProxy {
		d.FrameFrom, d.FrameTo = l.Prefix()+"/", l.Page()
	}
	t.render(w, d)
}

func (t *tree) handleSystemDefault(w http.ResponseWriter, r *http.Request) {
	sys := t.find(r.PathValue("system"))
	if sys == nil {
		http.NotFound(w, r)
		return
	}
	nav := sys.Nav()
	if len(nav) == 0 {
		http.Error(w, "system declares no pages", http.StatusInternalServerError)
		return
	}
	http.Redirect(w, r, "/s/"+sys.ID()+"/"+nav[0].Slug, http.StatusFound)
}

func (t *tree) handleSystemPage(w http.ResponseWriter, r *http.Request) {
	sys := t.find(r.PathValue("system"))
	if sys == nil {
		http.NotFound(w, r)
		return
	}
	slug := r.PathValue("slug")

	var known bool
	for _, n := range sys.Nav() {
		if n.Slug == slug {
			known = true
			break
		}
	}
	if !known {
		http.NotFound(w, r)
		return
	}

	data := t.layout(r, sys, slug)
	body, err := sys.Render(slug, r)
	if err != nil {
		// Render the shell anyway: a broken page should not cost the user
		// their navigation, and the error belongs on screen, not only in logs.
		t.log.Error("system render failed", "system", sys.ID(), "slug", slug, "err", err)
		data.Error = err.Error()
	}
	data.Body = body
	t.render(w, data)
}

type navLink struct {
	Href, Title string
	Active      bool
}

// topLink is one navbar entry, a system or a link.
type topLink struct {
	Href, Title    string
	Active, NewTab bool
}

type layoutData struct {
	Version        string
	Theme          string
	PageTitle      string
	ActiveTitle    string
	Top            []topLink
	Nav            []navLink
	SettingsActive bool
	Body           template.HTML
	Error          string
	// Login is whether one is configured at all, LoggedIn whether this visitor
	// is, and Here where the Log in link returns to.
	Login, LoggedIn, LoginActive bool
	Here                         string
	// Frame replaces the whole content area with an iframe. FrameFrom and
	// FrameTo are set for a same-origin frame, whose location is mirrored into
	// the address bar.
	Frame, FrameFrom, FrameTo string
}

func (t *tree) layout(r *http.Request, active system.System, slug string) layoutData {
	d := layoutData{
		Version:  version.Version,
		Theme:    theme(r),
		Login:    t.hasLogin(),
		LoggedIn: t.hasLogin() && system.CanEdit(r),
		Here:     r.URL.RequestURI(),
	}
	for _, sys := range t.enabled() {
		isActive := active != nil && sys.ID() == active.ID()
		d.Top = append(d.Top, topLink{Href: "/s/" + sys.ID() + "/", Title: sys.Title(), Active: isActive})
	}
	for _, l := range t.links {
		d.Top = append(d.Top, topLink{Href: l.Href(), Title: l.Title, NewTab: l.Mode == links.ModeTab})
	}
	if active == nil {
		return d
	}
	d.ActiveTitle = active.Title()
	d.PageTitle = active.Title()
	for _, n := range active.Nav() {
		if n.Slug == slug {
			d.PageTitle = n.Title + " · " + active.Title()
		}
		d.Nav = append(d.Nav, navLink{
			Href:   "/s/" + active.ID() + "/" + n.Slug,
			Title:  n.Title,
			Active: n.Slug == slug,
		})
	}
	return d
}

func (s *Server) render(w http.ResponseWriter, d layoutData) { s.renderCode(w, http.StatusOK, d) }

func (s *Server) renderCode(w http.ResponseWriter, code int, d layoutData) {
	var buf bytes.Buffer
	if err := s.tmpl.ExecuteTemplate(&buf, "layout", d); err != nil {
		s.log.Error("layout render failed", "err", err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(code)
	_, _ = buf.WriteTo(w)
}

// contextWithTimeout keeps the metrics handler readable.
func contextWithTimeout(r *http.Request, d time.Duration) (context.Context, context.CancelFunc) {
	return context.WithTimeout(r.Context(), d)
}