diff --git a/Makefile b/Makefile index 8894355..21aa230 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -IMAGE ?= webrestrict/control:0.3.0-phase3 -BUILD_VERSION ?= 0.3.0-phase3 +IMAGE ?= webrestrict/control:0.4.0-phase6 +BUILD_VERSION ?= 0.4.0-phase6 # Bounded docker helper for the Go toolchain. The container runs as the current # user, so the repository and the module cache stay owned by you. diff --git a/README.md b/README.md index d3a40ae..1bf3cfe 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Internet Use Control project (`SYSTEM_MAP.md`). It holds schedules, blocklists, per-device state and usage, and serves the web dashboard. Only it publishes device config. -This repository currently contains **Phase 3 — MQTT** (AGENTS.md §11), on top of -Phases 1 and 2: +This repository currently contains **Phase 6 — Schedules, blocklists, +allowlists** (AGENTS.md §11), on top of Phases 1–3: - Go service with its own image (`Dockerfile`) and compose file. - SQLite schema for the whole data model (§7), applied through versioned @@ -26,9 +26,18 @@ Phases 1 and 2: `restrict//config`; device status and telemetry are subscribed and written to SQLite; presence comes from the broker's Last Will and Testament. The broker is transport only — SQLite stays the source of truth. +- **Weekly schedules**: a per-device grid of blocking windows, Monday to Sunday, + including windows that cross midnight. The Control Service computes the + *effective* state — manual lock or active window — and republishes it at each + window boundary, so a schedule turns on and off with nobody touching it. +- **Blocklist / allowlist**: per-device `app` (Android package or Windows + process) and `site` (hostname) entries. While a device is blocked, the device + blocks every blocklist value **except** the allowlist values (Spotify at + night). That allow decision is in the published config. +- Every edit is written to the append-only audit log (FR-15). -Enforcement, schedules, blocklists, the admin API and the kids' clients arrive -in later phases. Nothing here touches the router, Pi-hole, or DNS. +Enforcement itself (the kids' clients), the admin API, usage screens and the Hub +arrive in later phases. Nothing here touches the router, Pi-hole, or DNS. ## Quick start (on `.13`) @@ -44,7 +53,7 @@ docker compose up -d --build # 3. Prove it. curl -s http://127.0.0.1:8791/health -# {"status":"ok","service":"control-service","version":"0.3.0-phase3",...} +# {"status":"ok","service":"control-service","version":"0.4.0-phase6",...} ``` Open in a browser. It redirects to `/login`. Sign in @@ -69,6 +78,7 @@ reads only the process environment. | `CONTROL_ADMIN_PASSWORD_HASH` | — | bcrypt hash (option B, preferred) | | `CONTROL_APP_ENV` | `production` | Label shown in the footer | | `CONTROL_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` | +| `CONTROL_TIMEZONE` | `Australia/Sydney` | IANA zone weekly windows are evaluated in | | `CONTROL_MQTT_ENABLED` | `true` | Turn the broker link on or off | | `CONTROL_MQTT_BROKER` | `tcp://host.docker.internal:1883` | Broker URL; `tls://…:8883` once the TLS listener exists | | `CONTROL_MQTT_CLIENT_ID` | `control-service` | MQTT client id | @@ -80,7 +90,7 @@ reads only the process environment. Generate a bcrypt hash without host Go: ```bash -docker run --rm --entrypoint /app/control webrestrict/control:0.3.0-phase3 \ +docker run --rm --entrypoint /app/control webrestrict/control:0.4.0-phase6 \ hash-password 'your-password' ``` @@ -124,13 +134,17 @@ Broker: the shared **Mosquitto on `.13`**. A second broker is not added ```json {"v":1,"device_id":1,"name":"Harry's phone","platform":"android", - "locked":true,"until":"2026-10-13T21:00:00Z","allow":[],"block":[], - "generated_at":"2026-10-13T03:30:27Z"} + "locked":true,"until":"2026-10-13T21:00:00Z", + "allow":["com.spotify.music"],"block":["com.youtube.android","roblox.exe"], + "reason":"schedule","generated_at":"2026-10-13T03:30:27Z"} ``` -`allow` and `block` are always arrays, never `null`. They are read from the -`allowlist` and `blocklist` tables, which get their editors in Phase 6; until -then they are empty. A client must ignore unknown fields, so this shape can grow. +`allow` and `block` are always arrays, never `null`. `locked` and `until` are the +**effective** state: a `reason` of `schedule` means a weekly window is active, +`manual` means a lock was set from the dashboard. While `locked` is true the +device blocks every `block` value **except** the `allow` values (FR-4), so the +allowlist is the exception list. A client must ignore unknown fields, so this +shape can grow. ### Status payload (device → service) @@ -174,12 +188,35 @@ docker exec webrestrict-control /app/control mqtt-clients | `POST` | `/devices/{id}` | Updates name, child, platform. | | `POST` | `/devices/{id}/lock` | Locks now; optional `minutes` (0 = until unlocked). | | `POST` | `/devices/{id}/unlock` | Unlocks now. | +| `GET` | `/devices/{id}/schedule` | Weekly schedule editor (grid of windows). | +| `POST` | `/devices/{id}/schedule` | Replaces the device's windows; `clear=1` clears them. | +| `GET` | `/devices/{id}/lists` | Blocklist / allowlist editor. | +| `POST` | `/devices/{id}/lists` | Adds one entry (`table=block|allow`, `kind=app|site`, `value`). | +| `POST` | `/devices/{id}/lists/{entryID}/delete` | Removes one entry. | Every form POST needs the `csrf` field from the session cookie. State changes append to the `audit` table and republish the device's retained config. Devices are never hard-deleted (§10 rule 10). -Phase 3 adds no HTTP endpoints. +## Schedules, blocklists and allowlists (Phase 6) + +A **schedule** is a set of weekly blocking windows per device: a weekday +(Monday = 0 … Sunday = 6), a local `HH:MM` start and end, and an end earlier than +the start means the window crosses midnight (Monday 20:00–07:00 ends Tuesday +07:00). A window means restrictions are **ON** during that time. + +The **effective state** is a manual lock, an active window, or neither. The +Control Service computes it and publishes it retained. A manual lock with no end +time (“until I unlock”) never lifts; otherwise the later of the manual end and +the covering window's end is used. A background loop republishes at the next +window boundary, so a window starts and ends on time without a poll. + +A **blocklist** entry is blocked while a window is active or the device is +locked. An **allowlist** entry (each entry is `app` or `site`) is the exception +list: the device blocks every blocklist value *except* the allowlist. For +example, with `com.spotify.music` allowlisted and `com.youtube.android` blocked, +Spotify stays available and YouTube does not. Hostnames are lower-cased; app +values keep their case. ## Development @@ -206,6 +243,7 @@ cmd/control/ service entry point (+ hash-password, mqtt-clients) internal/config/ environment configuration internal/store/ SQLite open, migrations, audit, devices + state devices.go device registry, desired state, lock expiry + schedules.go weekly windows and the effective state reports.go presence and telemetry written from broker reports lists.go allow/block lists and device lookup by topic migrations/001_init.sql, 002_telemetry_unique.sql @@ -215,7 +253,8 @@ internal/mqtt/ the broker link: retained config out, reports in link.go paho client, subscriptions, publish, TLS verification internal/web/ HTTP handlers, templates, static assets devices.go device registry + manual lock/unlock handlers - templates/ login, dashboard, device form, HTMX partials + schedules.go weekly schedule and block/allow editors + templates/ login, dashboard, device form, editors, HTMX partials static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts Dockerfile own image (multi-stage, CGO-free) docker-compose.yml own container, port 8791 @@ -230,12 +269,14 @@ deploy/mosquitto/ TLS listener, credentials, topic ACL, verifier `webrestrict.lab.audasmedia.com.au`. The terminating Caddy runs on `.35` (sam-Ubuntu1); see that file for the two proxy patterns and the apply commands. -## Non-goals (Phase 3) +## Non-goals (Phase 6) -- No schedules, blocklists or allowlists (Phase 6). The config payload reads - those tables, but nothing edits them yet. +- No group/global list editing from the UI. The data model supports a NULL + `device_id` that applies to every device, and the effective state merges it, + but the editor manages per-device rows only. - No usage screens (Phase 7). Telemetry is stored, not yet shown. - No admin API (Phase 8). - No kids' clients (Phases 4–5). The topic and payload contract above is what - they implement. + they implement. The clients apply the allowlist exception; the Control Service + only publishes it. - No Hub contact (Phase 9). diff --git a/cmd/control/main.go b/cmd/control/main.go index 4e93f25..62cc8c9 100644 --- a/cmd/control/main.go +++ b/cmd/control/main.go @@ -35,6 +35,12 @@ var version = "dev" // service repairs it from the database without a restart. const republishInterval = 15 * time.Minute +// republishPoll is how often the loop re-evaluates the next schedule boundary. +// It bounds how late a transition can be after the schedule is edited, and it +// keeps the service from sleeping past a boundary that did not exist yet when +// the loop last looked. +const republishPoll = 30 * time.Second + func main() { if len(os.Args) > 1 { switch os.Args[1] { @@ -104,11 +110,12 @@ func run() error { Password: cfg.MQTTPassword, CAFile: cfg.MQTTCAFile, Actor: "device", + Location: cfg.ScheduleLocation, Log: log, Devices: st, Source: st, Reports: st, - OnConnected: func() { republish(ctx, log, link) }, + OnConnected: func() { republish(ctx, log, link, st) }, }) if err != nil { return err @@ -116,7 +123,7 @@ func run() error { pub = link link.Start(ctx) defer link.Stop() - go republishLoop(ctx, log, link) + go republishLoop(ctx, log, link, st, cfg.ScheduleLocation) if !cfg.MQTTTLS() { log.Warn("mqtt is running without TLS; the shared broker has no TLS listener yet (AGENTS.md §10 rule 6)", "broker", cfg.MQTTBroker) @@ -169,14 +176,20 @@ func run() error { } // republish pushes every device's retained config. It runs once per connect and -// then periodically, so the broker's view of the desired state is rebuilt from -// SQLite, the source of truth. -func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) { +// whenever the desired state may have changed, so the broker's view is rebuilt +// from SQLite, the source of truth. It first clears any manual lock that has +// expired, so the published state is never stale. +func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store) { if link == nil { return } ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() + if n, err := st.ExpireLocks(ctx); err != nil { + log.Error("expire locks", "err", err) + } else if n > 0 { + log.Info("expired timed locks", "count", n) + } n, err := link.PublishAll(ctx) if err != nil { log.Error("mqtt publish all", "published", n, "err", err) @@ -185,19 +198,39 @@ func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) { log.Info("mqtt retained config refreshed", "devices", n) } -func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link) { - ticker := time.NewTicker(republishInterval) - defer ticker.Stop() +// republishLoop refreshes the retained config when a schedule boundary is +// crossed and otherwise on a long interval. It re-evaluates often instead of +// sleeping until one computed boundary, so a schedule edited after the loop +// started still turns on and off on time (FR-2) without a poll from the device. +func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link, st *store.Store, loc *time.Location) { + if loc == nil { + loc = time.Local + } + lastEval := time.Now() + lastPublish := lastEval for { select { case <-ctx.Done(): return - case <-ticker.C: - if !link.Connected() { - continue - } - republish(ctx, log, link) + case <-time.After(republishPoll): } + if !link.Connected() { + lastEval = time.Now() + continue + } + now := time.Now() + shouldPublish := !now.Before(lastPublish.Add(republishInterval)) + // A boundary between the last evaluation and now has been crossed. + if boundary, err := st.NextScheduleBoundary(ctx, lastEval.In(loc)); err != nil { + log.Error("next schedule boundary", "err", err) + } else if boundary != nil && !boundary.After(now) { + shouldPublish = true + } + if shouldPublish { + republish(ctx, log, link, st) + lastPublish = time.Now() + } + lastEval = now } } diff --git a/docker-compose.yml b/docker-compose.yml index 3d5d358..fb946cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: build: context: . args: - VERSION: "0.3.0-phase3" - image: webrestrict/control:0.3.0-phase3 + VERSION: "0.4.0-phase6" + image: webrestrict/control:0.4.0-phase6 container_name: webrestrict-control restart: unless-stopped env_file: diff --git a/environment.d/control-service.env.example b/environment.d/control-service.env.example index 56ca9b5..d0a228c 100644 --- a/environment.d/control-service.env.example +++ b/environment.d/control-service.env.example @@ -16,6 +16,10 @@ CONTROL_DB_PATH=/data/control.db CONTROL_APP_ENV=production CONTROL_LOG_LEVEL=info +# Timezone weekly schedule windows are evaluated in. Windows are wall-clock, so +# this must be the family's zone, not the container's default of UTC. +CONTROL_TIMEZONE=Australia/Sydney + # Signs the session cookie. Set to a long random string (>= 16 chars). # Generate: openssl rand -hex 32 CONTROL_SESSION_SECRET=change-me-to-a-long-random-string diff --git a/internal/config/config.go b/internal/config/config.go index 629292e..0310789 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,9 @@ import ( "strconv" "strings" "time" + // Embed the IANA time zone database so CONTROL_TIMEZONE resolves even in a + // container without tzdata, such as the test image. + _ "time/tzdata" ) // Config is the fully resolved runtime configuration. @@ -37,6 +40,11 @@ type Config struct { AppEnv string // Version is injected at build time. Version string + // ScheduleLocation is the timezone weekly schedule windows are evaluated in + // (CONTROL_TIMEZONE). Windows are wall-clock, so this must be the family's + // zone, not the container's default of UTC. It defaults to + // DefaultTimezone. + ScheduleLocation *time.Location // MQTTEnabled turns the broker link on. When false the service runs // HTTP-only and never touches the broker. @@ -102,6 +110,12 @@ func Load() (*Config, error) { } cfg.LogLevel = lvl + loc, err := time.LoadLocation(env("CONTROL_TIMEZONE", DefaultTimezone)) + if err != nil { + return nil, fmt.Errorf("CONTROL_TIMEZONE: %w", err) + } + cfg.ScheduleLocation = loc + if err := cfg.validateMQTT(); err != nil { return nil, err } @@ -114,6 +128,10 @@ func Load() (*Config, error) { // to the host gateway. const DefaultMQTTBroker = "tcp://host.docker.internal:1883" +// DefaultTimezone is the schedule timezone used when CONTROL_TIMEZONE is unset. +// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override. +const DefaultTimezone = "Australia/Sydney" + // MQTTTLS reports whether the broker URL asks for TLS. func (c *Config) MQTTTLS() bool { return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 24207cd..c12f163 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -37,6 +37,29 @@ func TestLoadDefaultsAndValidation(t *testing.T) { if !cfg.CookieSecure { t.Error("CookieSecure = false, want true by default") } + if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != DefaultTimezone { + t.Errorf("ScheduleLocation = %v, want %s", cfg.ScheduleLocation, DefaultTimezone) + } +} + +func TestScheduleTimezone(t *testing.T) { + t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef") + t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2") + t.Setenv("CONTROL_MQTT_INSECURE", "true") + + t.Setenv("CONTROL_TIMEZONE", "UTC") + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != "UTC" { + t.Errorf("ScheduleLocation = %v, want UTC", cfg.ScheduleLocation) + } + + t.Setenv("CONTROL_TIMEZONE", "Not/AZone") + if _, err := Load(); err == nil { + t.Error("expected an error for an unknown CONTROL_TIMEZONE") + } } func TestLoadRequiresSecretAndPassword(t *testing.T) { diff --git a/internal/mqtt/link.go b/internal/mqtt/link.go index dc49a1f..82b1e30 100644 --- a/internal/mqtt/link.go +++ b/internal/mqtt/link.go @@ -34,9 +34,11 @@ type ReportSink interface { } // ConfigSource reads the desired state that is published to devices. +// EffectiveState combines the manual lock, the weekly schedule and the +// allow/block lists (Phase 6). type ConfigSource interface { ListDevices(ctx context.Context) ([]store.Device, error) - DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) + EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) } // Options configures the link. @@ -60,6 +62,10 @@ type Options struct { // Actor is the audit-log actor for reports that arrive over the broker. Actor string + // Location is the schedule timezone. Weekly windows are wall-clock, so the + // service must evaluate them in the family's zone, not the container's + // (usually UTC). Nil means time.Local. + Location *time.Location // Log receives structured logs. Log *slog.Logger @@ -325,27 +331,43 @@ func (l *Link) noteMessage() { // topic stored with the device. It is the single writer path for config // (AGENTS.md §10 rule 5): only the Control Service publishes. func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error { - allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID) - if err != nil { - return err - } - payload, err := Config{ - V: ConfigVersion, - DeviceID: dev.ID, - Name: dev.Name, - Platform: dev.Platform, - Locked: dev.Locked, - Until: dev.Until, - Allow: allow, - Block: block, - GeneratedAt: time.Now().UTC(), - }.Marshal() + payload, err := l.buildConfig(ctx, dev) if err != nil { return err } return l.Publish(dev.Topic, payload) } +// buildConfig computes the effective desired state for one device at the current +// time and renders the retained payload. It is separated from the publish so it +// can be tested without a broker. +func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error) { + eff, err := l.opts.Source.EffectiveState(ctx, dev, time.Now().In(l.location())) + if err != nil { + return nil, err + } + return Config{ + V: ConfigVersion, + DeviceID: dev.ID, + Name: dev.Name, + Platform: dev.Platform, + Locked: eff.Locked, + Until: eff.Until, + Allow: eff.Allow, + Block: eff.Block, + Reason: eff.Reason, + GeneratedAt: time.Now().UTC(), + }.Marshal() +} + +// location returns the schedule timezone, defaulting to the process local zone. +func (l *Link) location() *time.Location { + if l.opts.Location != nil { + return l.opts.Location + } + return time.Local +} + // Publish sends one retained message and waits for the broker to accept it. func (l *Link) Publish(topic string, payload []byte) error { if !l.client.IsConnected() { diff --git a/internal/mqtt/payload.go b/internal/mqtt/payload.go index 74374e2..b4e862a 100644 --- a/internal/mqtt/payload.go +++ b/internal/mqtt/payload.go @@ -77,15 +77,19 @@ func deviceFromTopic(topic, suffix string) (string, bool) { // The shape is versioned with "v" and is consumed by the kids' clients in // Phases 4 and 5. Add fields; do not repurpose them. type Config struct { - V int `json:"v"` - DeviceID int64 `json:"device_id"` - Name string `json:"name"` - Platform string `json:"platform"` - Locked bool `json:"locked"` - Until *time.Time `json:"until"` - Allow []string `json:"allow"` - Block []string `json:"block"` - GeneratedAt time.Time `json:"generated_at"` + V int `json:"v"` + DeviceID int64 `json:"device_id"` + Name string `json:"name"` + Platform string `json:"platform"` + Locked bool `json:"locked"` + Until *time.Time `json:"until"` + Allow []string `json:"allow"` + Block []string `json:"block"` + // Reason is why the device is locked: "schedule" for an active weekly + // window, "manual" for a lock from the dashboard. It is omitted when the + // device is not locked. It is additive (Phase 6), so v stays 1. + Reason string `json:"reason,omitempty"` + GeneratedAt time.Time `json:"generated_at"` } // ConfigVersion is the current schema version of Config. diff --git a/internal/mqtt/payload_test.go b/internal/mqtt/payload_test.go index e63715e..fa27245 100644 --- a/internal/mqtt/payload_test.go +++ b/internal/mqtt/payload_test.go @@ -196,8 +196,8 @@ type stubSource struct{} func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil } -func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) { - return nil, nil, nil +func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) { + return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil } type stubSink struct{} @@ -209,3 +209,73 @@ func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time. func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) { return 0, nil } + +// stateSource returns a fixed effective state, to prove the published payload +// without a broker. +type stateSource struct { + eff store.EffectiveState +} + +func (s stateSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil } + +func (s stateSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) { + eff := s.eff + eff.DeviceID = dev.ID + return eff, nil +} + +// TestBuildConfigAllowlistIsolation is the Phase 6 definition of done: with +// Spotify allowlisted while other apps are blocked during an active schedule +// window, the retained payload says Spotify is allowed and the others are +// blocked. +func TestBuildConfigAllowlistIsolation(t *testing.T) { + until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC) + link, err := New(Options{ + Broker: "tcp://127.0.0.1:1883", + Source: stateSource{eff: store.EffectiveState{ + Locked: true, + Until: &until, + Reason: "schedule", + Allow: []string{"com.spotify.music"}, + Block: []string{"com.youtube.android", "roblox.exe"}, + }}, + Devices: stubLookup{}, + Reports: stubSink{}, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + payload, err := link.buildConfig(context.Background(), store.Device{ + ID: 7, Name: "Harry's phone", Platform: store.PlatformAndroid, + }) + if err != nil { + t.Fatalf("buildConfig: %v", err) + } + + var cfg Config + if err := json.Unmarshal(payload, &cfg); err != nil { + t.Fatalf("Unmarshal: %v", err) + } + if !cfg.Locked || cfg.Reason != "schedule" { + t.Errorf("payload = %s, want locked by schedule", payload) + } + if cfg.Until == nil || !cfg.Until.Equal(until) { + t.Errorf("until = %v, want %v", cfg.Until, until) + } + if len(cfg.Allow) != 1 || cfg.Allow[0] != "com.spotify.music" { + t.Errorf("allow = %v, want Spotify allowed", cfg.Allow) + } + if len(cfg.Block) != 2 { + t.Errorf("block = %v, want the other apps blocked", cfg.Block) + } + // The JSON is the contract the kids' clients read. + for _, want := range []string{ + `"locked":true`, `"reason":"schedule"`, + `"allow":["com.spotify.music"]`, + } { + if !strings.Contains(string(payload), want) { + t.Errorf("payload %s missing %s", payload, want) + } + } +} diff --git a/internal/store/lists.go b/internal/store/lists.go index 741df9c..3c754ea 100644 --- a/internal/store/lists.go +++ b/internal/store/lists.go @@ -1,9 +1,12 @@ // Per-device allow and block lists, and device lookup by MQTT topic -// (AGENTS.md §7 blocklist/allowlist, §11 Phase 3). +// (AGENTS.md §4 FR-3/FR-4, §7 blocklist/allowlist, §11 Phase 6). // -// The editors for these lists arrive in Phase 6. Phase 3 already publishes them -// in the device config, so a device that is onboarded later gets the real lists -// without another protocol change. Migration 001 created both tables. +// A list entry is one value tagged with a kind: "app" (an Android package name +// or a Windows process name) or "site" (a hostname). The editor on a device +// page edits the entries scoped to that device. A row with a NULL device_id +// applies to every device (a group entry, §7); the data model supports it and +// the effective state merges it, but the Phase 6 editor only manages +// device-scoped rows. package store import ( @@ -12,27 +15,96 @@ import ( "errors" "fmt" "strings" + "time" ) -// DeviceLists returns the site/app values that stay allowed and the values that -// are blocked for one device. A row with a NULL device_id applies to every -// device (§7). Rows are returned in insertion order, de-duplicated. +// List table names. They are package constants, never caller input, so they are +// safe to interpolate into a query. +const ( + TableBlocklist = "blocklist" + TableAllowlist = "allowlist" +) + +// List kind values allowed by the blocklist/allowlist CHECK constraint. +const ( + ListKindApp = "app" + ListKindSite = "site" +) + +// ListEntry is one blocklist or allowlist row. +type ListEntry struct { + ID int64 + DeviceID *int64 // nil = applies to every device + Kind string + Value string + CreatedAt time.Time +} + +// ValidListKind reports whether kind is "app" or "site". +func ValidListKind(kind string) bool { + return kind == ListKindApp || kind == ListKindSite +} + +// listTable maps a public constant to the real table name, rejecting anything +// else so a caller can never select an arbitrary table. +func listTable(table string) (string, error) { + switch table { + case TableBlocklist: + return "blocklist", nil + case TableAllowlist: + return "allowlist", nil + default: + return "", fmt.Errorf("unknown list %q", table) + } +} + +// normalizeListValue trims a value and rejects blanks, over-long values and +// control characters. Hostnames are lower-cased because DNS is +// case-insensitive; app identifiers keep their case. +func normalizeListValue(kind, value string) (string, error) { + if !ValidListKind(kind) { + return "", errors.New(`kind must be "app" or "site"`) + } + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("value is required") + } + if len(value) > 200 { + return "", errors.New("value must be 200 characters or fewer") + } + for _, r := range value { + if r < 0x20 || r == 0x7f { + return "", errors.New("value must not contain control characters") + } + } + if kind == ListKindSite { + value = strings.ToLower(value) + } + return value, nil +} + +// DeviceLists returns the values that stay allowed and the values that are +// blocked for one device. A row with a NULL device_id applies to every device +// (§7). Rows are returned in insertion order, de-duplicated. func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) { - if allow, err = s.listValues(ctx, "allowlist", id); err != nil { + if allow, err = s.listValues(ctx, TableAllowlist, id); err != nil { return nil, nil, err } - if block, err = s.listValues(ctx, "blocklist", id); err != nil { + if block, err = s.listValues(ctx, TableBlocklist, id); err != nil { return nil, nil, err } return allow, block, nil } func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) { - // The table name is a package constant, never caller input. - rows, err := s.db.QueryContext(ctx, - `SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id) + name, err := listTable(table) if err != nil { - return nil, fmt.Errorf("list %s: %w", table, err) + return nil, err + } + rows, err := s.db.QueryContext(ctx, + `SELECT value FROM `+name+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id) + if err != nil { + return nil, fmt.Errorf("list %s: %w", name, err) } defer rows.Close() @@ -41,24 +113,160 @@ func (s *Store) listValues(ctx context.Context, table string, id int64) ([]strin for rows.Next() { var v string if err := rows.Scan(&v); err != nil { - return nil, fmt.Errorf("scan %s: %w", table, err) + return nil, fmt.Errorf("scan %s: %w", name, err) } v = strings.TrimSpace(v) if v == "" { continue } - if _, dup := seen[v]; dup { + key := v + if _, dup := seen[key]; dup { continue } - seen[v] = struct{}{} + seen[key] = struct{}{} out = append(out, v) } if err := rows.Err(); err != nil { - return nil, fmt.Errorf("list %s: %w", table, err) + return nil, fmt.Errorf("list %s: %w", name, err) } return out, nil } +// ListEntries returns one device's blocklist or allowlist rows, oldest first. +func (s *Store) ListEntries(ctx context.Context, deviceID int64, table string) ([]ListEntry, error) { + name, err := listTable(table) + if err != nil { + return nil, err + } + rows, err := s.db.QueryContext(ctx, ` + SELECT id, device_id, kind, value, created_at + FROM `+name+` WHERE device_id = ? + ORDER BY id`, deviceID) + if err != nil { + return nil, fmt.Errorf("list entries: %w", err) + } + defer rows.Close() + + out := make([]ListEntry, 0, 8) + for rows.Next() { + var ( + e ListEntry + devID sql.NullInt64 + created sql.NullString + ) + if err := rows.Scan(&e.ID, &devID, &e.Kind, &e.Value, &created); err != nil { + return nil, fmt.Errorf("scan entry: %w", err) + } + if devID.Valid { + id := devID.Int64 + e.DeviceID = &id + } + e.CreatedAt = parseTime(created.String) + out = append(out, e) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list entries: %w", err) + } + return out, nil +} + +// AddListEntry appends one blocklist or allowlist value for a device and +// appends one audit entry in the same transaction. A value that is already +// present for the device (same kind and value, case-insensitively) is refused, +// so repeated submits do not pile up. +func (s *Store) AddListEntry(ctx context.Context, deviceID int64, table, kind, value, actor string) (*ListEntry, error) { + name, err := listTable(table) + if err != nil { + return nil, err + } + value, err = normalizeListValue(kind, value) + if err != nil { + return nil, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("add entry: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var exists int + err = tx.QueryRowContext(ctx, + `SELECT 1 FROM `+name+` WHERE device_id = ? AND kind = ? AND lower(value) = lower(?) LIMIT 1`, + deviceID, kind, value).Scan(&exists) + if err == nil { + return nil, fmt.Errorf("%s %q is already on the list", kind, value) + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("check entry: %w", err) + } + + res, err := tx.ExecContext(ctx, + `INSERT INTO `+name+` (device_id, kind, value) VALUES (?, ?, ?)`, deviceID, kind, value) + if err != nil { + return nil, fmt.Errorf("insert entry: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("entry id: %w", err) + } + listName := "allowlist" + if table == TableBlocklist { + listName = "blocklist" + } + if err := insertAudit(ctx, tx, actor, "list.added", &deviceID, + fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit entry: %w", err) + } + return &ListEntry{ID: id, DeviceID: &deviceID, Kind: kind, Value: value}, nil +} + +// DeleteListEntry removes one device-scoped row and appends one audit entry. +// A row that belongs to another device is not touched: the id and device_id +// must both match, so a guessed id cannot edit someone else's list. +func (s *Store) DeleteListEntry(ctx context.Context, deviceID, entryID int64, table, actor string) error { + name, err := listTable(table) + if err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("delete entry: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var kind, value string + err = tx.QueryRowContext(ctx, + `SELECT kind, value FROM `+name+` WHERE id = ? AND device_id = ?`, + entryID, deviceID).Scan(&kind, &value) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("read entry: %w", err) + } + + if _, err := tx.ExecContext(ctx, `DELETE FROM `+name+` WHERE id = ?`, entryID); err != nil { + return fmt.Errorf("delete entry: %w", err) + } + listName := "allowlist" + if table == TableBlocklist { + listName = "blocklist" + } + if err := insertAudit(ctx, tx, actor, "list.removed", &deviceID, + fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit delete: %w", err) + } + return nil +} + // DeviceByConfigTopic finds a device from a topic the device wrote to, for // example "restrict/harry_phone_2/status". Only the Control Service reads the // topic table, so a topic the service does not know is ignored. diff --git a/internal/store/schedules.go b/internal/store/schedules.go new file mode 100644 index 0000000..cbbf1e0 --- /dev/null +++ b/internal/store/schedules.go @@ -0,0 +1,428 @@ +// Weekly schedules and the effective desired state (AGENTS.md §4 FR-2/FR-4, +// §7 schedules, §11 Phase 6). +// +// A schedule row is a blocking window for one device: the weekday it starts on +// and a start/end wall-clock time. Restrictions are ON during the window. The +// effective state the Control Service publishes combines the manual lock in the +// state table with whatever window is active now, plus the allow/block lists +// (lists.go). The device enforces that: when locked, block every blocklist +// entry except the allowlist entries. +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// Weekday numbers in the schedules table are Monday based (0 = Monday, 6 = +// Sunday, AGENTS.md §7). Go's time.Weekday is Sunday based, so every conversion +// goes through weekdayMon0 / time.WeekdayFor. +const ( + Monday = 0 + Sunday = 6 +) + +// ScheduleWindow is one weekly blocking window. Start and End are "HH:MM" local +// wall-clock. When End is earlier than Start the window crosses midnight and +// belongs to the weekday it starts on: Monday 20:00-07:00 ends Tuesday 07:00. +// Start and End must differ. +type ScheduleWindow struct { + Weekday int + Start string + End string +} + +// Schedule is a stored window. +type Schedule struct { + ID int64 + DeviceID int64 + Weekday int + Start string + End string + CreatedAt time.Time +} + +// EffectiveState is the desired state for one device at one instant: whether it +// is blocked, until when, why, and the lists the device applies while blocked. +// It is what the Control Service publishes retained on the device config topic. +type EffectiveState struct { + DeviceID int64 + Locked bool + Until *time.Time + // Reason is "manual" (a lock from the dashboard), "schedule" (an active + // weekly window) or "" (not blocked). + Reason string + // Allow and Block are the configured list values, always non-nil. + Allow []string + Block []string +} + +// WeekdayName returns the English name for a Monday-based weekday. +func WeekdayName(weekday int) string { + if weekday < Monday || weekday > Sunday { + return "" + } + return time.Weekday((weekday + 1) % 7).String() +} + +// weekdayMon0 converts a Go weekday (Sunday = 0) to the Monday-based number the +// schedules table uses. +func weekdayMon0(t time.Time) int { + return (int(t.Weekday()) + 6) % 7 +} + +// NormalizeWindow validates and canonicalises one window. It is the only +// accepted shape for a schedule row; the editor and the store both call it. +func NormalizeWindow(w ScheduleWindow) (ScheduleWindow, error) { + if w.Weekday < Monday || w.Weekday > Sunday { + return w, fmt.Errorf("weekday must be 0 (Monday) to 6 (Sunday)") + } + start, err := parseClock(w.Start) + if err != nil { + return w, fmt.Errorf("start: %w", err) + } + end, err := parseClock(w.End) + if err != nil { + return w, fmt.Errorf("end: %w", err) + } + if start == end { + return w, errors.New("start and end must differ") + } + w.Start = formatClock(start) + w.End = formatClock(end) + return w, nil +} + +// parseClock reads "HH:MM" or "H:MM" and returns minutes after midnight. +func parseClock(s string) (int, error) { + s = strings.TrimSpace(s) + parts := strings.Split(s, ":") + if len(parts) != 2 { + return 0, fmt.Errorf("time %q must be HH:MM", s) + } + h, err := zeroPadInt(parts[0]) + if err != nil { + return 0, fmt.Errorf("time %q must be HH:MM", s) + } + m, err := zeroPadInt(parts[1]) + if err != nil { + return 0, fmt.Errorf("time %q must be HH:MM", s) + } + if h < 0 || h > 23 || m < 0 || m > 59 { + return 0, fmt.Errorf("time %q is out of range", s) + } + return h*60 + m, nil +} + +func zeroPadInt(s string) (int, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, errors.New("empty number") + } + n := 0 + for _, r := range s { + if r < '0' || r > '9' { + return 0, errors.New("not a number") + } + n = n*10 + int(r-'0') + } + return n, nil +} + +func formatClock(minutes int) string { + return fmt.Sprintf("%02d:%02d", minutes/60, minutes%60) +} + +// ListSchedules returns a device's windows, Monday first and then by start time. +func (s *Store) ListSchedules(ctx context.Context, deviceID int64) ([]Schedule, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, device_id, weekday, start_time, end_time, created_at + FROM schedules WHERE device_id = ? + ORDER BY weekday, start_time, id`, deviceID) + if err != nil { + return nil, fmt.Errorf("list schedules: %w", err) + } + defer rows.Close() + + out := make([]Schedule, 0, 8) + for rows.Next() { + var ( + sc Schedule + created string + ) + if err := rows.Scan(&sc.ID, &sc.DeviceID, &sc.Weekday, &sc.Start, &sc.End, &created); err != nil { + return nil, fmt.Errorf("scan schedule: %w", err) + } + sc.CreatedAt = parseTime(created) + out = append(out, sc) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("list schedules: %w", err) + } + return out, nil +} + +// ReplaceSchedules swaps every window for a device in one transaction and +// appends one audit entry. Windows are validated first, so an invalid edit +// changes nothing. An empty slice clears the device's windows. +func (s *Store) ReplaceSchedules(ctx context.Context, deviceID int64, windows []ScheduleWindow, actor string) error { + if _, err := s.GetDevice(ctx, deviceID); err != nil { + return err + } + normalized := make([]ScheduleWindow, 0, len(windows)) + for i, w := range windows { + n, err := NormalizeWindow(w) + if err != nil { + return fmt.Errorf("window %d: %w", i+1, err) + } + normalized = append(normalized, n) + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("replace schedules: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, `DELETE FROM schedules WHERE device_id = ?`, deviceID); err != nil { + return fmt.Errorf("clear schedules: %w", err) + } + for _, w := range normalized { + if _, err := tx.ExecContext(ctx, + `INSERT INTO schedules (device_id, weekday, start_time, end_time) VALUES (?, ?, ?, ?)`, + deviceID, w.Weekday, w.Start, w.End); err != nil { + return fmt.Errorf("insert schedule: %w", err) + } + } + if err := insertAudit(ctx, tx, actor, "schedule.updated", &deviceID, ScheduleSummary(normalized)); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit schedules: %w", err) + } + return nil +} + +// ScheduleSummary renders windows for the audit log and the editor. +func ScheduleSummary(windows []ScheduleWindow) string { + if len(windows) == 0 { + return "cleared (no windows)" + } + var b strings.Builder + for i, w := range windows { + if i > 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "%s %s-%s", WeekdayName(w.Weekday), w.Start, w.End) + } + return b.String() +} + +// ScheduleActive reports whether any window covers now, and if so the instant +// the last covering window ends. now carries the schedule timezone, so the +// caller decides whether "20:00" means Sydney or UTC. +func ScheduleActive(schedules []Schedule, now time.Time) (bool, *time.Time) { + weekday := weekdayMon0(now) + minutes := now.Hour()*60 + now.Minute() + + active := false + var until time.Time + for _, sc := range schedules { + start, err := parseClock(sc.Start) + if err != nil { + continue // a bad row is ignored, never a crash + } + end, err := parseClock(sc.End) + if err != nil { + continue + } + var covers bool + var coversUntil time.Time + switch { + case start < end: + // Same-day window. + if sc.Weekday == weekday && minutes >= start && minutes < end { + covers = true + coversUntil = atClock(now, end) + } + default: + // Overnight window: it belongs to the day it starts on. + if sc.Weekday == weekday && minutes >= start { + covers = true + coversUntil = atClock(now.AddDate(0, 0, 1), end) + } else if sc.Weekday == (weekday+6)%7 && minutes < end { + covers = true + coversUntil = atClock(now, end) + } + } + if covers { + active = true + if coversUntil.After(until) { + until = coversUntil + } + } + } + if !active { + return false, nil + } + return true, &until +} + +// atClock returns the given wall-clock time on now's calendar day. +func atClock(now time.Time, minutes int) time.Time { + return time.Date(now.Year(), now.Month(), now.Day(), + minutes/60, minutes%60, 0, 0, now.Location()) +} + +// EffectiveState computes the desired state for one device at now: a manual +// lock, an active schedule window, or neither. While blocked, the configured +// allowlist is honoured: the device blocks Block except Allow (FR-4). +// +// A manual lock with no end time ("until I unlock") never lifts, so it wins over +// a schedule end. Otherwise Until is the latest of the manual end and the +// covering window's end. +func (s *Store) EffectiveState(ctx context.Context, dev Device, now time.Time) (EffectiveState, error) { + schedules, err := s.ListSchedules(ctx, dev.ID) + if err != nil { + return EffectiveState{}, err + } + allow, block, err := s.DeviceLists(ctx, dev.ID) + if err != nil { + return EffectiveState{}, err + } + if allow == nil { + allow = []string{} + } + if block == nil { + block = []string{} + } + + eff := EffectiveState{DeviceID: dev.ID, Allow: allow, Block: block} + scheduled, schedUntil := ScheduleActive(schedules, now) + + switch { + case dev.Locked: + eff.Locked = true + eff.Reason = "manual" + eff.Until = dev.Until + // A manual lock with no end is indefinite; a schedule end must not + // shorten it. With an end, keep whichever lifts later. + if dev.Until != nil && schedUntil != nil && schedUntil.After(*dev.Until) { + eff.Until = schedUntil + } + case scheduled: + eff.Locked = true + eff.Reason = "schedule" + eff.Until = schedUntil + } + return eff, nil +} + +// NextScheduleBoundary returns the earliest future schedule start or end across +// every device. The Control Service republishes config at that instant so a +// window turns on and off on time with nobody touching anything (FR-2). It +// returns nil when no window is scheduled. +func (s *Store) NextScheduleBoundary(ctx context.Context, now time.Time) (*time.Time, error) { + rows, err := s.db.QueryContext(ctx, `SELECT weekday, start_time, end_time FROM schedules`) + if err != nil { + return nil, fmt.Errorf("next schedule boundary: %w", err) + } + defer rows.Close() + + var schedules []Schedule + for rows.Next() { + var sc Schedule + if err := rows.Scan(&sc.Weekday, &sc.Start, &sc.End); err != nil { + return nil, fmt.Errorf("scan schedule: %w", err) + } + schedules = append(schedules, sc) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("next schedule boundary: %w", err) + } + best := NextWindowBoundary(schedules, now) + + // A manual lock that expires is a boundary too, so the loop lifts it from + // the device promptly instead of waiting for the next interval. + lockRows, err := s.db.QueryContext(ctx, + `SELECT until FROM state WHERE locked = 1 AND until IS NOT NULL`) + if err != nil { + return nil, fmt.Errorf("next lock boundary: %w", err) + } + defer lockRows.Close() + for lockRows.Next() { + var until sql.NullString + if err := lockRows.Scan(&until); err != nil { + return nil, fmt.Errorf("scan lock until: %w", err) + } + t := parseTime(until.String) + if t.IsZero() || !t.After(now) { + continue + } + if best == nil || t.Before(*best) { + c := t + best = &c + } + } + if err := lockRows.Err(); err != nil { + return nil, fmt.Errorf("next lock boundary: %w", err) + } + return best, nil +} + +// NextWindowBoundary returns the earliest future start or end among the given +// windows, or nil when there is none. now carries the schedule timezone. +func NextWindowBoundary(schedules []Schedule, now time.Time) *time.Time { + type window struct { + weekday, start, end int + } + var windows []window + for _, sc := range schedules { + start, err := parseClock(sc.Start) + if err != nil { + continue + } + end, err := parseClock(sc.End) + if err != nil { + continue + } + if sc.Weekday < Monday || sc.Weekday > Sunday { + continue + } + windows = append(windows, window{weekday: sc.Weekday, start: start, end: end}) + } + if len(windows) == 0 { + return nil + } + + loc := now.Location() + base := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc) + var best *time.Time + consider := func(cand time.Time) { + if cand.After(now) && (best == nil || cand.Before(*best)) { + c := cand + best = &c + } + } + // Yesterday through next week: a window that started yesterday can end + // today, so its end is still a future boundary. + for d := -1; d <= 7; d++ { + day := base.AddDate(0, 0, d) + for _, w := range windows { + if weekdayMon0(day) != w.weekday { + continue + } + consider(day.Add(time.Duration(w.start) * time.Minute)) + end := day.Add(time.Duration(w.end) * time.Minute) + if w.end <= w.start { + end = end.AddDate(0, 0, 1) // overnight + } + consider(end) + } + } + return best +} diff --git a/internal/store/schedules_test.go b/internal/store/schedules_test.go new file mode 100644 index 0000000..f7c7258 --- /dev/null +++ b/internal/store/schedules_test.go @@ -0,0 +1,429 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" +) + +func mustCreateDevice(t *testing.T, st *Store, name, child, platform string) *Device { + t.Helper() + dev, err := st.CreateDevice(context.Background(), name, child, platform, "sam") + if err != nil { + t.Fatalf("CreateDevice: %v", err) + } + return dev +} + +func TestNormalizeWindow(t *testing.T) { + ok, err := NormalizeWindow(ScheduleWindow{Weekday: Monday, Start: "9:5", End: "15:00"}) + if err != nil { + t.Fatalf("NormalizeWindow: %v", err) + } + if ok.Start != "09:05" || ok.End != "15:00" { + t.Errorf("normalized = %+v, want 09:05-15:00", ok) + } + + for _, tc := range []ScheduleWindow{ + {Weekday: 7, Start: "09:00", End: "10:00"}, + {Weekday: -1, Start: "09:00", End: "10:00"}, + {Weekday: Monday, Start: "25:00", End: "10:00"}, + {Weekday: Monday, Start: "09:00", End: "09:70"}, + {Weekday: Monday, Start: "nine", End: "10:00"}, + {Weekday: Monday, Start: "09:00", End: "09:00"}, + {Weekday: Monday, Start: "", End: "10:00"}, + } { + if _, err := NormalizeWindow(tc); err == nil { + t.Errorf("NormalizeWindow(%+v) = nil error, want an error", tc) + } + } +} + +func TestReplaceAndListSchedules(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: 2, Start: "15:00", End: "16:00"}, // Wednesday + {Weekday: Monday, Start: "20:00", End: "07:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + + got, err := st.ListSchedules(ctx, dev.ID) + if err != nil { + t.Fatalf("ListSchedules: %v", err) + } + if len(got) != 2 { + t.Fatalf("schedules = %+v, want 2", got) + } + // Monday first, then Wednesday. + if got[0].Weekday != Monday || got[0].Start != "20:00" || got[0].End != "07:00" { + t.Errorf("schedule[0] = %+v", got[0]) + } + if got[1].Weekday != 2 { + t.Errorf("schedule[1] = %+v", got[1]) + } + + // Replacing clears the old rows and is audited. + if err := st.ReplaceSchedules(ctx, dev.ID, nil, "sam"); err != nil { + t.Fatalf("ReplaceSchedules clear: %v", err) + } + got, err = st.ListSchedules(ctx, dev.ID) + if err != nil { + t.Fatalf("ListSchedules: %v", err) + } + if len(got) != 0 { + t.Errorf("schedules after clear = %+v, want none", got) + } + + audit, err := st.ListAudit(ctx, 10) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + var updates int + for _, e := range audit { + if e.Action == "schedule.updated" { + updates++ + } + } + if updates != 2 { + t.Errorf("schedule.updated audit entries = %d, want 2", updates) + } + + // An invalid window changes nothing. + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: Monday, Start: "09:00", End: "10:00"}, + {Weekday: Monday, Start: "bad", End: "10:00"}, + }, "sam"); err == nil { + t.Fatal("expected an error for an invalid window") + } + got, _ = st.ListSchedules(ctx, dev.ID) + if len(got) != 0 { + t.Errorf("an invalid batch changed the schedules: %+v", got) + } + + if err := st.ReplaceSchedules(ctx, 999, nil, "sam"); !errors.Is(err, ErrNotFound) { + t.Errorf("ReplaceSchedules missing device: err = %v, want ErrNotFound", err) + } +} + +func TestScheduleActive(t *testing.T) { + // 2026-10-12 is a Monday, 2026-10-13 a Tuesday. + monday := func(h, m int) time.Time { + return time.Date(2026, 10, 12, h, m, 0, 0, time.UTC) + } + tuesday := func(h, m int) time.Time { + return time.Date(2026, 10, 13, h, m, 0, 0, time.UTC) + } + + sameDay := []Schedule{{Weekday: Monday, Start: "09:00", End: "15:00"}} + overnight := []Schedule{{Weekday: Monday, Start: "20:00", End: "07:00"}} + + tests := []struct { + name string + schedules []Schedule + now time.Time + active bool + until time.Time + }{ + {"inside same-day", sameDay, monday(12, 0), true, monday(15, 0)}, + {"before same-day", sameDay, monday(8, 59), false, time.Time{}}, + {"at start", sameDay, monday(9, 0), true, monday(15, 0)}, + {"at end is outside", sameDay, monday(15, 0), false, time.Time{}}, + {"wrong day", sameDay, tuesday(12, 0), false, time.Time{}}, + {"overnight before midnight", overnight, monday(21, 0), true, tuesday(7, 0)}, + {"overnight after midnight", overnight, tuesday(2, 0), true, tuesday(7, 0)}, + {"overnight after end", overnight, tuesday(7, 0), false, time.Time{}}, + {"overnight wrong day", overnight, tuesday(21, 0), false, time.Time{}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + active, until := ScheduleActive(tc.schedules, tc.now) + if active != tc.active { + t.Fatalf("active = %v, want %v", active, tc.active) + } + if tc.active { + if until == nil || !until.Equal(tc.until) { + t.Errorf("until = %v, want %v", until, tc.until) + } + } else if until != nil { + t.Errorf("until = %v, want nil", until) + } + }) + } + + // Two overlapping windows: the later end wins. + overlap := []Schedule{ + {Weekday: Monday, Start: "09:00", End: "12:00"}, + {Weekday: Monday, Start: "11:00", End: "13:00"}, + } + if _, until := ScheduleActive(overlap, monday(11, 30)); until == nil || !until.Equal(monday(13, 0)) { + t.Errorf("overlapping until = %v, want 13:00", until) + } +} + +// TestEffectiveStateAllowlistIsolation is the Phase 6 definition of done: with +// Spotify allowlisted while other apps are blocked and a window active, Spotify +// is allowed and the others are blocked. +func TestEffectiveStateAllowlistIsolation(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + // 2026-10-13 is a Tuesday. + now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: weekdayMon0(now), Start: "19:00", End: "21:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + if _, err := st.AddListEntry(ctx, dev.ID, TableAllowlist, ListKindApp, "com.spotify.music", "sam"); err != nil { + t.Fatalf("AddListEntry allow: %v", err) + } + for _, v := range []string{"com.youtube.android", "com.roblox.client"} { + if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, v, "sam"); err != nil { + t.Fatalf("AddListEntry block: %v", err) + } + } + blockSite, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindSite, "YouTube.com", "sam") + if err != nil { + t.Fatalf("AddListEntry block site: %v", err) + } + if blockSite.Value != "youtube.com" { + t.Errorf("site value = %q, want it lower-cased", blockSite.Value) + } + + eff, err := st.EffectiveState(ctx, *dev, now) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if !eff.Locked || eff.Reason != "schedule" { + t.Fatalf("effective = %+v, want locked by schedule", eff) + } + if eff.Until == nil || !eff.Until.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) { + t.Errorf("until = %v, want 21:00", eff.Until) + } + if len(eff.Allow) != 1 || eff.Allow[0] != "com.spotify.music" { + t.Errorf("allow = %v, want Spotify", eff.Allow) + } + if len(eff.Block) != 3 { + t.Errorf("block = %v, want the other apps and the site", eff.Block) + } + + // Outside the window the device is not blocked, but the lists remain so the + // device can apply them the moment a window starts. + eff, err = st.EffectiveState(ctx, *dev, time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if eff.Locked || eff.Reason != "" { + t.Errorf("outside window = %+v, want unlocked", eff) + } + if len(eff.Allow) != 1 { + t.Errorf("allow = %v, want the list still present", eff.Allow) + } + + // A manual lock is the reason, and "until I unlock" never lifts. + locked, err := st.SetDeviceLock(ctx, dev.ID, true, nil, "sam", "state.locked") + if err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + eff, err = st.EffectiveState(ctx, *locked, now) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if !eff.Locked || eff.Reason != "manual" || eff.Until != nil { + t.Errorf("manual lock = %+v, want locked manually with no end", eff) + } + + // A manual lock with an end keeps the later of the two. + end := time.Date(2026, 10, 13, 22, 0, 0, 0, time.UTC) + locked, err = st.SetDeviceLock(ctx, dev.ID, true, &end, "sam", "state.locked") + if err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + eff, err = st.EffectiveState(ctx, *locked, now) + if err != nil { + t.Fatalf("EffectiveState: %v", err) + } + if eff.Until == nil || !eff.Until.Equal(end) { + t.Errorf("until = %v, want the later manual end %v", eff.Until, end) + } +} + +func TestNextScheduleBoundary(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: weekdayMon0(time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC)), Start: "19:00", End: "21:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + + // Tuesday 20:30: the next boundary is the window end at 21:00. + now := time.Date(2026, 10, 13, 20, 30, 0, 0, time.UTC) + next, err := st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) { + t.Fatalf("next = %v, want 2026-10-13 21:00", next) + } + + // Tuesday 22:00: the next boundary is next week's start. + now = time.Date(2026, 10, 13, 22, 0, 0, 0, time.UTC) + next, err = st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(time.Date(2026, 10, 20, 19, 0, 0, 0, time.UTC)) { + t.Fatalf("next = %v, want 2026-10-20 19:00", next) + } + + // An overnight window: the morning end is a boundary even though the start + // was yesterday. + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: weekdayMon0(time.Date(2026, 10, 12, 0, 0, 0, 0, time.UTC)), Start: "20:00", End: "07:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + now = time.Date(2026, 10, 13, 2, 0, 0, 0, time.UTC) // Tuesday 02:00 + next, err = st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(time.Date(2026, 10, 13, 7, 0, 0, 0, time.UTC)) { + t.Fatalf("overnight next = %v, want 2026-10-13 07:00", next) + } + + // No schedules, no boundary. + if err := st.ReplaceSchedules(ctx, dev.ID, nil, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + if next, err = st.NextScheduleBoundary(ctx, now); err != nil || next != nil { + t.Errorf("NextScheduleBoundary with none = (%v, %v), want (nil, nil)", next, err) + } +} + +func TestNextBoundaryIncludesLockExpiry(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + + now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC) + if err := st.ReplaceSchedules(ctx, dev.ID, []ScheduleWindow{ + {Weekday: weekdayMon0(now), Start: "21:00", End: "22:00"}, + }, "sam"); err != nil { + t.Fatalf("ReplaceSchedules: %v", err) + } + // A lock that expires before the next window is the next boundary. + until := now.Add(10 * time.Minute) + if _, err := st.SetDeviceLock(ctx, dev.ID, true, &until, "sam", "state.locked"); err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + next, err := st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(until) { + t.Fatalf("next = %v, want the lock expiry %v", next, until) + } + + // An expired lock is not a boundary. + past := now.Add(-time.Minute) + if _, err := st.SetDeviceLock(ctx, dev.ID, true, &past, "sam", "state.locked"); err != nil { + t.Fatalf("SetDeviceLock: %v", err) + } + next, err = st.NextScheduleBoundary(ctx, now) + if err != nil { + t.Fatalf("NextScheduleBoundary: %v", err) + } + if next == nil || !next.Equal(time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)) { + t.Fatalf("next = %v, want the window start 21:00", next) + } +} + +func TestListEntryCRUD(t *testing.T) { + ctx := context.Background() + st := testStore(t) + dev := mustCreateDevice(t, st, "Harry Phone", "Harry", "android") + other := mustCreateDevice(t, st, "Finn PC", "Finn", "windows") + + entry, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, "com.youtube.android", "sam") + if err != nil { + t.Fatalf("AddListEntry: %v", err) + } + if entry.ID == 0 || entry.Value != "com.youtube.android" { + t.Errorf("entry = %+v", entry) + } + + // A duplicate (case-insensitive) is refused. + if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, ListKindApp, "COM.YouTube.Android", "sam"); err == nil { + t.Error("expected a duplicate to be refused") + } + + // Each list is separate. + if _, err := st.AddListEntry(ctx, dev.ID, TableAllowlist, ListKindApp, "com.spotify.music", "sam"); err != nil { + t.Fatalf("AddListEntry allow: %v", err) + } + block, err := st.ListEntries(ctx, dev.ID, TableBlocklist) + if err != nil { + t.Fatalf("ListEntries: %v", err) + } + if len(block) != 1 || block[0].Value != "com.youtube.android" { + t.Errorf("block = %+v", block) + } + + // A device sees only its own entries. + otherBlock, err := st.ListEntries(ctx, other.ID, TableBlocklist) + if err != nil { + t.Fatalf("ListEntries other: %v", err) + } + if len(otherBlock) != 0 { + t.Errorf("other device block = %+v, want none", otherBlock) + } + + // Deleting through the wrong device does not touch the row. + if err := st.DeleteListEntry(ctx, other.ID, entry.ID, TableBlocklist, "sam"); !errors.Is(err, ErrNotFound) { + t.Errorf("cross-device delete: err = %v, want ErrNotFound", err) + } + if err := st.DeleteListEntry(ctx, dev.ID, entry.ID, TableBlocklist, "sam"); err != nil { + t.Fatalf("DeleteListEntry: %v", err) + } + block, _ = st.ListEntries(ctx, dev.ID, TableBlocklist) + if len(block) != 0 { + t.Errorf("block after delete = %+v, want none", block) + } + + // Invalid input is rejected before it reaches the table. + for _, tc := range []struct{ kind, value string }{ + {"widget", "x"}, + {ListKindApp, " "}, + {ListKindApp, "with\nnewline"}, + } { + if _, err := st.AddListEntry(ctx, dev.ID, TableBlocklist, tc.kind, tc.value, "sam"); err == nil { + t.Errorf("AddListEntry(%q, %q) = nil error, want an error", tc.kind, tc.value) + } + } + if _, err := st.AddListEntry(ctx, dev.ID, "notes", ListKindApp, "x", "sam"); err == nil { + t.Error("expected an error for an unknown list table") + } + + // The allow/block values used by the effective state are de-duplicated. + if allow, block, err := st.DeviceLists(ctx, dev.ID); err != nil { + t.Fatalf("DeviceLists: %v", err) + } else { + if len(allow) != 1 || allow[0] != "com.spotify.music" { + t.Errorf("allow = %v", allow) + } + if len(block) != 0 { + t.Errorf("block = %v", block) + } + } +} diff --git a/internal/web/devices.go b/internal/web/devices.go index f56c83d..1d441c0 100644 --- a/internal/web/devices.go +++ b/internal/web/devices.go @@ -16,7 +16,8 @@ import ( "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" ) -// deviceView is the display model for one dashboard card. +// 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 @@ -26,10 +27,13 @@ type deviceView struct { 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 } @@ -44,9 +48,21 @@ type auditView struct { CreatedAtLocal string } -func buildDeviceViews(devs []store.Device) []deviceView { +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, @@ -55,21 +71,29 @@ func buildDeviceViews(devs []store.Device) []deviceView { PlatformLabel: platformLabel(d.Platform), Topic: d.Topic, Online: d.Online, - Locked: d.Locked, - Until: d.Until, + Locked: eff.Locked, + ManualLocked: d.Locked, + Until: eff.Until, } - if d.Locked { + 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 d.Until != nil { - local := d.Until.Local() + if eff.Until != nil { + local := eff.Until.In(loc) v.UntilLocal = local.Format("Mon 15:04") - v.TimeLeft = timeLeft(*d.Until) + v.TimeLeft = timeLeft(*eff.Until) } if d.LastSeen != nil { - v.LastSeen = d.LastSeen.Local().Format("Mon 02 Jan 15:04") + v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04") v.HasLastSeen = true } out = append(out, v) @@ -77,7 +101,10 @@ func buildDeviceViews(devs []store.Device) []deviceView { return out } -func buildAuditViews(entries []store.AuditEntry) []auditView { +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{ @@ -88,7 +115,7 @@ func buildAuditViews(entries []store.AuditEntry) []auditView { Detail: e.Detail, } if !e.CreatedAt.IsZero() { - v.CreatedAtLocal = e.CreatedAt.Local().Format("02 Jan 15:04:05") + v.CreatedAtLocal = e.CreatedAt.In(loc).Format("02 Jan 15:04:05") } out = append(out, v) } @@ -140,6 +167,12 @@ func auditLabel(action string) string { 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": diff --git a/internal/web/schedules.go b/internal/web/schedules.go new file mode 100644 index 0000000..b60b96e --- /dev/null +++ b/internal/web/schedules.go @@ -0,0 +1,387 @@ +// 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) +} diff --git a/internal/web/schedules_test.go b/internal/web/schedules_test.go new file mode 100644 index 0000000..1add0c2 --- /dev/null +++ b/internal/web/schedules_test.go @@ -0,0 +1,222 @@ +package web + +import ( + "fmt" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" +) + +func createTestDevice(t *testing.T, app *testApp) *store.Device { + t.Helper() + rec := app.do(t, http.MethodPost, "/devices", url.Values{ + "csrf": {app.csrf}, + "name": {"Harry's Pixel"}, + "child": {"Harry"}, + "platform": {"android"}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + devs, err := app.store.ListDevices(t.Context()) + if err != nil { + t.Fatalf("ListDevices: %v", err) + } + if len(devs) != 1 { + t.Fatalf("devices = %d, want 1", len(devs)) + } + return &devs[0] +} + +func TestScheduleEditorSavesAndBlocksBySchedule(t *testing.T) { + app := newTestApp(t) + dev := createTestDevice(t, app) + + // The empty grid renders. + rec := app.do(t, http.MethodGet, fmt.Sprintf("/devices/%d/schedule", dev.ID), nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Weekly schedule") { + t.Fatalf("GET schedule = %d", rec.Code) + } + + // Save a window that covers now, whatever the local time is. + now := time.Now() + weekday := (int(now.Weekday()) + 6) % 7 // Monday based + start := now.Add(-1 * time.Hour).Format("15:04") + end := now.Add(1 * time.Hour).Format("15:04") + rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ + "csrf": {app.csrf}, + fmt.Sprintf("start_%d_0", weekday): {start}, + fmt.Sprintf("end_%d_0", weekday): {end}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST schedule = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + + schedules, err := app.store.ListSchedules(t.Context(), dev.ID) + if err != nil { + t.Fatalf("ListSchedules: %v", err) + } + if len(schedules) != 1 || schedules[0].Start != start || schedules[0].End != end { + t.Fatalf("schedules = %+v, want one %s-%s", schedules, start, end) + } + + // The dashboard reflects the schedule-driven block, with the reason. + rec = app.do(t, http.MethodGet, "/", nil) + if body := rec.Body.String(); !strings.Contains(body, "by schedule") { + t.Errorf("dashboard should say the device is blocked by schedule:\n%s", body) + } + + // An invalid window is rejected and nothing is stored. + rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ + "csrf": {app.csrf}, + "start_0_0": {"not-a-time"}, + "end_0_0": {"10:00"}, + }) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "HH:MM") { + t.Errorf("invalid window = %d, want the editor with an error", rec.Code) + } + schedules, _ = app.store.ListSchedules(t.Context(), dev.ID) + if len(schedules) != 1 { + t.Errorf("an invalid submit changed the schedules: %+v", schedules) + } + + // Clear all removes the window. + rec = app.do(t, http.MethodPost, fmt.Sprintf("/devices/%d/schedule", dev.ID), url.Values{ + "csrf": {app.csrf}, + "clear": {"1"}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("clear = %d, want 303", rec.Code) + } + schedules, _ = app.store.ListSchedules(t.Context(), dev.ID) + if len(schedules) != 0 { + t.Errorf("schedules after clear = %+v, want none", schedules) + } + + // Every change is audited. + audit, err := app.store.ListAudit(t.Context(), 20) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + var updates int + for _, e := range audit { + if e.Action == "schedule.updated" { + updates++ + } + } + if updates != 2 { + t.Errorf("schedule.updated audit entries = %d, want 2", updates) + } +} + +func TestListsEditorAddAndRemove(t *testing.T) { + app := newTestApp(t) + dev := createTestDevice(t, app) + base := fmt.Sprintf("/devices/%d/lists", dev.ID) + + rec := app.do(t, http.MethodGet, base, nil) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Blocklist and allowlist") { + t.Fatalf("GET lists = %d", rec.Code) + } + + // Allowlist Spotify and block other apps. + for _, form := range []url.Values{ + {"csrf": {app.csrf}, "table": {"allow"}, "kind": {"app"}, "value": {"com.spotify.music"}}, + {"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"}}, + {"csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"roblox.exe"}}, + } { + rec = app.do(t, http.MethodPost, base, form) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST list entry = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + } + + rec = app.do(t, http.MethodGet, base, nil) + body := rec.Body.String() + for _, want := range []string{"com.spotify.music", "com.youtube.android", "roblox.exe", "Allowed", "Blocked"} { + if !strings.Contains(body, want) { + t.Errorf("lists page missing %q", want) + } + } + + // A duplicate is refused with a message, not a second row. + rec = app.do(t, http.MethodPost, base, url.Values{ + "csrf": {app.csrf}, "table": {"block"}, "kind": {"app"}, "value": {"com.youtube.android"}, + }) + if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "err=") { + t.Errorf("duplicate = %d %q, want a redirect with an error", rec.Code, rec.Header().Get("Location")) + } + + // Remove one blocked app. + blocked, err := app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist) + if err != nil { + t.Fatalf("ListEntries: %v", err) + } + if len(blocked) != 2 { + t.Fatalf("blocklist = %+v, want 2", blocked) + } + rec = app.do(t, http.MethodPost, + fmt.Sprintf("/devices/%d/lists/%d/delete", dev.ID, blocked[0].ID), + url.Values{"csrf": {app.csrf}, "table": {"block"}}) + if rec.Code != http.StatusSeeOther { + t.Fatalf("delete = %d, want 303", rec.Code) + } + blocked, _ = app.store.ListEntries(t.Context(), dev.ID, store.TableBlocklist) + if len(blocked) != 1 || blocked[0].Value != "roblox.exe" { + t.Errorf("blocklist after delete = %+v", blocked) + } + // The allowlist is untouched. + allow, _ := app.store.ListEntries(t.Context(), dev.ID, store.TableAllowlist) + if len(allow) != 1 || allow[0].Value != "com.spotify.music" { + t.Errorf("allowlist = %+v", allow) + } + + // The audit log records the adds and the remove. + audit, err := app.store.ListAudit(t.Context(), 20) + if err != nil { + t.Fatalf("ListAudit: %v", err) + } + var added, removed int + for _, e := range audit { + switch e.Action { + case "list.added": + added++ + case "list.removed": + removed++ + } + } + if added != 3 || removed != 1 { + t.Errorf("audit added=%d removed=%d, want 3 and 1", added, removed) + } + + // Cross-device delete is a 404. + other := createTestDeviceNamed(t, app, "Finn PC", "Finn", "windows") + rec = app.do(t, http.MethodPost, + fmt.Sprintf("/devices/%d/lists/%d/delete", other.ID, blocked[0].ID), + url.Values{"csrf": {app.csrf}, "table": {"block"}}) + if rec.Code != http.StatusNotFound { + t.Errorf("cross-device delete = %d, want 404", rec.Code) + } +} + +func createTestDeviceNamed(t *testing.T, app *testApp, name, child, platform string) *store.Device { + t.Helper() + rec := app.do(t, http.MethodPost, "/devices", url.Values{ + "csrf": {app.csrf}, + "name": {name}, + "child": {child}, + "platform": {platform}, + }) + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST /devices = %d, want 303; body=%s", rec.Code, rec.Body.String()) + } + devs, err := app.store.ListDevices(t.Context()) + if err != nil { + t.Fatalf("ListDevices: %v", err) + } + return &devs[len(devs)-1] +} diff --git a/internal/web/server.go b/internal/web/server.go index 88e15e5..214ee80 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -77,7 +77,7 @@ func (s *Server) publishConfig(ctx context.Context, dev store.Device) { return } s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic, - "locked", dev.Locked) + "manual_locked", dev.Locked) } // Handler returns the fully wrapped HTTP handler. @@ -96,6 +96,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate)) mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock)) mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock)) + mux.HandleFunc("GET /devices/{id}/schedule", s.requireAuth(s.handleScheduleEditor)) + mux.HandleFunc("POST /devices/{id}/schedule", s.requireAuth(s.handleScheduleSave)) + mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor)) + mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd)) + mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete)) staticSub, err := fs.Sub(staticFS, "static") if err != nil { @@ -320,8 +325,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { CSRF: s.auth.EnsureCSRF(w, r), Version: s.cfg.Version, AppEnv: s.cfg.AppEnv, - Devices: buildDeviceViews(devs), - Audit: buildAuditViews(audit), + Devices: buildDeviceViews(devs, s.effectiveStates(ctx, devs), s.loc()), + Audit: buildAuditViews(audit, s.loc()), Flash: strings.TrimSpace(r.URL.Query().Get("ok")), ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), }) @@ -333,11 +338,41 @@ func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) { s.renderFragment(w, r, "status.html", s.buildStatusData(ctx)) } +// effectiveStates computes the desired state for every device at the same +// instant, so one dashboard load is internally consistent. +func (s *Server) effectiveStates(ctx context.Context, devs []store.Device) map[int64]store.EffectiveState { + out := make(map[int64]store.EffectiveState, len(devs)) + now := s.now() + for _, d := range devs { + eff, err := s.store.EffectiveState(ctx, d, now) + if err != nil { + s.log.Error("effective state", "device", d.Name, "err", err) + continue + } + out[d.ID] = eff + } + return out +} + +// now is the current time in the schedule timezone, so weekly windows are +// evaluated wall-clock and not in the container's default UTC. +func (s *Server) now() time.Time { + return time.Now().In(s.loc()) +} + +// loc is the schedule timezone, defaulting to the process local zone. +func (s *Server) loc() *time.Location { + if s.cfg.ScheduleLocation != nil { + return s.cfg.ScheduleLocation + } + return time.Local +} + func (s *Server) buildStatusData(ctx context.Context) statusData { data := statusData{ DBHealthy: s.store.Ping(ctx) == nil, DBPath: s.cfg.DBPath, - ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"), + ServerTime: time.Now().In(s.loc()).Format("Mon 02 Jan 2006 15:04:05"), ServerUp: time.Since(serverStarted).Round(time.Second).String(), } if s.pub != nil { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 20683fc..17fdf80 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -464,6 +464,88 @@ a.button-primary:hover { text-decoration: none; } .form-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; } a.button-utility { text-decoration: none; } +/* ---------- Weekly schedule editor (Phase 6) ---------- */ + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.schedule-note { + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: var(--rounded-lg); + padding: var(--space-md) var(--space-lg); + display: grid; + gap: var(--space-xs); +} +.schedule-note__now { margin: 0; display: flex; align-items: center; gap: 8px; color: var(--ink); font: var(--type-body-md); } +.schedule-note__next { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); } +.schedule-note__hint { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); max-width: 70ch; } + +.schedule-scroll { overflow-x: auto; } +.schedule-grid { + width: 100%; + border-collapse: collapse; + min-width: 520px; +} +.schedule-grid th, +.schedule-grid td { + text-align: left; + padding: var(--space-xs) var(--space-sm); + border-bottom: 1px solid var(--hairline); + vertical-align: middle; +} +.schedule-grid thead th { + font: var(--type-eyebrow); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--ink-muted); +} +.schedule-grid tbody th { font: var(--type-body-md); color: var(--ink); white-space: nowrap; } +.schedule-grid__slot { white-space: nowrap; } +.schedule-grid__dash { padding: 0 6px; color: var(--ink-faint); } +.time-input { width: auto; min-width: 120px; display: inline-block; } + +/* ---------- Blocklist / allowlist editor (Phase 6) ---------- */ + +.list-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: var(--space-md); + align-items: start; +} +.list-panel { display: grid; gap: var(--space-sm); } +.list-panel__sub { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); } +.list-add { display: flex; flex-wrap: wrap; gap: var(--space-xs); } +.list-add .text-input { flex: 1 1 160px; min-width: 0; } +.list-entries { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); } +.list-entry { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-xs) var(--space-sm); + padding: var(--space-xs) 0; + border-top: 1px solid var(--hairline); +} +.list-entry--empty { color: var(--ink-muted); font-style: italic; border-top: 0; } +.list-entry__kind { + padding: 2px 8px; + border-radius: var(--rounded-full); + background: var(--surface-2); + color: var(--ink-muted); + font: var(--type-eyebrow); +} +.list-entry__value { flex: 1 1 auto; word-break: break-all; } +.list-entry__remove { margin: 0; } + /* ---------- Responsive ---------- */ @media (max-width: 760px) { @@ -474,4 +556,5 @@ a.button-utility { text-decoration: none; } .nav-bar__user { display: none; } .section__head { flex-wrap: wrap; } .audit-item__meta { margin-left: 0; width: 100%; } + .list-add .button-primary { flex: 1 1 auto; } } diff --git a/internal/web/templates/lists-editor.html b/internal/web/templates/lists-editor.html new file mode 100644 index 0000000..337a7b1 --- /dev/null +++ b/internal/web/templates/lists-editor.html @@ -0,0 +1,114 @@ +{{define "lists-editor.html"}} + + + + + + {{.Title}} — Internet Use Control + + + + + + +
+
+

