// Package web is the HTTP surface of the Control Service: /health, the session // login, and the server-rendered HTMX dashboard (AGENTS.md §5, §9). package web import ( "context" "embed" "encoding/json" "fmt" "html/template" "io/fs" "log/slog" "net/http" "strings" "time" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/auth" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/config" "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) //go:embed templates/*.html templates/partials/*.html var templateFS embed.FS //go:embed static var staticFS embed.FS // Server holds the dependencies for the HTTP handlers. type Server struct { cfg *config.Config store *store.Store auth *auth.Manager tmpl *template.Template log *slog.Logger } // New builds a Server and parses the embedded templates. func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger) (*Server, error) { funcs := template.FuncMap{ "year": func() int { return time.Now().Year() }, "now": func() time.Time { return time.Now() }, "clock": func() string { return time.Now().Format("15:04:05 MST") }, } tmpl, err := template.New("").Funcs(funcs).ParseFS(templateFS, "templates/*.html", "templates/partials/*.html") if err != nil { return nil, fmt.Errorf("parse templates: %w", err) } return &Server{cfg: cfg, store: st, auth: am, tmpl: tmpl, log: log}, nil } // Handler returns the fully wrapped HTTP handler. func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("POST /login", s.handleLoginSubmit) mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("GET /{$}", s.requireAuth(s.handleDashboard)) mux.HandleFunc("GET /partials/status", s.requireAuth(s.handleStatusPartial)) mux.HandleFunc("GET /devices/new", s.requireAuth(s.handleDeviceNew)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleDeviceCreate)) mux.HandleFunc("GET /devices/{id}/edit", s.requireAuth(s.handleDeviceEdit)) mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate)) mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock)) mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock)) staticSub, err := fs.Sub(staticFS, "static") if err != nil { panic("web: embedded static dir missing: " + err.Error()) } mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) return s.recoverer(s.securityHeaders(s.requestLogger(mux))) } // ---------- middleware ---------- type statusRecorder struct { http.ResponseWriter status int } func (r *statusRecorder) WriteHeader(code int) { r.status = code r.ResponseWriter.WriteHeader(code) } func (s *Server) requestLogger(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) s.log.Info("http", "method", r.Method, "path", r.URL.Path, "status", rec.status, "remote", r.RemoteAddr, "duration", time.Since(start).String(), ) }) } func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() h.Set("X-Content-Type-Options", "nosniff") h.Set("X-Frame-Options", "DENY") h.Set("Referrer-Policy", "same-origin") h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; form-action 'self'; frame-ancestors 'none'") next.ServeHTTP(w, r) }) } func (s *Server) recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { s.log.Error("panic", "err", rec, "path", r.URL.Path) http.Error(w, "internal server error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) } func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !s.auth.Authenticated(r) { http.Redirect(w, r, "/login", http.StatusSeeOther) return } next(w, r) } } // ---------- handlers ---------- type healthResponse struct { Status string `json:"status"` Service string `json:"service"` Version string `json:"version"` Database string `json:"database"` Time string `json:"time"` } func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { resp := healthResponse{ Status: "ok", Service: "control-service", Version: s.cfg.Version, Database: "ok", Time: time.Now().UTC().Format(time.RFC3339), } code := http.StatusOK if err := s.store.Ping(r.Context()); err != nil { resp.Status = "error" resp.Database = "error" code = http.StatusServiceUnavailable } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code) _ = json.NewEncoder(w).Encode(resp) } type loginData struct { CSRF string Error string Username string } func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { if s.auth.Authenticated(r) { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, r, "login.html", loginData{CSRF: s.auth.EnsureCSRF(w, r)}) } func (s *Server) handleLoginSubmit(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } username := strings.TrimSpace(r.PostFormValue("username")) if !s.auth.CheckCSRF(r) { s.log.Warn("login rejected: bad csrf token", "remote", r.RemoteAddr) _ = s.store.Audit(r.Context(), username, "login.csrf_rejected", nil, r.RemoteAddr) s.render(w, r, "login.html", loginData{ CSRF: s.auth.EnsureCSRF(w, r), Error: "Session expired. Please try again.", Username: username, }) return } if !s.auth.CheckCredentials(username, r.PostFormValue("password")) { s.log.Warn("login failed", "user", username, "remote", r.RemoteAddr) _ = s.store.Audit(r.Context(), username, "login.failed", nil, r.RemoteAddr) w.WriteHeader(http.StatusUnauthorized) s.render(w, r, "login.html", loginData{ CSRF: s.auth.EnsureCSRF(w, r), Error: "Incorrect username or password.", Username: username, }) return } s.auth.Issue(w) _ = s.store.Audit(r.Context(), username, "login.success", nil, r.RemoteAddr) s.log.Info("login success", "user", username, "remote", r.RemoteAddr) http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } if s.auth.Authenticated(r) && s.auth.CheckCSRF(r) { _ = s.store.Audit(r.Context(), s.cfg.AdminUsername, "logout", nil, r.RemoteAddr) } s.auth.Clear(w) http.Redirect(w, r, "/login", http.StatusSeeOther) } type statusData struct { DBHealthy bool DBPath string ServerTime string ServerUp string } type dashboardData struct { statusData Title string Username string CSRF string Version string AppEnv string Devices []deviceView Audit []auditView Flash string ErrMsg string } var serverStarted = time.Now() func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) defer cancel() if n, err := s.store.ExpireLocks(ctx); err != nil { s.log.Error("expire locks", "err", err) } else if n > 0 { s.log.Info("expired timed locks", "count", n) } devs, err := s.store.ListDevices(ctx) if err != nil { s.log.Error("list devices", "err", err) } audit, err := s.store.ListAudit(ctx, 20) if err != nil { s.log.Error("list audit", "err", err) } s.render(w, r, "dashboard.html", dashboardData{ statusData: s.buildStatusData(ctx), Title: "Dashboard", Username: s.cfg.AdminUsername, CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, Devices: buildDeviceViews(devs), Audit: buildAuditViews(audit), Flash: strings.TrimSpace(r.URL.Query().Get("ok")), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), }) } func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() s.renderFragment(w, r, "status.html", s.buildStatusData(ctx)) } func (s *Server) buildStatusData(ctx context.Context) statusData { return statusData{ DBHealthy: s.store.Ping(ctx) == nil, DBPath: s.cfg.DBPath, ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"), ServerUp: time.Since(serverStarted).Round(time.Second).String(), } } func (s *Server) render(w http.ResponseWriter, _ *http.Request, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { s.log.Error("render", "template", name, "err", err) http.Error(w, "template error", http.StatusInternalServerError) } } func (s *Server) renderFragment(w http.ResponseWriter, _ *http.Request, name string, data any) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil { s.log.Error("render fragment", "template", name, "err", err) http.Error(w, "template error", http.StatusInternalServerError) } }