Files
internet_use_control/internal/web/server.go
Paperclip 9af543c394 Phase 6: schedules, blocklists, allowlists (editors + effective state)
- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-13 16:12:02 +11:00

408 lines
13 KiB
Go

// 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
pub Publisher
}
// Publisher pushes device config to the broker and reports the link's health.
// Only the Control Service publishes config (AGENTS.md §10 rule 5). It is nil
// when MQTT is switched off, and every call site tolerates that.
type Publisher interface {
Broker() string
Connected() bool
Stats() (received, published int64, lastMessageAt time.Time, lastErr string)
PublishDeviceConfig(ctx context.Context, dev store.Device) error
PublishAll(ctx context.Context) (int, error)
}
// New builds a Server and parses the embedded templates. pub may be nil.
func New(cfg *config.Config, st *store.Store, am *auth.Manager, log *slog.Logger, pub Publisher) (*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, pub: pub}, nil
}
// publishConfig pushes one device's desired state to the broker, retained. A
// publish failure never fails the HTTP request: SQLite already holds the new
// state, and the link republishes everything when it reconnects, so the broker
// catches up on its own.
func (s *Server) publishConfig(ctx context.Context, dev store.Device) {
if s.pub == nil {
s.log.Debug("mqtt disabled; config kept in SQLite only", "device", dev.Name)
return
}
if err := s.pub.PublishDeviceConfig(ctx, dev); err != nil {
s.log.Error("mqtt publish config", "device", dev.Name, "topic", dev.Topic, "err", err)
return
}
s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic,
"manual_locked", dev.Locked)
}
// 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))
mux.HandleFunc("GET /devices/{id}/schedule", s.requireAuth(s.handleScheduleEditor))
mux.HandleFunc("POST /devices/{id}/schedule", s.requireAuth(s.handleScheduleSave))
mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor))
mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd))
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
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
// MQTT link health (Phase 3).
MQTTEnabled bool
MQTTConnected bool
MQTTBroker string
MQTTReceived int64
MQTTPublished int64
MQTTLastMessage string
MQTTLastError 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)
// The lock lifted, so the retained config is stale. Republish.
if s.pub != nil {
if _, err := s.pub.PublishAll(ctx); err != nil {
s.log.Error("mqtt republish after expiry", "err", err)
}
}
}
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, s.effectiveStates(ctx, devs), s.loc()),
Audit: buildAuditViews(audit, s.loc()),
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))
}
// effectiveStates computes the desired state for every device at the same
// instant, so one dashboard load is internally consistent.
func (s *Server) effectiveStates(ctx context.Context, devs []store.Device) map[int64]store.EffectiveState {
out := make(map[int64]store.EffectiveState, len(devs))
now := s.now()
for _, d := range devs {
eff, err := s.store.EffectiveState(ctx, d, now)
if err != nil {
s.log.Error("effective state", "device", d.Name, "err", err)
continue
}
out[d.ID] = eff
}
return out
}
// now is the current time in the schedule timezone, so weekly windows are
// evaluated wall-clock and not in the container's default UTC.
func (s *Server) now() time.Time {
return time.Now().In(s.loc())
}
// loc is the schedule timezone, defaulting to the process local zone.
func (s *Server) loc() *time.Location {
if s.cfg.ScheduleLocation != nil {
return s.cfg.ScheduleLocation
}
return time.Local
}
func (s *Server) buildStatusData(ctx context.Context) statusData {
data := statusData{
DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath,
ServerTime: time.Now().In(s.loc()).Format("Mon 02 Jan 2006 15:04:05"),
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
}
if s.pub != nil {
data.MQTTEnabled = true
data.MQTTConnected = s.pub.Connected()
data.MQTTBroker = s.pub.Broker()
rx, tx, last, lastErr := s.pub.Stats()
data.MQTTReceived = rx
data.MQTTPublished = tx
data.MQTTLastError = lastErr
if !last.IsZero() {
data.MQTTLastMessage = last.Local().Format("15:04:05")
}
}
return data
}
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)
}
}