Files
TMCandClaude Opus 5.5 60b4c79b0f
CI / build (push) Successful in 40s
Show internal URLs, list label problems, copy a label template
Tiles now show the public URL and, when set, the internal URL as a second
link. Label names ignore case and - versus _. Containers HODA can't fully
use are listed above the tiles with the reason: labels without enable,
a missing url, or an unknown label name with a suggestion (category ->
group). To find labels without enable, discovery reads all containers.

A "Copy labels" button copies every label as a commented template, and
falls back to the copy command where the clipboard API is missing (plain
http on the LAN).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:55:06 +01:00

189 lines
5.5 KiB
Go

// Package server serves the dashboard page, the app list as JSON and the icons.
// An agent serves only the JSON and the icons, and only with the peer token.
package server
import (
"context"
"crypto/sha256"
"crypto/subtle"
"embed"
"encoding/hex"
"encoding/json"
"html/template"
"io/fs"
"log/slog"
"net/http"
"git.mariosem.es/msemes/hoda/internal/apps"
"git.mariosem.es/msemes/hoda/internal/config"
"git.mariosem.es/msemes/hoda/internal/icons"
)
//go:embed web
var webFS embed.FS
// IconSource is this host's icon cache.
type IconSource interface {
URL(id string) string
Lookup(id string) (icons.Icon, bool)
}
// PeerIcons fetches icons of apps that run on an agent's host.
type PeerIcons interface {
Icon(ctx context.Context, id string) (icons.Icon, bool)
}
// Deps are the parts the server reads from.
type Deps struct {
Store *apps.Store
Icons IconSource
Peers PeerIcons // nil when there are no agents
}
type server struct {
cfg config.Config
deps Deps
version string
log *slog.Logger
page *template.Template
}
// New returns the HTTP handler for cfg.Mode.
func New(cfg config.Config, deps Deps, version string, log *slog.Logger) http.Handler {
s := &server{
cfg: cfg,
deps: deps,
version: version,
log: log,
page: template.Must(template.ParseFS(webFS, "web/index.html")),
}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.healthz)
if cfg.Mode == config.ModeAgent {
mux.HandleFunc("GET /{$}", s.agentIndex)
mux.Handle("GET /api/apps", s.requireToken(s.apiApps))
mux.Handle("GET /icons/{id}", s.requireToken(s.icon))
} else {
static, err := fs.Sub(webFS, "web/static")
if err != nil {
panic(err)
}
mux.HandleFunc("GET /{$}", s.index)
mux.HandleFunc("GET /api/apps", s.apiApps)
mux.HandleFunc("GET /icons/{id}", s.icon)
mux.Handle("GET /static/", s.cacheStatic(http.StripPrefix("/static/", http.FileServerFS(static))))
}
return securityHeaders(mux)
}
func (s *server) index(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
err := s.page.Execute(w, map[string]string{
"Title": s.cfg.Title,
"Version": s.version,
"LinkTarget": s.cfg.LinkTarget,
"LabelPrefix": s.cfg.LabelPrefix,
})
if err != nil {
s.log.Error("rendering the page failed", "err", err)
}
}
func (s *server) agentIndex(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("HODA agent on " + s.cfg.NodeName + ". The dashboard is served by the primary.\n"))
}
func (s *server) apiApps(w http.ResponseWriter, r *http.Request) {
resp := s.deps.Store.Snapshot(s.deps.Icons.URL)
resp.Node = s.cfg.NodeName
body, err := json.Marshal(resp)
if err != nil {
http.Error(w, "encoding the app list failed", http.StatusInternalServerError)
return
}
sum := sha256.Sum256(body)
etag := `"` + hex.EncodeToString(sum[:8]) + `"`
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
}
func (s *server) icon(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if !apps.ValidID(id) {
http.NotFound(w, r)
return
}
ic, ok := s.deps.Icons.Lookup(id)
if !ok && s.deps.Peers != nil {
ic, ok = s.deps.Peers.Icon(r.Context(), id)
}
if !ok {
http.NotFound(w, r)
return
}
h := w.Header()
h.Set("Content-Type", ic.ContentType)
// Icons come from third parties: the sandbox stops an SVG from running script if opened directly.
h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
etag := `"` + ic.Version + `"`
h.Set("ETag", etag)
if r.URL.Query().Get("v") == ic.Version {
h.Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
h.Set("Cache-Control", "no-cache")
}
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write(ic.Data)
}
func (s *server) healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok\n"))
}
func (s *server) requireToken(next http.HandlerFunc) http.Handler {
want := []byte("Bearer " + s.cfg.PeerToken)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), want) != 1 {
http.Error(w, "missing or wrong peer token", http.StatusUnauthorized)
return
}
next(w, r)
})
}
// cacheStatic lets browsers keep assets for a year when the URL carries this
// build's version, which changes with every release.
func (s *server) cacheStatic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.version != "dev" && r.URL.Query().Get("v") == s.version {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
next.ServeHTTP(w, r)
})
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Frame-Options", "DENY")
next.ServeHTTP(w, r)
})
}