Build on Phases 1–7. Adds the two override paths from AGENTS.md §4 FR-10/FR-11: the shared device keycode and the versioned admin HTTP API the separate Kotlin admin app will call. - store: migration 004 adds state.grant_until and a settings table. New overrides.go records every override in `overrides` and verifies the shared keycode (bcrypt; one code for every device, §10 rule 3). ExpireLocks also clears an expired grant, and NextScheduleBoundary includes the grant end so a device re-locks on time. - effective state: a grant unlocks a device inside a schedule window; a manual lock clears a grant and a grant clears a manual lock. The retained config payload gains grant_until (additive, v stays 1). - web: /api/v1/admin/... (list, state, lock, unlock, grant) guarded by CONTROL_ADMIN_API_TOKEN, separate from the session login, plus the on-device keycode endpoint POST /api/v1/device/override with per-address throttling. A state change is published retained before the API responds. - cmd/control: `keycode` subcommand and start-up seeding from CONTROL_DEVICE_KEYCODE_HASH. - deploy/verify-phase8.sh + `make verify-phase8` prove the definition of done: an API lock reaches a real broker subscriber in well under a second. - Version 0.6.0-phase8 (compose, Makefile, docs). Measured on .13: the lock reached the TLS broker subscriber in 7 ms (handler 1 ms, request to retained publish accepted). Tests: go vet ./... and go test -count=1 ./... pass in golang:1.25-alpine. Co-Authored-By: Paperclip <noreply@paperclip.ing>
461 lines
12 KiB
Go
461 lines
12 KiB
Go
// 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
|
||
Granted 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"
|
||
case "grant":
|
||
v.Granted = true
|
||
v.ReasonLabel = "time granted"
|
||
}
|
||
if eff.Locked {
|
||
v.StateLabel = "Locked"
|
||
} else if v.Granted {
|
||
v.StateLabel = "Granted"
|
||
} 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 eff.GrantUntil != nil {
|
||
v.Until = eff.GrantUntil
|
||
local := eff.GrantUntil.In(loc)
|
||
v.UntilLocal = local.Format("Mon 15:04")
|
||
v.TimeLeft = timeLeft(*eff.GrantUntil)
|
||
}
|
||
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 "override.granted":
|
||
return "Time granted"
|
||
case "override.keycode":
|
||
return "Keycode override"
|
||
case "override.keycode_rejected":
|
||
return "Keycode rejected"
|
||
case "override.keycode_throttled":
|
||
return "Keycode throttled"
|
||
case "keycode.updated":
|
||
return "Keycode changed"
|
||
case "admin.token_rejected":
|
||
return "Admin API rejected"
|
||
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
|
||
}
|