- 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>
388 lines
11 KiB
Go
388 lines
11 KiB
Go
// 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)
|
||
}
|