Phase 6: schedules, blocklists, allowlists (editors + effective state)

- Weekly schedule editor per device: a Monday-Sunday grid of blocking
  windows, including windows that cross midnight.
- Blocklist/allowlist editor per device: app (Android package / Windows
  process) and site (hostname) entries, tagged by the device platform.
- Effective desired state: a manual lock, an active schedule window, or
  neither. While blocked the allowlist is honoured (Spotify stays while
  other apps are blocked). Published retained with reason schedule/manual.
- Boundary-aware republish loop so a window turns on and off on time,
  and manual lock expiry is cleared promptly.
- CONTROL_TIMEZONE (default Australia/Sydney) with embedded tzdata, so
  wall-clock windows are evaluated in the family zone, not the container.
- Every change is written to the append-only audit log (FR-15).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-10-13 16:12:02 +11:00
parent 2f8e788fe6
commit 9af543c394
21 changed files with 2341 additions and 94 deletions

View File

@@ -1,5 +1,5 @@
IMAGE ?= webrestrict/control:0.3.0-phase3 IMAGE ?= webrestrict/control:0.4.0-phase6
BUILD_VERSION ?= 0.3.0-phase3 BUILD_VERSION ?= 0.4.0-phase6
# Bounded docker helper for the Go toolchain. The container runs as the current # 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. # user, so the repository and the module cache stay owned by you.

View File

@@ -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 per-device state and usage, and serves the web dashboard. Only it publishes
device config. device config.
This repository currently contains **Phase 3 — MQTT** (AGENTS.md §11), on top of This repository currently contains **Phase 6 — Schedules, blocklists,
Phases 1 and 2: allowlists** (AGENTS.md §11), on top of Phases 1–3:
- Go service with its own image (`Dockerfile`) and compose file. - Go service with its own image (`Dockerfile`) and compose file.
- SQLite schema for the whole data model (§7), applied through versioned - SQLite schema for the whole data model (§7), applied through versioned
@@ -26,9 +26,18 @@ Phases 1 and 2:
`restrict/<device>/config`; device status and telemetry are subscribed and `restrict/<device>/config`; device status and telemetry are subscribed and
written to SQLite; presence comes from the broker's Last Will and Testament. written to SQLite; presence comes from the broker's Last Will and Testament.
The broker is transport only — SQLite stays the source of truth. 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 Enforcement itself (the kids' clients), the admin API, usage screens and the Hub
in later phases. Nothing here touches the router, Pi-hole, or DNS. arrive in later phases. Nothing here touches the router, Pi-hole, or DNS.
## Quick start (on `.13`) ## Quick start (on `.13`)
@@ -44,7 +53,7 @@ docker compose up -d --build
# 3. Prove it. # 3. Prove it.
curl -s http://127.0.0.1:8791/health 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 <http://127.0.0.1:8791/> in a browser. It redirects to `/login`. Sign in Open <http://127.0.0.1:8791/> 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_ADMIN_PASSWORD_HASH` | — | bcrypt hash (option B, preferred) |
| `CONTROL_APP_ENV` | `production` | Label shown in the footer | | `CONTROL_APP_ENV` | `production` | Label shown in the footer |
| `CONTROL_LOG_LEVEL` | `info` | `debug`/`info`/`warn`/`error` | | `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_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_BROKER` | `tcp://host.docker.internal:1883` | Broker URL; `tls://…:8883` once the TLS listener exists |
| `CONTROL_MQTT_CLIENT_ID` | `control-service` | MQTT client id | | `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: Generate a bcrypt hash without host Go:
```bash ```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' hash-password 'your-password'
``` ```
@@ -124,13 +134,17 @@ Broker: the shared **Mosquitto on `.13`**. A second broker is not added
```json ```json
{"v":1,"device_id":1,"name":"Harry's phone","platform":"android", {"v":1,"device_id":1,"name":"Harry's phone","platform":"android",
"locked":true,"until":"2026-10-13T21:00:00Z","allow":[],"block":[], "locked":true,"until":"2026-10-13T21:00:00Z",
"generated_at":"2026-10-13T03:30:27Z"} "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 `allow` and `block` are always arrays, never `null`. `locked` and `until` are the
`allowlist` and `blocklist` tables, which get their editors in Phase 6; until **effective** state: a `reason` of `schedule` means a weekly window is active,
then they are empty. A client must ignore unknown fields, so this shape can grow. `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) ### 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}` | Updates name, child, platform. |
| `POST` | `/devices/{id}/lock` | Locks now; optional `minutes` (0 = until unlocked). | | `POST` | `/devices/{id}/lock` | Locks now; optional `minutes` (0 = until unlocked). |
| `POST` | `/devices/{id}/unlock` | Unlocks now. | | `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 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 append to the `audit` table and republish the device's retained config. Devices
are never hard-deleted (§10 rule 10). 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 ## Development
@@ -206,6 +243,7 @@ cmd/control/ service entry point (+ hash-password, mqtt-clients)
internal/config/ environment configuration internal/config/ environment configuration
internal/store/ SQLite open, migrations, audit, devices + state internal/store/ SQLite open, migrations, audit, devices + state
devices.go device registry, desired state, lock expiry 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 reports.go presence and telemetry written from broker reports
lists.go allow/block lists and device lookup by topic lists.go allow/block lists and device lookup by topic
migrations/001_init.sql, 002_telemetry_unique.sql 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 link.go paho client, subscriptions, publish, TLS verification
internal/web/ HTTP handlers, templates, static assets internal/web/ HTTP handlers, templates, static assets
devices.go device registry + manual lock/unlock handlers 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 static/ tokens.css (Family Home Lab), app.css, htmx, Inter fonts
Dockerfile own image (multi-stage, CGO-free) Dockerfile own image (multi-stage, CGO-free)
docker-compose.yml own container, port 8791 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` `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. (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 - No group/global list editing from the UI. The data model supports a NULL
those tables, but nothing edits them yet. `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 usage screens (Phase 7). Telemetry is stored, not yet shown.
- No admin API (Phase 8). - No admin API (Phase 8).
- No kids' clients (Phases 4–5). The topic and payload contract above is what - 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). - No Hub contact (Phase 9).

