Build on Phases 1–6. Telemetry already arrived on
restrict/<device>/telemetry; this phase stores it in a daily rollup and
shows it.
- store: migration 003 adds telemetry_daily (device, day, app seconds,
site seconds). InsertTelemetry maintains the affected days in the same
transaction; RebuildRollups backfills and repairs it. UsageRange and
TopUsage serve the week grid and top lists. CapAudit enforces the
size-capped append-only audit log from AGENTS.md §7.
- web: GET /devices/{id}/usage — Monday-to-Sunday totals plus the top 10
apps and top 10 sites, using CSP-safe native <progress> bars.
- theme.js: light/dark from the OS preference, with a nav toggle, loaded
as an external script so the strict CSP still holds.
- cmd/control: `rollup` subcommand; rollup rebuild and audit cap at
start-up and every 15 minutes.
- Version 0.5.0-phase7 (compose, Makefile, docs).
Tests: go test ./... and go vet ./... pass in golang:1.25-alpine.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
409 lines
13 KiB
Go
409 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))
|
|
mux.HandleFunc("GET /devices/{id}/usage", s.requireAuth(s.handleDeviceUsage))
|
|
|
|
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)
|
|
}
|
|
}
|