Files
internet_use_control/internal/web/devices.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

437 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Device registry and manual lock/unlock handlers (AGENTS.md §5 screens 2–3,
// §11 Phase 2). Only the Control Service writes device state; the dashboard is
// the admin surface.
package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// deviceView is the display model for one dashboard card. Locked and Until are
// the effective state: a manual lock or an active weekly window (Phase 6).
type deviceView struct {
ID int64
Name string
Child string
Platform string
PlatformLabel string
Topic string
Online bool
Locked bool
ManualLocked bool
Scheduled bool
Until *time.Time
UntilLocal string
TimeLeft string
StateLabel string
ReasonLabel string
LastSeen string
HasLastSeen bool
}
// auditView is the display model for one audit row.
type auditView struct {
Action string
ActionLabel string
Actor string
DeviceName string
Detail string
CreatedAtLocal string
}
func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState, loc *time.Location) []deviceView {
if loc == nil {
loc = time.Local
}
out := make([]deviceView, 0, len(devs))
for _, d := range devs {
eff, ok := effs[d.ID]
if !ok {
// No computed state (for example a store error): fall back to the
// manual lock so the card never lies about it.
eff = store.EffectiveState{DeviceID: d.ID, Locked: d.Locked, Until: d.Until}
if d.Locked {
eff.Reason = "manual"
}
}
v := deviceView{
ID: d.ID,
Name: d.Name,
Child: d.Child,
Platform: d.Platform,
PlatformLabel: platformLabel(d.Platform),
Topic: d.Topic,
Online: d.Online,
Locked: eff.Locked,
ManualLocked: d.Locked,
Until: eff.Until,
}
switch eff.Reason {
case "schedule":
v.Scheduled = true
v.ReasonLabel = "by schedule"
case "manual":
v.ReasonLabel = "manually"
}
if eff.Locked {
v.StateLabel = "Locked"
} else {
v.StateLabel = "Unlocked"
}
if eff.Until != nil {
local := eff.Until.In(loc)
v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*eff.Until)
}
if d.LastSeen != nil {
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
v.HasLastSeen = true
}
out = append(out, v)
}
return out
}
func buildAuditViews(entries []store.AuditEntry, loc *time.Location) []auditView {
if loc == nil {
loc = time.Local
}
out := make([]auditView, 0, len(entries))
for _, e := range entries {
v := auditView{
Action: e.Action,
ActionLabel: auditLabel(e.Action),
Actor: e.Actor,
DeviceName: e.DeviceName,
Detail: e.Detail,
}
if !e.CreatedAt.IsZero() {
v.CreatedAtLocal = e.CreatedAt.In(loc).Format("02 Jan 15:04:05")
}
out = append(out, v)
}
return out
}
func platformLabel(p string) string {
switch p {
case store.PlatformAndroid:
return "Android"
case store.PlatformWindows:
return "Windows"
default:
return p
}
}
// timeLeft renders how long remains on a timed lock.
func timeLeft(until time.Time) string {
d := time.Until(until)
if d <= 0 {
return "lifting now"
}
d = d.Round(time.Minute)
if d < time.Minute {
return "under a minute left"
}
h := int(d.Hours())
m := int(d.Minutes()) % 60
switch {
case h == 0:
return fmt.Sprintf("%d min left", m)
case m == 0:
return fmt.Sprintf("%dh left", h)
default:
return fmt.Sprintf("%dh %dm left", h, m)
}
}
func auditLabel(action string) string {
switch action {
case "device.created":
return "Device added"
case "device.updated":
return "Device edited"
case "state.locked":
return "Locked"
case "state.unlocked":
return "Unlocked"
case "state.expired":
return "Lock expired"
case "schedule.updated":
return "Schedule saved"
case "list.added":
return "List entry added"
case "list.removed":
return "List entry removed"
case "device.online":
return "Device online"
case "device.offline":
return "Device offline"
case "login.success":
return "Signed in"
case "login.failed":
return "Sign-in failed"
case "login.csrf_rejected":
return "Sign-in rejected"
case "logout":
return "Signed out"
default:
return action
}
}
// ---------- dashboard data ----------
type deviceFormData struct {
Title string
Heading string
Sub string
Username string
CSRF string
Version string
AppEnv string
FormAction string
Name string
Child string
Platform string
Error string
}
// ---------- handlers ----------
func (s *Server) handleDeviceNew(w http.ResponseWriter, r *http.Request) {
s.render(w, r, "device-form.html", deviceFormData{
Title: "Add device",
Heading: "Add a device",
Sub: "Name the device, assign it to a child, and choose its platform.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: "/devices",
Platform: store.PlatformAndroid,
})
}
func (s *Server) handleDeviceCreate(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PostFormValue("name")
child := r.PostFormValue("child")
platform := r.PostFormValue("platform")
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.CreateDevice(ctx, name, child, platform, s.cfg.AdminUsername)
if err != nil {
s.log.Warn("device create rejected", "err", err, "remote", r.RemoteAddr)
s.render(w, r, "device-form.html", deviceFormData{
Title: "Add device",
Heading: "Add a device",
Sub: "Name the device, assign it to a child, and choose its platform.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: "/devices",
Name: strings.TrimSpace(name),
Child: strings.TrimSpace(child),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Error: err.Error(),
})
return
}
s.log.Info("device created", "id", dev.ID, "name", dev.Name, "topic", dev.Topic)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Added "+dev.Name), http.StatusSeeOther)
}
func (s *Server) handleDeviceEdit(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "device-form.html", deviceFormData{
Title: "Edit " + dev.Name,
Heading: "Edit device",
Sub: fmt.Sprintf("%s · %s", platformLabel(dev.Platform), dev.Topic),
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: fmt.Sprintf("/devices/%d", dev.ID),
Name: dev.Name,
Child: dev.Child,
Platform: dev.Platform,
})
}
func (s *Server) handleDeviceUpdate(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
name := r.PostFormValue("name")
child := r.PostFormValue("child")
platform := r.PostFormValue("platform")
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.UpdateDevice(ctx, id, name, child, platform, s.cfg.AdminUsername)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "device-form.html", deviceFormData{
Title: "Edit device",
Heading: "Edit device",
Sub: "Correct the details and save again.",
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
FormAction: fmt.Sprintf("/devices/%d", id),
Name: strings.TrimSpace(name),
Child: strings.TrimSpace(child),
Platform: strings.ToLower(strings.TrimSpace(platform)),
Error: err.Error(),
})
return
}
s.log.Info("device updated", "id", dev.ID, "name", dev.Name)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Saved "+dev.Name), http.StatusSeeOther)
}
func (s *Server) handleDeviceLock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
minutes, _ := strconv.Atoi(strings.TrimSpace(r.PostFormValue("minutes")))
if minutes < 0 || minutes > 24*60 {
minutes = 0
}
var until *time.Time
if minutes > 0 {
t := time.Now().Add(time.Duration(minutes) * time.Minute).UTC()
until = &t
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, true, until, s.cfg.AdminUsername, "state.locked")
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.log.Info("device locked", "id", dev.ID, "name", dev.Name, "minutes", minutes)
s.publishConfig(ctx, *dev)
msg := "Locked " + dev.Name
if minutes > 0 {
msg = fmt.Sprintf("Locked %s for %d min", dev.Name, minutes)
}
http.Redirect(w, r, "/?ok="+url.QueryEscape(msg), http.StatusSeeOther)
}
func (s *Server) handleDeviceUnlock(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.SetDeviceLock(ctx, id, false, nil, s.cfg.AdminUsername, "state.unlocked")
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.log.Info("device unlocked", "id", dev.ID, "name", dev.Name)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, "/?ok="+url.QueryEscape("Unlocked "+dev.Name), http.StatusSeeOther)
}
// ---------- helpers ----------
func (s *Server) deviceID(w http.ResponseWriter, r *http.Request) (int64, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return 0, false
}
return id, true
}
func (s *Server) deviceLookupError(w http.ResponseWriter, r *http.Request, err error) {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
s.log.Error("device handler", "err", err, "path", r.URL.Path)
http.Error(w, "internal server error", http.StatusInternalServerError)
}
func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return false
}
if !s.auth.CheckCSRF(r) {
s.log.Warn("rejected: bad csrf token", "path", r.URL.Path, "remote", r.RemoteAddr)
http.Error(w, "invalid CSRF token", http.StatusForbidden)
return false
}
return true
}