View File

@@ -35,6 +35,12 @@ var version = "dev"
// service repairs it from the database without a restart. // service repairs it from the database without a restart.
const republishInterval = 15 * time.Minute 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() { func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
switch os.Args[1] { switch os.Args[1] {
@@ -104,11 +110,12 @@ func run() error {
Password: cfg.MQTTPassword, Password: cfg.MQTTPassword,
CAFile: cfg.MQTTCAFile, CAFile: cfg.MQTTCAFile,
Actor: "device", Actor: "device",
Location: cfg.ScheduleLocation,
Log: log, Log: log,
Devices: st, Devices: st,
Source: st, Source: st,
Reports: st, Reports: st,
OnConnected: func() { republish(ctx, log, link) }, OnConnected: func() { republish(ctx, log, link, st) },
}) })
if err != nil { if err != nil {
return err return err
@@ -116,7 +123,7 @@ func run() error {
pub = link pub = link
link.Start(ctx) link.Start(ctx)
defer link.Stop() defer link.Stop()
go republishLoop(ctx, log, link) go republishLoop(ctx, log, link, st, cfg.ScheduleLocation)
if !cfg.MQTTTLS() { if !cfg.MQTTTLS() {
log.Warn("mqtt is running without TLS; the shared broker has no TLS listener yet (AGENTS.md §10 rule 6)", log.Warn("mqtt is running without TLS; the shared broker has no TLS listener yet (AGENTS.md §10 rule 6)",
"broker", cfg.MQTTBroker) "broker", cfg.MQTTBroker)
@@ -169,14 +176,20 @@ func run() error {
} }
// republish pushes every device's retained config. It runs once per connect and // 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 // whenever the desired state may have changed, so the broker's view is rebuilt
// SQLite, the source of truth. // from SQLite, the source of truth. It first clears any manual lock that has
func republish(ctx context.Context, log *slog.Logger, link *mqtt.Link) { // 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 { if link == nil {
return return
} }
ctx, cancel := context.WithTimeout(ctx, 30*time.Second) ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel() 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) n, err := link.PublishAll(ctx)
if err != nil { if err != nil {
log.Error("mqtt publish all", "published", n, "err", err) 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) log.Info("mqtt retained config refreshed", "devices", n)
} }
func republishLoop(ctx context.Context, log *slog.Logger, link *mqtt.Link) { // republishLoop refreshes the retained config when a schedule boundary is
ticker := time.NewTicker(republishInterval) // crossed and otherwise on a long interval. It re-evaluates often instead of
defer ticker.Stop() // 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 { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return return
case <-ticker.C: case <-time.After(republishPoll):
}
if !link.Connected() { if !link.Connected() {
lastEval = time.Now()
continue continue
} }
republish(ctx, log, link) 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
} }
} }