← Dashboard

+

{{.Heading}}

+

{{.Sub}}

+
+ + {{if .Flash}}

{{.Flash}}

{{end}} + {{if .Error}}{{end}} + +
+

{{.PlatformHint}}

+

While a window is active or the device is locked, everything on the + blocklist is blocked except the allowlist. The allowlist is the exception list.

+
+ +
+
+

Blocked

+

Blocked during a window or lock.

+
+ + + + + +
+
    + {{range .Block}} +
  • + {{.KindLabel}} + {{.Value}} +
    + + + +
    +
  • + {{else}} +
  • Nothing blocked yet.
  • + {{end}} +
+
+ +
+

Allowed

+

Stays available while a block is active.

+
+ + + + + +
+
    + {{range .Allow}} +
  • + {{.KindLabel}} + {{.Value}} +
    + + + +
    +
  • + {{else}} +
  • Nothing allowed yet.
  • + {{end}} +
+
+
+ + +
+ +
+ Control Service · {{.Version}} · {{.AppEnv}} + · + {{year}} Family Home Lab +
+ +{{end}} diff --git a/internal/web/templates/partials/device-cards.html b/internal/web/templates/partials/device-cards.html index 3ad0cf1..084da1b 100644 --- a/internal/web/templates/partials/device-cards.html +++ b/internal/web/templates/partials/device-cards.html @@ -12,7 +12,7 @@

