// 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) }