View File

@@ -12,8 +12,8 @@ services:
build: build:
context: . context: .
args: args:
VERSION: "0.3.0-phase3" VERSION: "0.4.0-phase6"
image: webrestrict/control:0.3.0-phase3 image: webrestrict/control:0.4.0-phase6
container_name: webrestrict-control container_name: webrestrict-control
restart: unless-stopped restart: unless-stopped
env_file: env_file:

View File

@@ -16,6 +16,10 @@ CONTROL_DB_PATH=/data/control.db
CONTROL_APP_ENV=production CONTROL_APP_ENV=production
CONTROL_LOG_LEVEL=info 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). # Signs the session cookie. Set to a long random string (>= 16 chars).
# Generate: openssl rand -hex 32 # Generate: openssl rand -hex 32
CONTROL_SESSION_SECRET=change-me-to-a-long-random-string CONTROL_SESSION_SECRET=change-me-to-a-long-random-string

View File

@@ -11,6 +11,9 @@ import (
"strconv" "strconv"
"strings" "strings"
"time" "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. // Config is the fully resolved runtime configuration.
@@ -37,6 +40,11 @@ type Config struct {
AppEnv string AppEnv string
// Version is injected at build time. // Version is injected at build time.
Version string 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 // MQTTEnabled turns the broker link on. When false the service runs
// HTTP-only and never touches the broker. // HTTP-only and never touches the broker.
@@ -102,6 +110,12 @@ func Load() (*Config, error) {
} }
cfg.LogLevel = lvl 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 { if err := cfg.validateMQTT(); err != nil {
return nil, err return nil, err
} }
@@ -114,6 +128,10 @@ func Load() (*Config, error) {
// to the host gateway. // to the host gateway.
const DefaultMQTTBroker = "tcp://host.docker.internal:1883" 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. // MQTTTLS reports whether the broker URL asks for TLS.
func (c *Config) MQTTTLS() bool { func (c *Config) MQTTTLS() bool {
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://") return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")

View File

@@ -37,6 +37,29 @@ func TestLoadDefaultsAndValidation(t *testing.T) {
if !cfg.CookieSecure { if !cfg.CookieSecure {
t.Error("CookieSecure = false, want true by default") 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) { func TestLoadRequiresSecretAndPassword(t *testing.T) {

View File

@@ -34,9 +34,11 @@ type ReportSink interface {
} }
// ConfigSource reads the desired state that is published to devices. // 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 { type ConfigSource interface {
ListDevices(ctx context.Context) ([]store.Device, error) 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. // 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 is the audit-log actor for reports that arrive over the broker.
Actor string 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 receives structured logs.
Log *slog.Logger Log *slog.Logger
@@ -325,27 +331,43 @@ func (l *Link) noteMessage() {
// topic stored with the device. It is the single writer path for config // topic stored with the device. It is the single writer path for config
// (AGENTS.md §10 rule 5): only the Control Service publishes. // (AGENTS.md §10 rule 5): only the Control Service publishes.
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error { func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID) payload, err := l.buildConfig(ctx, dev)
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()
if err != nil { if err != nil {
return err return err
} }
return l.Publish(dev.Topic, payload) 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. // Publish sends one retained message and waits for the broker to accept it.
func (l *Link) Publish(topic string, payload []byte) error { func (l *Link) Publish(topic string, payload []byte) error {
if !l.client.IsConnected() { if !l.client.IsConnected() {

View File

@@ -85,6 +85,10 @@ type Config struct {
Until *time.Time `json:"until"` Until *time.Time `json:"until"`
Allow []string `json:"allow"` Allow []string `json:"allow"`
Block []string `json:"block"` 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"` GeneratedAt time.Time `json:"generated_at"`
} }

View File

@@ -196,8 +196,8 @@ type stubSource struct{}
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil } func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) { func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
return nil, nil, nil return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil
} }
type stubSink struct{} 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) { func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
return 0, nil 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)
}
}
}

View File

@@ -1,9 +1,12 @@
// Per-device allow and block lists, and device lookup by MQTT topic // 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 // A list entry is one value tagged with a kind: "app" (an Android package name
// in the device config, so a device that is onboarded later gets the real lists // or a Windows process name) or "site" (a hostname). The editor on a device
// without another protocol change. Migration 001 created both tables. // 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 package store
import ( import (
@@ -12,27 +15,96 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
) )
// DeviceLists returns the site/app values that stay allowed and the values that // List table names. They are package constants, never caller input, so they are
// are blocked for one device. A row with a NULL device_id applies to every // safe to interpolate into a query.
// device (§7). Rows are returned in insertion order, de-duplicated. 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) { 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 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 nil, nil, err
} }
return allow, block, nil return allow, block, nil
} }
func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) { func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) {
// The table name is a package constant, never caller input. name, err := listTable(table)
rows, err := s.db.QueryContext(ctx,
`SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
if err != nil { 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() defer rows.Close()
@@ -41,24 +113,160 @@ func (s *Store) listValues(ctx context.Context, table string, id int64) ([]strin
for rows.Next() { for rows.Next() {
var v string var v string
if err := rows.Scan(&v); err != nil { 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) v = strings.TrimSpace(v)
if v == "" { if v == "" {
continue continue
} }
if _, dup := seen[v]; dup { key := v
if _, dup := seen[key]; dup {
continue continue
} }
seen[v] = struct{}{} seen[key] = struct{}{}
out = append(out, v) out = append(out, v)
} }
if err := rows.Err(); err != nil { 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 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 // DeviceByConfigTopic finds a device from a topic the device wrote to, for
// example "restrict/harry_phone_2/status". Only the Control Service reads the // example "restrict/harry_phone_2/status". Only the Control Service reads the
// topic table, so a topic the service does not know is ignored. // topic table, so a topic the service does not know is ignored.

428
internal/store/schedules.go Normal file
View File

@@ -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
}

View File

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

View File

@@ -16,7 +16,8 @@ import (
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store" "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 { type deviceView struct {
ID int64 ID int64
Name string Name string
@@ -26,10 +27,13 @@ type deviceView struct {
Topic string Topic string
Online bool Online bool
Locked bool Locked bool
ManualLocked bool
Scheduled bool
Until *time.Time Until *time.Time
UntilLocal string UntilLocal string
TimeLeft string TimeLeft string
StateLabel string StateLabel string
ReasonLabel string
LastSeen string LastSeen string
HasLastSeen bool HasLastSeen bool
} }
@@ -44,9 +48,21 @@ type auditView struct {
CreatedAtLocal string 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)) out := make([]deviceView, 0, len(devs))
for _, d := range 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{ v := deviceView{
ID: d.ID, ID: d.ID,
Name: d.Name, Name: d.Name,
@@ -55,21 +71,29 @@ func buildDeviceViews(devs []store.Device) []deviceView {
PlatformLabel: platformLabel(d.Platform), PlatformLabel: platformLabel(d.Platform),
Topic: d.Topic, Topic: d.Topic,
Online: d.Online, Online: d.Online,
Locked: d.Locked, Locked: eff.Locked,
Until: d.Until, 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" v.StateLabel = "Locked"
} else { } else {
v.StateLabel = "Unlocked" v.StateLabel = "Unlocked"
} }
if d.Until != nil { if eff.Until != nil {
local := d.Until.Local() local := eff.Until.In(loc)
v.UntilLocal = local.Format("Mon 15:04") v.UntilLocal = local.Format("Mon 15:04")
v.TimeLeft = timeLeft(*d.Until) v.TimeLeft = timeLeft(*eff.Until)
} }
if d.LastSeen != nil { 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 v.HasLastSeen = true
} }
out = append(out, v) out = append(out, v)
@@ -77,7 +101,10 @@ func buildDeviceViews(devs []store.Device) []deviceView {
return out 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)) out := make([]auditView, 0, len(entries))
for _, e := range entries { for _, e := range entries {
v := auditView{ v := auditView{
@@ -88,7 +115,7 @@ func buildAuditViews(entries []store.AuditEntry) []auditView {
Detail: e.Detail, Detail: e.Detail,
} }
if !e.CreatedAt.IsZero() { 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) out = append(out, v)
} }
@@ -140,6 +167,12 @@ func auditLabel(action string) string {
return "Unlocked" return "Unlocked"
case "state.expired": case "state.expired":
return "Lock 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": case "device.online":
return "Device online" return "Device online"
case "device.offline": case "device.offline":

387
internal/web/schedules.go Normal file
View File

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

View File

@@ -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]
}

View File

@@ -77,7 +77,7 @@ func (s *Server) publishConfig(ctx context.Context, dev store.Device) {
return return
} }
s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic, 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. // 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}", s.requireAuth(s.handleDeviceUpdate))
mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock)) mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock))
mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock)) 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") staticSub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
@@ -320,8 +325,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
CSRF: s.auth.EnsureCSRF(w, r), CSRF: s.auth.EnsureCSRF(w, r),
Version: s.cfg.Version, Version: s.cfg.Version,
AppEnv: s.cfg.AppEnv, AppEnv: s.cfg.AppEnv,
Devices: buildDeviceViews(devs), Devices: buildDeviceViews(devs, s.effectiveStates(ctx, devs), s.loc()),
Audit: buildAuditViews(audit), Audit: buildAuditViews(audit, s.loc()),
Flash: strings.TrimSpace(r.URL.Query().Get("ok")), Flash: strings.TrimSpace(r.URL.Query().Get("ok")),
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")), 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)) 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 { func (s *Server) buildStatusData(ctx context.Context) statusData {
data := statusData{ data := statusData{
DBHealthy: s.store.Ping(ctx) == nil, DBHealthy: s.store.Ping(ctx) == nil,
DBPath: s.cfg.DBPath, 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(), ServerUp: time.Since(serverStarted).Round(time.Second).String(),
} }
if s.pub != nil { if s.pub != nil {

View File

@@ -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; } .form-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
a.button-utility { text-decoration: none; } 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 ---------- */ /* ---------- Responsive ---------- */
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -474,4 +556,5 @@ a.button-utility { text-decoration: none; }
.nav-bar__user { display: none; } .nav-bar__user { display: none; }
.section__head { flex-wrap: wrap; } .section__head { flex-wrap: wrap; }
.audit-item__meta { margin-left: 0; width: 100%; } .audit-item__meta { margin-left: 0; width: 100%; }
.list-add .button-primary { flex: 1 1 auto; }
} }

View File

@@ -0,0 +1,114 @@
{{define "lists-editor.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Internet Use Control
</a>
<div class="nav-bar__right">
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>
<main class="container">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">{{.Heading}}</h1>
<p class="page-head__sub">{{.Sub}}</p>
</header>
{{if .Flash}}<p class="flash flash--ok" role="status">{{.Flash}}</p>{{end}}
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<section class="schedule-note">
<p class="schedule-note__hint">{{.PlatformHint}}</p>
<p class="schedule-note__hint">While a window is active or the device is locked, everything on the
blocklist is blocked <strong>except</strong> the allowlist. The allowlist is the exception list.</p>
</section>
<div class="list-grid">
<section class="card-panel list-panel" aria-labelledby="blocked-title">
<h2 id="blocked-title" class="heading-2">Blocked</h2>
<p class="list-panel__sub">Blocked during a window or lock.</p>
<form method="post" action="/devices/{{.DeviceID}}/lists" class="list-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="table" value="block">
<select class="select-input" name="kind" aria-label="Blocked entry kind">
<option value="app">app</option>
<option value="site">site</option>
</select>
<input class="text-input" type="text" name="value" placeholder="com.youtube.android or youtube.com"
maxlength="200" required spellcheck="false" autocapitalize="none">
<button class="button-primary" type="submit">Add</button>
</form>
<ul class="list-entries">
{{range .Block}}
<li class="list-entry">
<span class="list-entry__kind">{{.KindLabel}}</span>
<code class="list-entry__value">{{.Value}}</code>
<form method="post" action="/devices/{{$.DeviceID}}/lists/{{.ID}}/delete" class="list-entry__remove">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="table" value="block">
<button class="button-utility" type="submit" aria-label="Remove {{.Value}} from the blocklist">Remove</button>
</form>
</li>
{{else}}
<li class="list-entry list-entry--empty">Nothing blocked yet.</li>
{{end}}
</ul>
</section>
<section class="card-panel list-panel" aria-labelledby="allowed-title">
<h2 id="allowed-title" class="heading-2">Allowed</h2>
<p class="list-panel__sub">Stays available while a block is active.</p>
<form method="post" action="/devices/{{.DeviceID}}/lists" class="list-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="table" value="allow">
<select class="select-input" name="kind" aria-label="Allowed entry kind">
<option value="app">app</option>
<option value="site">site</option>
</select>
<input class="text-input" type="text" name="value" placeholder="com.spotify.music"
maxlength="200" required spellcheck="false" autocapitalize="none">
<button class="button-primary" type="submit">Add</button>
</form>
<ul class="list-entries">
{{range .Allow}}
<li class="list-entry">
<span class="list-entry__kind">{{.KindLabel}}</span>
<code class="list-entry__value">{{.Value}}</code>
<form method="post" action="/devices/{{$.DeviceID}}/lists/{{.ID}}/delete" class="list-entry__remove">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="table" value="allow">
<button class="button-utility" type="submit" aria-label="Remove {{.Value}} from the allowlist">Remove</button>
</form>
</li>
{{else}}
<li class="list-entry list-entry--empty">Nothing allowed yet.</li>
{{end}}
</ul>
</section>
</div>
<div class="form-actions">
<a class="button-utility" href="/devices/{{.DeviceID}}/schedule">Weekly schedule</a>
<a class="button-utility" href="/">Done</a>
</div>
</main>
<footer class="app-foot">
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}

View File

@@ -12,7 +12,7 @@
</header> </header>
<p class="device-card__state"> <p class="device-card__state">
{{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}}
</p> </p>
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}} {{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}}
@@ -34,6 +34,8 @@
</form> </form>
{{end}} {{end}}
<a class="button-utility" href="/devices/{{.ID}}/edit">Edit</a> <a class="button-utility" href="/devices/{{.ID}}/edit">Edit</a>
<a class="button-utility" href="/devices/{{.ID}}/schedule">Schedule</a>
<a class="button-utility" href="/devices/{{.ID}}/lists">Lists</a>
</div> </div>
<p class="device-card__foot"> <p class="device-card__foot">

View File

@@ -0,0 +1,91 @@
{{define "schedule-editor.html"}}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>{{.Title}} — Internet Use Control</title>
<link rel="stylesheet" href="/static/tokens.css">
<link rel="stylesheet" href="/static/app.css">
</head>
<body class="app">
<nav class="nav-bar">
<a class="nav-bar__brand" href="/">
<span class="nav-bar__mark" aria-hidden="true"></span>
Internet Use Control
</a>
<div class="nav-bar__right">
<span class="nav-bar__user">{{.Username}}</span>
</div>
</nav>
<main class="container">
<header class="page-head">
<p class="page-head__eyebrow"><a href="/">← Dashboard</a></p>
<h1 class="heading-1">{{.Heading}}</h1>
<p class="page-head__sub">{{.Sub}}</p>
</header>
{{if .Flash}}<p class="flash flash--ok" role="status">{{.Flash}}</p>{{end}}
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
<section class="schedule-note">
{{if .ActiveNow}}
<p class="schedule-note__now"><span class="dot dot--off"></span> A window is active now{{if .ActiveUntil}} until {{.ActiveUntil}}{{end}}.</p>
{{else}}
<p class="schedule-note__now"><span class="dot dot--faint"></span> No window is active now.</p>
{{end}}
{{if .NextBoundary}}<p class="schedule-note__next">Next change: {{.NextBoundary}}.</p>{{end}}
<p class="schedule-note__hint">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.</p>
</section>
<section class="card-panel">
<form method="post" action="/devices/{{.DeviceID}}/schedule" class="stack-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="schedule-scroll">
<table class="schedule-grid">
<caption class="visually-hidden">Weekly blocking windows for {{.DeviceName}}</caption>
<thead>
<tr>
<th scope="col">Day</th>
{{range index .Rows 0}}{{range .Slots}}<th scope="col">Window</th>{{end}}{{end}}
</tr>
</thead>
<tbody>
{{range $row := .Rows}}
<tr>
<th scope="row">{{$row.Name}}</th>
{{range $i, $slot := $row.Slots}}
<td class="schedule-grid__slot">
<input class="text-input time-input" type="time" name="start_{{$row.Weekday}}_{{$i}}"
value="{{$slot.Start}}" aria-label="{{$row.Name}} window {{$i}} start">
<span class="schedule-grid__dash" aria-hidden="true">–</span>
<input class="text-input time-input" type="time" name="end_{{$row.Weekday}}_{{$i}}"
value="{{$slot.End}}" aria-label="{{$row.Name}} window {{$i}} end">
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="form-actions">
<button class="button-primary" type="submit">Save schedule</button>
<button class="button-utility" type="submit" name="clear" value="1" formnovalidate>Clear all</button>
<a class="button-utility" href="/devices/{{.DeviceID}}/lists">Blocklist / allowlist</a>
<a class="button-utility" href="/">Done</a>
</div>
</form>
</section>
</main>
<footer class="app-foot">
<span>Control Service · {{.Version}} · {{.AppEnv}}</span>
<span class="app-foot__sep">·</span>
<span>{{year}} Family Home Lab</span>
</footer>
</body>
</html>{{end}}