- {{if .Locked}}Blocked{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}} + {{if .Locked}}Blocked{{if .ReasonLabel}} {{.ReasonLabel}}{{end}}{{if .TimeLeft}} · {{.TimeLeft}}{{end}}{{else}}No block active{{end}}

{{if .Until}}

Lifts {{.UntilLocal}}

{{end}} @@ -34,6 +34,8 @@ {{end}} Edit + Schedule + Lists

diff --git a/internal/web/templates/schedule-editor.html b/internal/web/templates/schedule-editor.html new file mode 100644 index 0000000..d381ca1 --- /dev/null +++ b/internal/web/templates/schedule-editor.html @@ -0,0 +1,91 @@ +{{define "schedule-editor.html"}} + + + + + + {{.Title}} — Internet Use Control + + + + +

+ +
+
+

← Dashboard

+

{{.Heading}}

+

{{.Sub}}

+
+ + {{if .Flash}}

{{.Flash}}

{{end}} + {{if .Error}}{{end}} + +
+ {{if .ActiveNow}} +

A window is active now{{if .ActiveUntil}} until {{.ActiveUntil}}{{end}}.

+ {{else}} +

No window is active now.

+ {{end}} + {{if .NextBoundary}}

Next change: {{.NextBoundary}}.

{{end}} +

Each row is a day. A window means restrictions are ON during that time. + Leave both boxes empty to remove a window. An end time earlier than the start crosses midnight.

+
+ +
+
+ + +
+ + + + + + {{range index .Rows 0}}{{range .Slots}}{{end}}{{end}} + + + + {{range $row := .Rows}} + + + {{range $i, $slot := $row.Slots}} + + {{end}} + + {{end}} + +
Weekly blocking windows for {{.DeviceName}}
DayWindow
{{$row.Name}} + + + +
+
+ +
+ + + Blocklist / allowlist + Done +
+
+
+
+ + + +{{end}}