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:
@@ -11,6 +11,9 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
// Embed the IANA time zone database so CONTROL_TIMEZONE resolves even in a
|
||||
// container without tzdata, such as the test image.
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
// Config is the fully resolved runtime configuration.
|
||||
@@ -37,6 +40,11 @@ type Config struct {
|
||||
AppEnv string
|
||||
// Version is injected at build time.
|
||||
Version string
|
||||
// ScheduleLocation is the timezone weekly schedule windows are evaluated in
|
||||
// (CONTROL_TIMEZONE). Windows are wall-clock, so this must be the family's
|
||||
// zone, not the container's default of UTC. It defaults to
|
||||
// DefaultTimezone.
|
||||
ScheduleLocation *time.Location
|
||||
|
||||
// MQTTEnabled turns the broker link on. When false the service runs
|
||||
// HTTP-only and never touches the broker.
|
||||
@@ -102,6 +110,12 @@ func Load() (*Config, error) {
|
||||
}
|
||||
cfg.LogLevel = lvl
|
||||
|
||||
loc, err := time.LoadLocation(env("CONTROL_TIMEZONE", DefaultTimezone))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CONTROL_TIMEZONE: %w", err)
|
||||
}
|
||||
cfg.ScheduleLocation = loc
|
||||
|
||||
if err := cfg.validateMQTT(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,6 +128,10 @@ func Load() (*Config, error) {
|
||||
// to the host gateway.
|
||||
const DefaultMQTTBroker = "tcp://host.docker.internal:1883"
|
||||
|
||||
// DefaultTimezone is the schedule timezone used when CONTROL_TIMEZONE is unset.
|
||||
// It matches the lab host's configured zone; set CONTROL_TIMEZONE to override.
|
||||
const DefaultTimezone = "Australia/Sydney"
|
||||
|
||||
// MQTTTLS reports whether the broker URL asks for TLS.
|
||||
func (c *Config) MQTTTLS() bool {
|
||||
return strings.HasPrefix(c.MQTTBroker, "tls://") || strings.HasPrefix(c.MQTTBroker, "ssl://")
|
||||
|
||||
@@ -37,6 +37,29 @@ func TestLoadDefaultsAndValidation(t *testing.T) {
|
||||
if !cfg.CookieSecure {
|
||||
t.Error("CookieSecure = false, want true by default")
|
||||
}
|
||||
if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != DefaultTimezone {
|
||||
t.Errorf("ScheduleLocation = %v, want %s", cfg.ScheduleLocation, DefaultTimezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleTimezone(t *testing.T) {
|
||||
t.Setenv("CONTROL_SESSION_SECRET", "0123456789abcdef")
|
||||
t.Setenv("CONTROL_ADMIN_PASSWORD", "hunter2")
|
||||
t.Setenv("CONTROL_MQTT_INSECURE", "true")
|
||||
|
||||
t.Setenv("CONTROL_TIMEZONE", "UTC")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if cfg.ScheduleLocation == nil || cfg.ScheduleLocation.String() != "UTC" {
|
||||
t.Errorf("ScheduleLocation = %v, want UTC", cfg.ScheduleLocation)
|
||||
}
|
||||
|
||||
t.Setenv("CONTROL_TIMEZONE", "Not/AZone")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Error("expected an error for an unknown CONTROL_TIMEZONE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRequiresSecretAndPassword(t *testing.T) {
|
||||
|
||||
@@ -34,9 +34,11 @@ type ReportSink interface {
|
||||
}
|
||||
|
||||
// ConfigSource reads the desired state that is published to devices.
|
||||
// EffectiveState combines the manual lock, the weekly schedule and the
|
||||
// allow/block lists (Phase 6).
|
||||
type ConfigSource interface {
|
||||
ListDevices(ctx context.Context) ([]store.Device, error)
|
||||
DeviceLists(ctx context.Context, id int64) (allow, block []string, err error)
|
||||
EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error)
|
||||
}
|
||||
|
||||
// Options configures the link.
|
||||
@@ -60,6 +62,10 @@ type Options struct {
|
||||
|
||||
// Actor is the audit-log actor for reports that arrive over the broker.
|
||||
Actor string
|
||||
// Location is the schedule timezone. Weekly windows are wall-clock, so the
|
||||
// service must evaluate them in the family's zone, not the container's
|
||||
// (usually UTC). Nil means time.Local.
|
||||
Location *time.Location
|
||||
// Log receives structured logs.
|
||||
Log *slog.Logger
|
||||
|
||||
@@ -325,27 +331,43 @@ func (l *Link) noteMessage() {
|
||||
// topic stored with the device. It is the single writer path for config
|
||||
// (AGENTS.md §10 rule 5): only the Control Service publishes.
|
||||
func (l *Link) PublishDeviceConfig(ctx context.Context, dev store.Device) error {
|
||||
allow, block, err := l.opts.Source.DeviceLists(ctx, dev.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: dev.ID,
|
||||
Name: dev.Name,
|
||||
Platform: dev.Platform,
|
||||
Locked: dev.Locked,
|
||||
Until: dev.Until,
|
||||
Allow: allow,
|
||||
Block: block,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}.Marshal()
|
||||
payload, err := l.buildConfig(ctx, dev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return l.Publish(dev.Topic, payload)
|
||||
}
|
||||
|
||||
// buildConfig computes the effective desired state for one device at the current
|
||||
// time and renders the retained payload. It is separated from the publish so it
|
||||
// can be tested without a broker.
|
||||
func (l *Link) buildConfig(ctx context.Context, dev store.Device) ([]byte, error) {
|
||||
eff, err := l.opts.Source.EffectiveState(ctx, dev, time.Now().In(l.location()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Config{
|
||||
V: ConfigVersion,
|
||||
DeviceID: dev.ID,
|
||||
Name: dev.Name,
|
||||
Platform: dev.Platform,
|
||||
Locked: eff.Locked,
|
||||
Until: eff.Until,
|
||||
Allow: eff.Allow,
|
||||
Block: eff.Block,
|
||||
Reason: eff.Reason,
|
||||
GeneratedAt: time.Now().UTC(),
|
||||
}.Marshal()
|
||||
}
|
||||
|
||||
// location returns the schedule timezone, defaulting to the process local zone.
|
||||
func (l *Link) location() *time.Location {
|
||||
if l.opts.Location != nil {
|
||||
return l.opts.Location
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// Publish sends one retained message and waits for the broker to accept it.
|
||||
func (l *Link) Publish(topic string, payload []byte) error {
|
||||
if !l.client.IsConnected() {
|
||||
|
||||
@@ -77,15 +77,19 @@ func deviceFromTopic(topic, suffix string) (string, bool) {
|
||||
// The shape is versioned with "v" and is consumed by the kids' clients in
|
||||
// Phases 4 and 5. Add fields; do not repurpose them.
|
||||
type Config struct {
|
||||
V int `json:"v"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Locked bool `json:"locked"`
|
||||
Until *time.Time `json:"until"`
|
||||
Allow []string `json:"allow"`
|
||||
Block []string `json:"block"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
V int `json:"v"`
|
||||
DeviceID int64 `json:"device_id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Locked bool `json:"locked"`
|
||||
Until *time.Time `json:"until"`
|
||||
Allow []string `json:"allow"`
|
||||
Block []string `json:"block"`
|
||||
// Reason is why the device is locked: "schedule" for an active weekly
|
||||
// window, "manual" for a lock from the dashboard. It is omitted when the
|
||||
// device is not locked. It is additive (Phase 6), so v stays 1.
|
||||
Reason string `json:"reason,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// ConfigVersion is the current schema version of Config.
|
||||
|
||||
@@ -196,8 +196,8 @@ type stubSource struct{}
|
||||
|
||||
func (stubSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
||||
|
||||
func (stubSource) DeviceLists(ctx context.Context, id int64) ([]string, []string, error) {
|
||||
return nil, nil, nil
|
||||
func (stubSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
||||
return store.EffectiveState{DeviceID: dev.ID, Allow: []string{}, Block: []string{}}, nil
|
||||
}
|
||||
|
||||
type stubSink struct{}
|
||||
@@ -209,3 +209,73 @@ func (stubSink) SetPresence(ctx context.Context, id int64, online bool, at time.
|
||||
func (stubSink) InsertTelemetry(ctx context.Context, id int64, day string, e []store.TelemetryEntry) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// stateSource returns a fixed effective state, to prove the published payload
|
||||
// without a broker.
|
||||
type stateSource struct {
|
||||
eff store.EffectiveState
|
||||
}
|
||||
|
||||
func (s stateSource) ListDevices(ctx context.Context) ([]store.Device, error) { return nil, nil }
|
||||
|
||||
func (s stateSource) EffectiveState(ctx context.Context, dev store.Device, now time.Time) (store.EffectiveState, error) {
|
||||
eff := s.eff
|
||||
eff.DeviceID = dev.ID
|
||||
return eff, nil
|
||||
}
|
||||
|
||||
// TestBuildConfigAllowlistIsolation is the Phase 6 definition of done: with
|
||||
// Spotify allowlisted while other apps are blocked during an active schedule
|
||||
// window, the retained payload says Spotify is allowed and the others are
|
||||
// blocked.
|
||||
func TestBuildConfigAllowlistIsolation(t *testing.T) {
|
||||
until := time.Date(2026, 10, 13, 21, 0, 0, 0, time.UTC)
|
||||
link, err := New(Options{
|
||||
Broker: "tcp://127.0.0.1:1883",
|
||||
Source: stateSource{eff: store.EffectiveState{
|
||||
Locked: true,
|
||||
Until: &until,
|
||||
Reason: "schedule",
|
||||
Allow: []string{"com.spotify.music"},
|
||||
Block: []string{"com.youtube.android", "roblox.exe"},
|
||||
}},
|
||||
Devices: stubLookup{},
|
||||
Reports: stubSink{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
payload, err := link.buildConfig(context.Background(), store.Device{
|
||||
ID: 7, Name: "Harry's phone", Platform: store.PlatformAndroid,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("buildConfig: %v", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := json.Unmarshal(payload, &cfg); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
if !cfg.Locked || cfg.Reason != "schedule" {
|
||||
t.Errorf("payload = %s, want locked by schedule", payload)
|
||||
}
|
||||
if cfg.Until == nil || !cfg.Until.Equal(until) {
|
||||
t.Errorf("until = %v, want %v", cfg.Until, until)
|
||||
}
|
||||
if len(cfg.Allow) != 1 || cfg.Allow[0] != "com.spotify.music" {
|
||||
t.Errorf("allow = %v, want Spotify allowed", cfg.Allow)
|
||||
}
|
||||
if len(cfg.Block) != 2 {
|
||||
t.Errorf("block = %v, want the other apps blocked", cfg.Block)
|
||||
}
|
||||
// The JSON is the contract the kids' clients read.
|
||||
for _, want := range []string{
|
||||
`"locked":true`, `"reason":"schedule"`,
|
||||
`"allow":["com.spotify.music"]`,
|
||||
} {
|
||||
if !strings.Contains(string(payload), want) {
|
||||
t.Errorf("payload %s missing %s", payload, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// Per-device allow and block lists, and device lookup by MQTT topic
|
||||
// (AGENTS.md §7 blocklist/allowlist, §11 Phase 3).
|
||||
// (AGENTS.md §4 FR-3/FR-4, §7 blocklist/allowlist, §11 Phase 6).
|
||||
//
|
||||
// The editors for these lists arrive in Phase 6. Phase 3 already publishes them
|
||||
// in the device config, so a device that is onboarded later gets the real lists
|
||||
// without another protocol change. Migration 001 created both tables.
|
||||
// A list entry is one value tagged with a kind: "app" (an Android package name
|
||||
// or a Windows process name) or "site" (a hostname). The editor on a device
|
||||
// page edits the entries scoped to that device. A row with a NULL device_id
|
||||
// applies to every device (a group entry, §7); the data model supports it and
|
||||
// the effective state merges it, but the Phase 6 editor only manages
|
||||
// device-scoped rows.
|
||||
package store
|
||||
|
||||
import (
|
||||
@@ -12,27 +15,96 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeviceLists returns the site/app values that stay allowed and the values that
|
||||
// are blocked for one device. A row with a NULL device_id applies to every
|
||||
// device (§7). Rows are returned in insertion order, de-duplicated.
|
||||
// List table names. They are package constants, never caller input, so they are
|
||||
// safe to interpolate into a query.
|
||||
const (
|
||||
TableBlocklist = "blocklist"
|
||||
TableAllowlist = "allowlist"
|
||||
)
|
||||
|
||||
// List kind values allowed by the blocklist/allowlist CHECK constraint.
|
||||
const (
|
||||
ListKindApp = "app"
|
||||
ListKindSite = "site"
|
||||
)
|
||||
|
||||
// ListEntry is one blocklist or allowlist row.
|
||||
type ListEntry struct {
|
||||
ID int64
|
||||
DeviceID *int64 // nil = applies to every device
|
||||
Kind string
|
||||
Value string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ValidListKind reports whether kind is "app" or "site".
|
||||
func ValidListKind(kind string) bool {
|
||||
return kind == ListKindApp || kind == ListKindSite
|
||||
}
|
||||
|
||||
// listTable maps a public constant to the real table name, rejecting anything
|
||||
// else so a caller can never select an arbitrary table.
|
||||
func listTable(table string) (string, error) {
|
||||
switch table {
|
||||
case TableBlocklist:
|
||||
return "blocklist", nil
|
||||
case TableAllowlist:
|
||||
return "allowlist", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown list %q", table)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeListValue trims a value and rejects blanks, over-long values and
|
||||
// control characters. Hostnames are lower-cased because DNS is
|
||||
// case-insensitive; app identifiers keep their case.
|
||||
func normalizeListValue(kind, value string) (string, error) {
|
||||
if !ValidListKind(kind) {
|
||||
return "", errors.New(`kind must be "app" or "site"`)
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("value is required")
|
||||
}
|
||||
if len(value) > 200 {
|
||||
return "", errors.New("value must be 200 characters or fewer")
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return "", errors.New("value must not contain control characters")
|
||||
}
|
||||
}
|
||||
if kind == ListKindSite {
|
||||
value = strings.ToLower(value)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// DeviceLists returns the values that stay allowed and the values that are
|
||||
// blocked for one device. A row with a NULL device_id applies to every device
|
||||
// (§7). Rows are returned in insertion order, de-duplicated.
|
||||
func (s *Store) DeviceLists(ctx context.Context, id int64) (allow, block []string, err error) {
|
||||
if allow, err = s.listValues(ctx, "allowlist", id); err != nil {
|
||||
if allow, err = s.listValues(ctx, TableAllowlist, id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if block, err = s.listValues(ctx, "blocklist", id); err != nil {
|
||||
if block, err = s.listValues(ctx, TableBlocklist, id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return allow, block, nil
|
||||
}
|
||||
|
||||
func (s *Store) listValues(ctx context.Context, table string, id int64) ([]string, error) {
|
||||
// The table name is a package constant, never caller input.
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT value FROM `+table+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
|
||||
name, err := listTable(table)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", table, err)
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT value FROM `+name+` WHERE device_id = ? OR device_id IS NULL ORDER BY id`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", name, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
@@ -41,24 +113,160 @@ func (s *Store) listValues(ctx context.Context, table string, id int64) ([]strin
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("scan %s: %w", table, err)
|
||||
return nil, fmt.Errorf("scan %s: %w", name, err)
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[v]; dup {
|
||||
key := v
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list %s: %w", table, err)
|
||||
return nil, fmt.Errorf("list %s: %w", name, err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListEntries returns one device's blocklist or allowlist rows, oldest first.
|
||||
func (s *Store) ListEntries(ctx context.Context, deviceID int64, table string) ([]ListEntry, error) {
|
||||
name, err := listTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, device_id, kind, value, created_at
|
||||
FROM `+name+` WHERE device_id = ?
|
||||
ORDER BY id`, deviceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]ListEntry, 0, 8)
|
||||
for rows.Next() {
|
||||
var (
|
||||
e ListEntry
|
||||
devID sql.NullInt64
|
||||
created sql.NullString
|
||||
)
|
||||
if err := rows.Scan(&e.ID, &devID, &e.Kind, &e.Value, &created); err != nil {
|
||||
return nil, fmt.Errorf("scan entry: %w", err)
|
||||
}
|
||||
if devID.Valid {
|
||||
id := devID.Int64
|
||||
e.DeviceID = &id
|
||||
}
|
||||
e.CreatedAt = parseTime(created.String)
|
||||
out = append(out, e)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list entries: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AddListEntry appends one blocklist or allowlist value for a device and
|
||||
// appends one audit entry in the same transaction. A value that is already
|
||||
// present for the device (same kind and value, case-insensitively) is refused,
|
||||
// so repeated submits do not pile up.
|
||||
func (s *Store) AddListEntry(ctx context.Context, deviceID int64, table, kind, value, actor string) (*ListEntry, error) {
|
||||
name, err := listTable(table)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value, err = normalizeListValue(kind, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add entry: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var exists int
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT 1 FROM `+name+` WHERE device_id = ? AND kind = ? AND lower(value) = lower(?) LIMIT 1`,
|
||||
deviceID, kind, value).Scan(&exists)
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("%s %q is already on the list", kind, value)
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("check entry: %w", err)
|
||||
}
|
||||
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO `+name+` (device_id, kind, value) VALUES (?, ?, ?)`, deviceID, kind, value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert entry: %w", err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("entry id: %w", err)
|
||||
}
|
||||
listName := "allowlist"
|
||||
if table == TableBlocklist {
|
||||
listName = "blocklist"
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor, "list.added", &deviceID,
|
||||
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit entry: %w", err)
|
||||
}
|
||||
return &ListEntry{ID: id, DeviceID: &deviceID, Kind: kind, Value: value}, nil
|
||||
}
|
||||
|
||||
// DeleteListEntry removes one device-scoped row and appends one audit entry.
|
||||
// A row that belongs to another device is not touched: the id and device_id
|
||||
// must both match, so a guessed id cannot edit someone else's list.
|
||||
func (s *Store) DeleteListEntry(ctx context.Context, deviceID, entryID int64, table, actor string) error {
|
||||
name, err := listTable(table)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete entry: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var kind, value string
|
||||
err = tx.QueryRowContext(ctx,
|
||||
`SELECT kind, value FROM `+name+` WHERE id = ? AND device_id = ?`,
|
||||
entryID, deviceID).Scan(&kind, &value)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read entry: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `DELETE FROM `+name+` WHERE id = ?`, entryID); err != nil {
|
||||
return fmt.Errorf("delete entry: %w", err)
|
||||
}
|
||||
listName := "allowlist"
|
||||
if table == TableBlocklist {
|
||||
listName = "blocklist"
|
||||
}
|
||||
if err := insertAudit(ctx, tx, actor, "list.removed", &deviceID,
|
||||
fmt.Sprintf("%s %s %s", listName, kind, value)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeviceByConfigTopic finds a device from a topic the device wrote to, for
|
||||
// example "restrict/harry_phone_2/status". Only the Control Service reads the
|
||||
// topic table, so a topic the service does not know is ignored.
|
||||
|
||||
428
internal/store/schedules.go
Normal file
428
internal/store/schedules.go
Normal 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
|
||||
}
|
||||
429
internal/store/schedules_test.go
Normal file
429
internal/store/schedules_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import (
|
||||
"gitea.lab.audasmedia.com.au/sam/internet_use_control/internal/store"
|
||||
)
|
||||
|
||||
// deviceView is the display model for one dashboard card.
|
||||
// deviceView is the display model for one dashboard card. Locked and Until are
|
||||
// the effective state: a manual lock or an active weekly window (Phase 6).
|
||||
type deviceView struct {
|
||||
ID int64
|
||||
Name string
|
||||
@@ -26,10 +27,13 @@ type deviceView struct {
|
||||
Topic string
|
||||
Online bool
|
||||
Locked bool
|
||||
ManualLocked bool
|
||||
Scheduled bool
|
||||
Until *time.Time
|
||||
UntilLocal string
|
||||
TimeLeft string
|
||||
StateLabel string
|
||||
ReasonLabel string
|
||||
LastSeen string
|
||||
HasLastSeen bool
|
||||
}
|
||||
@@ -44,9 +48,21 @@ type auditView struct {
|
||||
CreatedAtLocal string
|
||||
}
|
||||
|
||||
func buildDeviceViews(devs []store.Device) []deviceView {
|
||||
func buildDeviceViews(devs []store.Device, effs map[int64]store.EffectiveState, loc *time.Location) []deviceView {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
out := make([]deviceView, 0, len(devs))
|
||||
for _, d := range devs {
|
||||
eff, ok := effs[d.ID]
|
||||
if !ok {
|
||||
// No computed state (for example a store error): fall back to the
|
||||
// manual lock so the card never lies about it.
|
||||
eff = store.EffectiveState{DeviceID: d.ID, Locked: d.Locked, Until: d.Until}
|
||||
if d.Locked {
|
||||
eff.Reason = "manual"
|
||||
}
|
||||
}
|
||||
v := deviceView{
|
||||
ID: d.ID,
|
||||
Name: d.Name,
|
||||
@@ -55,21 +71,29 @@ func buildDeviceViews(devs []store.Device) []deviceView {
|
||||
PlatformLabel: platformLabel(d.Platform),
|
||||
Topic: d.Topic,
|
||||
Online: d.Online,
|
||||
Locked: d.Locked,
|
||||
Until: d.Until,
|
||||
Locked: eff.Locked,
|
||||
ManualLocked: d.Locked,
|
||||
Until: eff.Until,
|
||||
}
|
||||
if d.Locked {
|
||||
switch eff.Reason {
|
||||
case "schedule":
|
||||
v.Scheduled = true
|
||||
v.ReasonLabel = "by schedule"
|
||||
case "manual":
|
||||
v.ReasonLabel = "manually"
|
||||
}
|
||||
if eff.Locked {
|
||||
v.StateLabel = "Locked"
|
||||
} else {
|
||||
v.StateLabel = "Unlocked"
|
||||
}
|
||||
if d.Until != nil {
|
||||
local := d.Until.Local()
|
||||
if eff.Until != nil {
|
||||
local := eff.Until.In(loc)
|
||||
v.UntilLocal = local.Format("Mon 15:04")
|
||||
v.TimeLeft = timeLeft(*d.Until)
|
||||
v.TimeLeft = timeLeft(*eff.Until)
|
||||
}
|
||||
if d.LastSeen != nil {
|
||||
v.LastSeen = d.LastSeen.Local().Format("Mon 02 Jan 15:04")
|
||||
v.LastSeen = d.LastSeen.In(loc).Format("Mon 02 Jan 15:04")
|
||||
v.HasLastSeen = true
|
||||
}
|
||||
out = append(out, v)
|
||||
@@ -77,7 +101,10 @@ func buildDeviceViews(devs []store.Device) []deviceView {
|
||||
return out
|
||||
}
|
||||
|
||||
func buildAuditViews(entries []store.AuditEntry) []auditView {
|
||||
func buildAuditViews(entries []store.AuditEntry, loc *time.Location) []auditView {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
out := make([]auditView, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
v := auditView{
|
||||
@@ -88,7 +115,7 @@ func buildAuditViews(entries []store.AuditEntry) []auditView {
|
||||
Detail: e.Detail,
|
||||
}
|
||||
if !e.CreatedAt.IsZero() {
|
||||
v.CreatedAtLocal = e.CreatedAt.Local().Format("02 Jan 15:04:05")
|
||||
v.CreatedAtLocal = e.CreatedAt.In(loc).Format("02 Jan 15:04:05")
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
@@ -140,6 +167,12 @@ func auditLabel(action string) string {
|
||||
return "Unlocked"
|
||||
case "state.expired":
|
||||
return "Lock expired"
|
||||
case "schedule.updated":
|
||||
return "Schedule saved"
|
||||
case "list.added":
|
||||
return "List entry added"
|
||||
case "list.removed":
|
||||
return "List entry removed"
|
||||
case "device.online":
|
||||
return "Device online"
|
||||
case "device.offline":
|
||||
|
||||
387
internal/web/schedules.go
Normal file
387
internal/web/schedules.go
Normal 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)
|
||||
}
|
||||
222
internal/web/schedules_test.go
Normal file
222
internal/web/schedules_test.go
Normal 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]
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func (s *Server) publishConfig(ctx context.Context, dev store.Device) {
|
||||
return
|
||||
}
|
||||
s.log.Info("mqtt config published", "device", dev.Name, "topic", dev.Topic,
|
||||
"locked", dev.Locked)
|
||||
"manual_locked", dev.Locked)
|
||||
}
|
||||
|
||||
// Handler returns the fully wrapped HTTP handler.
|
||||
@@ -96,6 +96,11 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /devices/{id}", s.requireAuth(s.handleDeviceUpdate))
|
||||
mux.HandleFunc("POST /devices/{id}/lock", s.requireAuth(s.handleDeviceLock))
|
||||
mux.HandleFunc("POST /devices/{id}/unlock", s.requireAuth(s.handleDeviceUnlock))
|
||||
mux.HandleFunc("GET /devices/{id}/schedule", s.requireAuth(s.handleScheduleEditor))
|
||||
mux.HandleFunc("POST /devices/{id}/schedule", s.requireAuth(s.handleScheduleSave))
|
||||
mux.HandleFunc("GET /devices/{id}/lists", s.requireAuth(s.handleListsEditor))
|
||||
mux.HandleFunc("POST /devices/{id}/lists", s.requireAuth(s.handleListAdd))
|
||||
mux.HandleFunc("POST /devices/{id}/lists/{entryID}/delete", s.requireAuth(s.handleListDelete))
|
||||
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
@@ -320,8 +325,8 @@ func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
CSRF: s.auth.EnsureCSRF(w, r),
|
||||
Version: s.cfg.Version,
|
||||
AppEnv: s.cfg.AppEnv,
|
||||
Devices: buildDeviceViews(devs),
|
||||
Audit: buildAuditViews(audit),
|
||||
Devices: buildDeviceViews(devs, s.effectiveStates(ctx, devs), s.loc()),
|
||||
Audit: buildAuditViews(audit, s.loc()),
|
||||
Flash: strings.TrimSpace(r.URL.Query().Get("ok")),
|
||||
ErrMsg: strings.TrimSpace(r.URL.Query().Get("err")),
|
||||
})
|
||||
@@ -333,11 +338,41 @@ func (s *Server) handleStatusPartial(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderFragment(w, r, "status.html", s.buildStatusData(ctx))
|
||||
}
|
||||
|
||||
// effectiveStates computes the desired state for every device at the same
|
||||
// instant, so one dashboard load is internally consistent.
|
||||
func (s *Server) effectiveStates(ctx context.Context, devs []store.Device) map[int64]store.EffectiveState {
|
||||
out := make(map[int64]store.EffectiveState, len(devs))
|
||||
now := s.now()
|
||||
for _, d := range devs {
|
||||
eff, err := s.store.EffectiveState(ctx, d, now)
|
||||
if err != nil {
|
||||
s.log.Error("effective state", "device", d.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
out[d.ID] = eff
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// now is the current time in the schedule timezone, so weekly windows are
|
||||
// evaluated wall-clock and not in the container's default UTC.
|
||||
func (s *Server) now() time.Time {
|
||||
return time.Now().In(s.loc())
|
||||
}
|
||||
|
||||
// loc is the schedule timezone, defaulting to the process local zone.
|
||||
func (s *Server) loc() *time.Location {
|
||||
if s.cfg.ScheduleLocation != nil {
|
||||
return s.cfg.ScheduleLocation
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
func (s *Server) buildStatusData(ctx context.Context) statusData {
|
||||
data := statusData{
|
||||
DBHealthy: s.store.Ping(ctx) == nil,
|
||||
DBPath: s.cfg.DBPath,
|
||||
ServerTime: time.Now().Format("Mon 02 Jan 2006 15:04:05"),
|
||||
ServerTime: time.Now().In(s.loc()).Format("Mon 02 Jan 2006 15:04:05"),
|
||||
ServerUp: time.Since(serverStarted).Round(time.Second).String(),
|
||||
}
|
||||
if s.pub != nil {
|
||||
|
||||
@@ -464,6 +464,88 @@ a.button-primary:hover { text-decoration: none; }
|
||||
.form-actions { display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center; }
|
||||
a.button-utility { text-decoration: none; }
|
||||
|
||||
/* ---------- Weekly schedule editor (Phase 6) ---------- */
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.schedule-note {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--rounded-lg);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
.schedule-note__now { margin: 0; display: flex; align-items: center; gap: 8px; color: var(--ink); font: var(--type-body-md); }
|
||||
.schedule-note__next { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
|
||||
.schedule-note__hint { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); max-width: 70ch; }
|
||||
|
||||
.schedule-scroll { overflow-x: auto; }
|
||||
.schedule-grid {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 520px;
|
||||
}
|
||||
.schedule-grid th,
|
||||
.schedule-grid td {
|
||||
text-align: left;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.schedule-grid thead th {
|
||||
font: var(--type-eyebrow);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.schedule-grid tbody th { font: var(--type-body-md); color: var(--ink); white-space: nowrap; }
|
||||
.schedule-grid__slot { white-space: nowrap; }
|
||||
.schedule-grid__dash { padding: 0 6px; color: var(--ink-faint); }
|
||||
.time-input { width: auto; min-width: 120px; display: inline-block; }
|
||||
|
||||
/* ---------- Blocklist / allowlist editor (Phase 6) ---------- */
|
||||
|
||||
.list-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: var(--space-md);
|
||||
align-items: start;
|
||||
}
|
||||
.list-panel { display: grid; gap: var(--space-sm); }
|
||||
.list-panel__sub { margin: 0; color: var(--ink-muted); font: var(--type-body-sm); }
|
||||
.list-add { display: flex; flex-wrap: wrap; gap: var(--space-xs); }
|
||||
.list-add .text-input { flex: 1 1 160px; min-width: 0; }
|
||||
.list-entries { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); }
|
||||
.list-entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs) var(--space-sm);
|
||||
padding: var(--space-xs) 0;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
.list-entry--empty { color: var(--ink-muted); font-style: italic; border-top: 0; }
|
||||
.list-entry__kind {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--rounded-full);
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-muted);
|
||||
font: var(--type-eyebrow);
|
||||
}
|
||||
.list-entry__value { flex: 1 1 auto; word-break: break-all; }
|
||||
.list-entry__remove { margin: 0; }
|
||||
|
||||
/* ---------- Responsive ---------- */
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -474,4 +556,5 @@ a.button-utility { text-decoration: none; }
|
||||
.nav-bar__user { display: none; }
|
||||
.section__head { flex-wrap: wrap; }
|
||||
.audit-item__meta { margin-left: 0; width: 100%; }
|
||||
.list-add .button-primary { flex: 1 1 auto; }
|
||||
}
|
||||
|
||||
114
internal/web/templates/lists-editor.html
Normal file
114
internal/web/templates/lists-editor.html
Normal 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}}
|
||||
@@ -12,7 +12,7 @@
|
||||
</header>
|
||||
|
||||
<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>
|
||||
{{if .Until}}<p class="device-card__until">Lifts {{.UntilLocal}}</p>{{end}}
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
</form>
|
||||
{{end}}
|
||||
<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>
|
||||
|
||||
<p class="device-card__foot">
|
||||
|
||||
91
internal/web/templates/schedule-editor.html
Normal file
91
internal/web/templates/schedule-editor.html
Normal 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}}
|
||||
Reference in New Issue
Block a user