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

388 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.
// Weekly schedule and blocklist/allowlist editors (AGENTS.md §5 screens 3–4,
// §11 Phase 6). Only the Control Service writes desired state, so every save
// goes through the store, is audited, and republishes the device's retained
// config.
package web
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
)
// defaultScheduleSlots is how many windows a day shows in the weekly grid. The
// grid grows if a device already has more, so a save never drops a window.
const defaultScheduleSlots = 3
// maxScheduleSlots bounds how many window slots the editor reads back from a
// submitted form.
const maxScheduleSlots = 12
// editorCommon carries the fields every editor page shares with the app shell.
type editorCommon struct {
Title string
Heading string
Sub string
Username string
CSRF string
Version string
AppEnv string
DeviceID int64
DeviceName string
PlatformLabel string
Error string
Flash string
}
// ---------- weekly schedule ----------
type scheduleSlotView struct {
Start string
End string
}
type scheduleRowView struct {
Weekday int
Name string
Slots []scheduleSlotView
}
type scheduleEditorData struct {
editorCommon
Rows []scheduleRowView
ActiveNow bool
ActiveUntil string
NextBoundary string
}
func buildScheduleRows(schedules []store.Schedule, minSlots int) []scheduleRowView {
byDay := make([][]scheduleSlotView, 7)
maxDay := 0
for _, sc := range schedules {
if sc.Weekday < store.Monday || sc.Weekday > store.Sunday {
continue
}
byDay[sc.Weekday] = append(byDay[sc.Weekday], scheduleSlotView{Start: sc.Start, End: sc.End})
if len(byDay[sc.Weekday]) > maxDay {
maxDay = len(byDay[sc.Weekday])
}
}
slots := minSlots
if maxDay > slots {
slots = maxDay
}
rows := make([]scheduleRowView, 0, 7)
for d := store.Monday; d <= store.Sunday; d++ {
row := scheduleRowView{Weekday: d, Name: store.WeekdayName(d)}
for i := 0; i < slots; i++ {
var slot scheduleSlotView
if i < len(byDay[d]) {
slot = byDay[d][i]
}
row.Slots = append(row.Slots, slot)
}
rows = append(rows, row)
}
return rows
}
func rowsFromWindows(windows []store.ScheduleWindow) []scheduleRowView {
schedules := make([]store.Schedule, 0, len(windows))
for _, w := range windows {
schedules = append(schedules, store.Schedule{Weekday: w.Weekday, Start: w.Start, End: w.End})
}
return buildScheduleRows(schedules, defaultScheduleSlots)
}
func (s *Server) handleScheduleEditor(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
schedules, err := s.store.ListSchedules(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.renderScheduleEditor(w, r, dev, buildScheduleRows(schedules, defaultScheduleSlots),
strings.TrimSpace(r.URL.Query().Get("err")),
strings.TrimSpace(r.URL.Query().Get("ok")))
}
func (s *Server) renderScheduleEditor(w http.ResponseWriter, r *http.Request, dev *store.Device, rows []scheduleRowView, errMsg, flash string) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
data := scheduleEditorData{
editorCommon: editorCommon{
Title: "Schedule — " + dev.Name,
Heading: "Weekly schedule",
Sub: fmt.Sprintf("%s · %s", dev.Name, platformLabel(dev.Platform)),
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
DeviceID: dev.ID,
DeviceName: dev.Name,
PlatformLabel: platformLabel(dev.Platform),
Error: errMsg,
Flash: flash,
},
Rows: rows,
}
now := s.now()
if schedules, err := s.store.ListSchedules(ctx, dev.ID); err == nil {
if active, until := store.ScheduleActive(schedules, now); active && until != nil {
data.ActiveNow = true
data.ActiveUntil = until.In(s.loc()).Format("Mon 02 Jan 15:04")
}
if boundary := store.NextWindowBoundary(schedules, now); boundary != nil {
data.NextBoundary = boundary.In(s.loc()).Format("Mon 02 Jan 15:04")
}
}
s.render(w, r, "schedule-editor.html", data)
}
func (s *Server) handleScheduleSave(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
var windows []store.ScheduleWindow
if r.PostFormValue("clear") == "" {
for weekday := store.Monday; weekday <= store.Sunday; weekday++ {
for slot := 0; slot < maxScheduleSlots; slot++ {
start := r.PostFormValue(fmt.Sprintf("start_%d_%d", weekday, slot))
end := r.PostFormValue(fmt.Sprintf("end_%d_%d", weekday, slot))
if strings.TrimSpace(start) == "" && strings.TrimSpace(end) == "" {
continue
}
windows = append(windows, store.ScheduleWindow{Weekday: weekday, Start: start, End: end})
}
}
}
if err := s.store.ReplaceSchedules(ctx, id, windows, s.cfg.AdminUsername); err != nil {
s.log.Warn("schedule rejected", "device", dev.Name, "err", err)
s.renderScheduleEditor(w, r, dev, rowsFromWindows(windows), err.Error(), "")
return
}
s.log.Info("schedule saved", "device", dev.Name, "windows", len(windows))
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/schedule?ok=%s", id,
url.QueryEscape("Saved schedule for "+dev.Name)), http.StatusSeeOther)
}
// ---------- blocklist / allowlist ----------
type listEntryView struct {
ID int64
Kind string
KindLabel string
Value string
}
type listsEditorData struct {
editorCommon
PlatformHint string
Block []listEntryView
Allow []listEntryView
}
func listEntryViews(entries []store.ListEntry) []listEntryView {
out := make([]listEntryView, 0, len(entries))
for _, e := range entries {
out = append(out, listEntryView{
ID: e.ID,
Kind: e.Kind,
KindLabel: e.Kind,
Value: e.Value,
})
}
return out
}
func platformHint(platform string) string {
switch platform {
case store.PlatformAndroid:
return "App values are Android package names (for example com.spotify.music). Site values are hostnames (for example youtube.com)."
case store.PlatformWindows:
return "App values are Windows process names (for example chrome.exe). Site values are hostnames (for example youtube.com)."
default:
return "App values are package or process names. Site values are hostnames."
}
}
func (s *Server) handleListsEditor(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.renderListsEditor(w, r, dev,
strings.TrimSpace(r.URL.Query().Get("err")),
strings.TrimSpace(r.URL.Query().Get("ok")))
}
func (s *Server) renderListsEditor(w http.ResponseWriter, r *http.Request, dev *store.Device, errMsg, flash string) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
block, err := s.store.ListEntries(ctx, dev.ID, store.TableBlocklist)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
allow, err := s.store.ListEntries(ctx, dev.ID, store.TableAllowlist)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
s.render(w, r, "lists-editor.html", listsEditorData{
editorCommon: editorCommon{
Title: "Lists — " + dev.Name,
Heading: "Blocklist and allowlist",
Sub: fmt.Sprintf("%s · %s", dev.Name, platformLabel(dev.Platform)),
Username: s.cfg.AdminUsername,
CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv,
DeviceID: dev.ID,
DeviceName: dev.Name,
PlatformLabel: platformLabel(dev.Platform),
Error: errMsg,
Flash: flash,
},
PlatformHint: platformHint(dev.Platform),
Block: listEntryViews(block),
Allow: listEntryViews(allow),
})
}
// listTableFromForm maps the editor's "block"/"allow" field to the store's
// table constant.
func listTableFromForm(value string) (string, error) {
switch strings.TrimSpace(value) {
case "block", store.TableBlocklist:
return store.TableBlocklist, nil
case "allow", store.TableAllowlist:
return store.TableAllowlist, nil
default:
return "", errors.New("list must be block or allow")
}
}
func (s *Server) handleListAdd(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
table, err := listTableFromForm(r.PostFormValue("table"))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
kind := strings.ToLower(strings.TrimSpace(r.PostFormValue("kind")))
value := r.PostFormValue("value")
entry, err := s.store.AddListEntry(ctx, id, table, kind, value, s.cfg.AdminUsername)
if err != nil {
s.log.Warn("list entry rejected", "device", dev.Name, "err", err)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
s.log.Info("list entry added", "device", dev.Name, "list", table, "kind", entry.Kind, "value", entry.Value)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?ok=%s", id,
url.QueryEscape("Added "+entry.Value)), http.StatusSeeOther)
}
func (s *Server) handleListDelete(w http.ResponseWriter, r *http.Request) {
id, ok := s.deviceID(w, r)
if !ok {
return
}
entryID, err := strconv.ParseInt(r.PathValue("entryID"), 10, 64)
if err != nil || entryID <= 0 {
http.NotFound(w, r)
return
}
if !s.requireCSRF(w, r) {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
dev, err := s.store.GetDevice(ctx, id)
if err != nil {
s.deviceLookupError(w, r, err)
return
}
table, err := listTableFromForm(r.PostFormValue("table"))
if err != nil {
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?err=%s", id,
url.QueryEscape(err.Error())), http.StatusSeeOther)
return
}
if err := s.store.DeleteListEntry(ctx, id, entryID, table, s.cfg.AdminUsername); err != nil {
if errors.Is(err, store.ErrNotFound) {
http.NotFound(w, r)
return
}
s.deviceLookupError(w, r, err)
return
}
s.log.Info("list entry removed", "device", dev.Name, "list", table, "entry", entryID)
s.publishConfig(ctx, *dev)
http.Redirect(w, r, fmt.Sprintf("/devices/%d/lists?ok=%s", id,
url.QueryEscape("Removed entry")), http.StatusSeeOther)
}