From 121fdbacdc98d6e881e7565179d06d1d46926e92 Mon Sep 17 00:00:00 2001 From: zwitschi Date: Thu, 13 Aug 2026 13:12:35 +0200 Subject: [PATCH] feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention --- .env.example | 5 + .github/instructions/BACKLOG.md | 2 +- .github/instructions/DONE.md | 63 +++- .github/instructions/TODO.md | 31 +- backend/chat/go.mod | 2 +- backend/chat/src/auth/auth.go | 48 +++ backend/chat/src/auth/auth_test.go | 29 ++ backend/chat/src/config/config.go | 14 + backend/chat/src/config/runtime.go | 221 ++++++++++++++ backend/chat/src/config/runtime_test.go | 181 ++++++++++++ backend/chat/src/db/db.go | 2 +- backend/chat/src/db/models.go | 8 + backend/chat/src/handlers/handlers.go | 323 ++++++++++++++++++++- backend/chat/src/handlers/handlers_test.go | 288 +++++++++++++++++- backend/chat/src/main.go | 14 + backend/chat/src/ws/ws.go | 17 ++ backend/playout/src/playlist.go | 32 ++ backend/playout/src/playlist_test.go | 44 +++ docker-compose.prod.yml | 2 + docker-compose.yml | 2 + docs/MONITORING.md | 1 + docs/architecture/README.md | 132 ++++++--- frontend/index.html | 172 ++++++----- frontend/public/admin/system.html | 5 + frontend/src/admin.ts | 101 ++++++- frontend/src/main.ts | 186 +++++++++++- frontend/src/style.css | 77 ++++- 27 files changed, 1841 insertions(+), 161 deletions(-) create mode 100644 backend/chat/src/config/runtime.go create mode 100644 backend/chat/src/config/runtime_test.go diff --git a/.env.example b/.env.example index d10c7d2..ad22a46 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,11 @@ PLAYLIST_PATH=/app/playlist/playlist.txt HLS_SEGMENT_DURATION=6 HLS_WINDOW_SIZE=24 +# Shared runtime config (written by chat admin via /api/admin/config, read by playout). +# Both resolve to the same host file through the ./data/playlist volume. +CONFIG_PATH=/app/playlist/config.json +PLAYOUT_CONFIG_PATH=/app/playlist/config.json + # Logging / Monitoring LOG_LEVEL=info PROMETHEUS_PORT=9090 diff --git a/.github/instructions/BACKLOG.md b/.github/instructions/BACKLOG.md index 31e65e2..c01a6af 100644 --- a/.github/instructions/BACKLOG.md +++ b/.github/instructions/BACKLOG.md @@ -1,6 +1,6 @@ # BACKLOG -> All remaining work is planned in [TODO](./TODO.md) (2026-08-13). This file records completed backlog items for reference; new unplanned work is added here. +> All current work is planned in [TODO](./TODO.md) (2026-08-13). This file lists backlog items; new unplanned work is added here. ## Cloudflare Configuration diff --git a/.github/instructions/DONE.md b/.github/instructions/DONE.md index be04bb7..d0a0043 100644 --- a/.github/instructions/DONE.md +++ b/.github/instructions/DONE.md @@ -1,6 +1,6 @@ # DONE -Completed tasks from `.github/instructions/TODO.md`. Historical record for CHANGELOG.md, README.md, architecture docs. +> Completed tasks from [TODO](./TODO.md). Historical record for [CHANGELOG](./CHANGELOG.md), [README](./README.md), [architecture docs](./architecture/README.md). ## Session 2026-08-07 — Unit Tests @@ -1087,3 +1087,64 @@ Completed the three Playout enhancement workstreams from `TODO.md`. Remaining po - Verify `init.mp4` + manifest are correctly published on clean completion (existing `publishManifest` path runs after every run) - Add metrics `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds` - Tests: `TestPlaylistFingerprint`, `TestHLSNextStartNumber` + +## Session 2026-08-13 — Application Configuration, Matomo, Chat Accounts + +### Application configuration (runtime config store) + +- Runtime config store in `backend/chat/src/config/runtime.go` (thread-safe `Get`/`Set`, JSON persist, typed getters/setters with validation) +- `ConfigPath()` default `/app/playlist/config.json` (shared with playout via the playlist volume) +- Typed getters: `MatomoURL()`, `MatomoSiteID()`, `HLSSegmentDuration()`, `HLSWindowSize()` +- `GET/PUT /api/admin/config` now reads/persists `matomoUrl`, `matomoSiteId`, `hlsSegmentDuration`, `hlsWindowSize` +- Admin UI config form extended (`frontend/public/admin/system.html` + `frontend/src/admin.ts`) +- Playout reads the shared `config.json` (`loadSharedHLSConfig` + `PLAYOUT_CONFIG_PATH`) +- Tests: `runtime_test.go`, config handler tests, `TestLoadSharedHLSConfig` + +### Matomo analytics + +- Public `GET /api/matomo` endpoint +- Viewer pageview snippet in `frontend/index.html` +- Test: `TestHandlePublicMatomo` + +### Admin frontend (media status + playlist gating) + +- Media library: status badges (`ready`/`needs_normalization`/`normalizing`/`failed`) + "Add to playlist" gated to `ready` media +- Playlist entries: per-item status badges +- `statusBadge`, `fetchMediaStatusMap`, `addMediaToPlaylist` in `admin.ts` + +### Chat user accounts + +- `db.User` model (bcrypt password hash, unique username) +- `auth.UserSessionStore` (token → username with expiry) +- Endpoints: `POST /api/auth/register`, `POST /api/auth/login`, `GET /api/auth/session`, `POST /api/auth/logout` +- Viewer sign-in UI (`frontend/index.html` + `frontend/src/main.ts`) +- Tests: `TestUserSessionStore`, register/login/session handler tests + +### Private DM history & search + +- `GET /api/dm/history` (paginated) + `GET /api/dm/search` (case-insensitive) +- DM search UI in viewer +- Tests: `TestHandleUserDMHistory`, `TestHandleUserDMHistory_Unauthorized`, `TestHandleUserDMSearch` + +### Chat UI (typing indicator + user list) + +- `Hub.BroadcastTyping` (ephemeral typing events, not persisted) +- Viewer typing indicator (`showTyping`/`hideTyping`/`sendTyping`) + user-list toggle + +## Session 2026-08-13 — Prometheus TSDB Retention + +- Added `--storage.tsdb.retention.time=30d` (delete metrics older than 30d) + `--storage.tsdb.retention.size=15GB` (disk cap) to the `prometheus` service `command` in `docker-compose.yml` and `docker-compose.prod.yml` +- Validated: `docker compose config --quiet` (both files), `promtool check config` (SUCCESS, 10 rules), Prometheus v3.13.2 accepts both retention flags +- Documented the retention policy in `docs/MONITORING.md` → Operational Recommendations + +## Session 2026-08-13 — Architecture Documentation Update + +- Updated `docs/architecture/README.md` to reflect the `[Unreleased]` changes: + - Automatic media normalization on upload (`media.NormalizeVideo`, statuses, `POST /api/admin/media/{id}/normalize`) + - Playout: seamless looping, R2 segment retention, `-validate` mode, shared runtime config, new loop/cleanup metrics + - Prometheus: TSDB retention (`30d` + `15GB`), alert rule count fixed 8 → 10 + - New Cross-cutting Concepts sections: Runtime Configuration Store, Analytics (Matomo) + - Viewer user accounts (register/login/logout/session, bcrypt, `linearvoid_user_session`) + - Chat: DM history/search, typing indicator, user-list toggle + - API reference: `GET/PUT /api/admin/config`, `POST /api/admin/media/{id}/normalize`, new Viewer REST API section + - Frontend + Chat Service module tables updated diff --git a/.github/instructions/TODO.md b/.github/instructions/TODO.md index 24f152f..209fa4b 100644 --- a/.github/instructions/TODO.md +++ b/.github/instructions/TODO.md @@ -4,36 +4,9 @@ ## Enhancements -### Application configuration +### Release new version 1.0.13 -- [ ] Configuration: create simple getter/setter for configuration values (e.g. `MATOMO_URL`, `MATOMO_SITE_ID`) in `backend/admin/config.go` -- [ ] Configuration: expose configuration values to frontend via `/api/admin/config` endpoint -- [ ] Configuration: make configuration values editable: - - [ ] HLS_SEGMENT_DURATION - - [ ] HLS_WINDOW_SIZE - - [ ] MATOMO_URL - - [ ] MATOMO_SITE_ID - -### Admin frontend - -- [ ] Playlist: show `needs_normalization` badge and block/queue playlist-add until `ready` -- [ ] Playlist: show `ready` badge and allow playlist-add when `ready` -- [ ] Extend `system.html` with a section for configuration settings on top of page (HLS segment duration, window size, Matomo URL/site ID) -- [ ] Add functions to `admin.ts` to call `/api/admin/config` for getting and updating configuration values - -### User frontend - -- [ ] Player frontend: Embed JavaScript snippet in `index.html` to send pageview events to Matomo - -### Chat user features - -- [ ] Chat: add user registration and login flows -- [ ] Chat: private DM history and search -- [ ] Chat UI: add login/logout button, show username in chat, show/toggle user list, show/toggle online status, show typing indicator - -### Monitoring - -- [ ] Prometheus TSDB retention policy: delete metrics older than 30d +- [ ] Follow the [release procedure](/CONTRIBUTING.md#full-release-procedure) to release a new version of the software, including updating `CHANGELOG.md`, tagging the release in Git, and creating a Gitea release with notes. ## Setup phases remaining diff --git a/backend/chat/go.mod b/backend/chat/go.mod index 0f86410..dbb0844 100644 --- a/backend/chat/go.mod +++ b/backend/chat/go.mod @@ -6,6 +6,7 @@ require ( github.com/glebarez/sqlite v1.11.0 github.com/gorilla/websocket v1.5.3 github.com/redis/go-redis/v9 v9.7.0 + golang.org/x/crypto v0.37.0 gorm.io/driver/postgres v1.5.11 gorm.io/gorm v1.25.12 ) @@ -24,7 +25,6 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/crypto v0.37.0 // indirect golang.org/x/sync v0.13.0 // indirect golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.24.0 // indirect diff --git a/backend/chat/src/auth/auth.go b/backend/chat/src/auth/auth.go index 23abef2..6a8884a 100644 --- a/backend/chat/src/auth/auth.go +++ b/backend/chat/src/auth/auth.go @@ -137,4 +137,52 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) next(w, r) } } +} + +// UserSessionStore maps user session tokens to usernames with expiry. +type UserSessionStore struct { + mu sync.Mutex + sessions map[string]userSession +} + +type userSession struct { + Username string + ExpiresAt time.Time +} + +// NewUserSessionStore creates a new user session store. +func NewUserSessionStore() *UserSessionStore { + return &UserSessionStore{sessions: make(map[string]userSession)} +} + +// Add stores a user session token with username and expiry. +func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) { + s.mu.Lock() + defer s.mu.Unlock() + s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt} +} + +// Username returns the username for a valid, unexpired token. +func (s *UserSessionStore) Username(token string) (string, bool) { + if token == "" { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + sess, ok := s.sessions[token] + if !ok { + return "", false + } + if time.Now().After(sess.ExpiresAt) { + delete(s.sessions, token) + return "", false + } + return sess.Username, true +} + +// Remove deletes a user session token. +func (s *UserSessionStore) Remove(token string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.sessions, token) } \ No newline at end of file diff --git a/backend/chat/src/auth/auth_test.go b/backend/chat/src/auth/auth_test.go index 8631b00..ec697e5 100644 --- a/backend/chat/src/auth/auth_test.go +++ b/backend/chat/src/auth/auth_test.go @@ -245,4 +245,33 @@ func TestRequireAdmin(t *testing.T) { if w3.Code != http.StatusUnauthorized { t.Errorf("status = %d, want 401", w3.Code) } +} + +func TestUserSessionStore(t *testing.T) { + store := NewUserSessionStore() + + if _, ok := store.Username(""); ok { + t.Error("Username(\"\") = true, want false") + } + + token, _ := GenerateToken() + store.Add(token, "alice", time.Now().Add(1*time.Hour)) + + username, ok := store.Username(token) + if !ok || username != "alice" { + t.Errorf("Username() = %q, %v; want alice, true", username, ok) + } + + // Expired session + expired, _ := GenerateToken() + store.Add(expired, "bob", time.Now().Add(-1*time.Hour)) + if _, ok := store.Username(expired); ok { + t.Error("Username(expired) = true, want false") + } + + // Remove + store.Remove(token) + if _, ok := store.Username(token); ok { + t.Error("Username after Remove = true, want false") + } } \ No newline at end of file diff --git a/backend/chat/src/config/config.go b/backend/chat/src/config/config.go index 8d86c13..e1226fc 100644 --- a/backend/chat/src/config/config.go +++ b/backend/chat/src/config/config.go @@ -16,6 +16,7 @@ const ( DefaultRateLimitMax = 10 DefaultRateLimitWindow = 10 * time.Second DefaultAdminSessionTTL = 12 * time.Hour + DefaultUserSessionTTL = 30 * 24 * time.Hour DefaultChatPort = "3000" ) @@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration { return time.Duration(hours) * time.Hour } +// UserSessionTTL returns the user session TTL from env or default (30 days). +func UserSessionTTL() time.Duration { + raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS")) + if raw == "" { + return DefaultUserSessionTTL + } + hours, err := strconv.Atoi(raw) + if err != nil || hours <= 0 { + return DefaultUserSessionTTL + } + return time.Duration(hours) * time.Hour +} + // RateLimitMax returns the chat rate limit max messages per window. func RateLimitMax() int { raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX")) diff --git a/backend/chat/src/config/runtime.go b/backend/chat/src/config/runtime.go new file mode 100644 index 0000000..3d956cf --- /dev/null +++ b/backend/chat/src/config/runtime.go @@ -0,0 +1,221 @@ +package config + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" +) + +// Runtime configuration keys stored in the runtime config file. +const ( + keyMatomoURL = "matomo_url" + keyMatomoSiteID = "matomo_site_id" + keyHLSSegmentDur = "hls_segment_duration" + keyHLSWindowSize = "hls_window_size" +) + +// Default HLS values used when neither env nor persisted config is set. +const ( + defaultHLSSegmentDuration = 6 + defaultHLSWindowSize = 24 +) + +var ( + runtimeMu sync.RWMutex + runtimeStore = map[string]string{} +) + +// ConfigPath returns the runtime config JSON file path from env or default. +// The default lives in the shared playlist volume so the playout service can +// read the same file (chat has read-write, playout read-only). +func ConfigPath() string { + p := strings.TrimSpace(os.Getenv("CONFIG_PATH")) + if p == "" { + return "/app/playlist/config.json" + } + return p +} + +// GetRuntime returns a value from the runtime store and whether it is present. +func GetRuntime(key string) (string, bool) { + runtimeMu.RLock() + defer runtimeMu.RUnlock() + v, ok := runtimeStore[key] + return v, ok +} + +// SetRuntime stores a value in the runtime store (in-memory only). +func SetRuntime(key, value string) { + runtimeMu.Lock() + defer runtimeMu.Unlock() + runtimeStore[key] = value +} + +// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does +// not exist, it seeds the store from environment variables. +func LoadRuntimeConfig() error { + data, err := os.ReadFile(ConfigPath()) + if err != nil { + if os.IsNotExist(err) { + runtimeMu.Lock() + runtimeStore = map[string]string{} + runtimeMu.Unlock() + seedRuntimeDefaults() + return nil + } + return fmt.Errorf("read runtime config: %w", err) + } + + values := map[string]string{} + if err := json.Unmarshal(data, &values); err != nil { + return fmt.Errorf("parse runtime config: %w", err) + } + + runtimeMu.Lock() + defer runtimeMu.Unlock() + runtimeStore = values + return nil +} + +// SaveRuntimeConfig persists the runtime store to disk as JSON. +func SaveRuntimeConfig() error { + runtimeMu.RLock() + data, err := json.MarshalIndent(runtimeStore, "", " ") + runtimeMu.RUnlock() + if err != nil { + return fmt.Errorf("marshal runtime config: %w", err) + } + if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create runtime config dir: %w", err) + } + } + if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil { + return fmt.Errorf("write runtime config: %w", err) + } + return nil +} + +// seedRuntimeDefaults populates the store from environment variables. +func seedRuntimeDefaults() { + if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" { + SetRuntime(keyMatomoURL, v) + } + if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" { + SetRuntime(keyMatomoSiteID, v) + } + if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" { + SetRuntime(keyHLSSegmentDur, v) + } + if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" { + SetRuntime(keyHLSWindowSize, v) + } +} + +// MatomoURL returns the Matomo analytics URL. +func MatomoURL() string { + if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return strings.TrimSpace(os.Getenv("MATOMO_URL")) +} + +// MatomoSiteID returns the Matomo site ID. +func MatomoSiteID() string { + if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")) +} + +// HLSSegmentDuration returns the HLS segment duration in seconds. +func HLSSegmentDuration() int { + raw := "" + if v, ok := GetRuntime(keyHLSSegmentDur); ok { + raw = strings.TrimSpace(v) + } + if raw == "" { + raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")) + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultHLSSegmentDuration + } + return n +} + +// HLSWindowSize returns the HLS playlist window size in segments. +func HLSWindowSize() int { + raw := "" + if v, ok := GetRuntime(keyHLSWindowSize); ok { + raw = strings.TrimSpace(v) + } + if raw == "" { + raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")) + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return defaultHLSWindowSize + } + return n +} + +// SetMatomoURL validates and stores the Matomo URL. +func SetMatomoURL(raw string) error { + raw = strings.TrimSpace(raw) + if raw == "" { + SetRuntime(keyMatomoURL, "") + return nil + } + u, err := url.Parse(raw) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { + return fmt.Errorf("matomo URL must be an absolute http(s) URL") + } + SetRuntime(keyMatomoURL, raw) + return nil +} + +// SetMatomoSiteID validates and stores the Matomo site ID. +func SetMatomoSiteID(raw string) error { + raw = strings.TrimSpace(raw) + if raw != "" { + if _, err := strconv.Atoi(raw); err != nil { + return fmt.Errorf("matomo site ID must be numeric") + } + } + SetRuntime(keyMatomoSiteID, raw) + return nil +} + +// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration. +func SetHLSSegmentDuration(seconds int) error { + if seconds < 2 || seconds > 15 { + return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds") + } + SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds)) + return nil +} + +// SetHLSWindowSize validates (3–100) and stores the HLS window size. +func SetHLSWindowSize(size int) error { + if size < 3 || size > 100 { + return fmt.Errorf("HLS window size must be between 3 and 100 segments") + } + SetRuntime(keyHLSWindowSize, strconv.Itoa(size)) + return nil +} + +// RuntimeConfigSnapshot returns the resolved runtime configuration values. +func RuntimeConfigSnapshot() map[string]any { + return map[string]any{ + "matomoUrl": MatomoURL(), + "matomoSiteId": MatomoSiteID(), + "hlsSegmentDuration": HLSSegmentDuration(), + "hlsWindowSize": HLSWindowSize(), + } +} diff --git a/backend/chat/src/config/runtime_test.go b/backend/chat/src/config/runtime_test.go new file mode 100644 index 0000000..c234ca2 --- /dev/null +++ b/backend/chat/src/config/runtime_test.go @@ -0,0 +1,181 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func resetRuntime(t *testing.T) { + t.Helper() + runtimeMu.Lock() + runtimeStore = map[string]string{} + runtimeMu.Unlock() + os.Unsetenv("CONFIG_PATH") + os.Unsetenv("MATOMO_URL") + os.Unsetenv("MATOMO_SITE_ID") + os.Unsetenv("HLS_SEGMENT_DURATION") + os.Unsetenv("HLS_WINDOW_SIZE") +} + +func TestConfigPath(t *testing.T) { + resetRuntime(t) + if got := ConfigPath(); got != "/app/playlist/config.json" { + t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got) + } + + os.Setenv("CONFIG_PATH", "/tmp/custom.json") + defer os.Unsetenv("CONFIG_PATH") + if got := ConfigPath(); got != "/tmp/custom.json" { + t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got) + } +} + +func TestGetSetRuntime(t *testing.T) { + resetRuntime(t) + if _, ok := GetRuntime("foo"); ok { + t.Errorf("expected missing key") + } + SetRuntime("foo", "bar") + v, ok := GetRuntime("foo") + if !ok || v != "bar" { + t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok) + } +} + +func TestGettersDefaults(t *testing.T) { + resetRuntime(t) + if got := MatomoURL(); got != "" { + t.Errorf("MatomoURL() = %q, want empty", got) + } + if got := MatomoSiteID(); got != "" { + t.Errorf("MatomoSiteID() = %q, want empty", got) + } + if got := HLSSegmentDuration(); got != 6 { + t.Errorf("HLSSegmentDuration() = %d, want 6", got) + } + if got := HLSWindowSize(); got != 24 { + t.Errorf("HLSWindowSize() = %d, want 24", got) + } +} + +func TestGettersEnvFallback(t *testing.T) { + resetRuntime(t) + os.Setenv("HLS_SEGMENT_DURATION", "4") + os.Setenv("HLS_WINDOW_SIZE", "12") + os.Setenv("MATOMO_URL", "https://analytics.example.com") + os.Setenv("MATOMO_SITE_ID", "7") + + if got := HLSSegmentDuration(); got != 4 { + t.Errorf("HLSSegmentDuration() = %d, want 4", got) + } + if got := HLSWindowSize(); got != 12 { + t.Errorf("HLSWindowSize() = %d, want 12", got) + } + if got := MatomoURL(); got != "https://analytics.example.com" { + t.Errorf("MatomoURL() = %q", got) + } + if got := MatomoSiteID(); got != "7" { + t.Errorf("MatomoSiteID() = %q", got) + } +} + +func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) { + resetRuntime(t) + dir := t.TempDir() + os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json")) + os.Setenv("HLS_SEGMENT_DURATION", "8") + + if err := LoadRuntimeConfig(); err != nil { + t.Fatalf("LoadRuntimeConfig error: %v", err) + } + if got := HLSSegmentDuration(); got != 8 { + t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got) + } +} + +func TestSaveAndLoadRuntimeConfig(t *testing.T) { + resetRuntime(t) + dir := t.TempDir() + os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json")) + + if err := SetHLSSegmentDuration(5); err != nil { + t.Fatal(err) + } + if err := SetHLSWindowSize(30); err != nil { + t.Fatal(err) + } + if err := SetMatomoURL("https://stats.example.com"); err != nil { + t.Fatal(err) + } + if err := SetMatomoSiteID("42"); err != nil { + t.Fatal(err) + } + + if err := SaveRuntimeConfig(); err != nil { + t.Fatalf("SaveRuntimeConfig error: %v", err) + } + + // Reset the store and reload from disk. + runtimeMu.Lock() + runtimeStore = map[string]string{} + runtimeMu.Unlock() + + if err := LoadRuntimeConfig(); err != nil { + t.Fatalf("LoadRuntimeConfig error: %v", err) + } + + if got := HLSSegmentDuration(); got != 5 { + t.Errorf("HLSSegmentDuration() = %d, want 5", got) + } + if got := HLSWindowSize(); got != 30 { + t.Errorf("HLSWindowSize() = %d, want 30", got) + } + if got := MatomoURL(); got != "https://stats.example.com" { + t.Errorf("MatomoURL() = %q", got) + } + if got := MatomoSiteID(); got != "42" { + t.Errorf("MatomoSiteID() = %q", got) + } +} + +func TestSettersValidation(t *testing.T) { + resetRuntime(t) + + if err := SetHLSSegmentDuration(1); err == nil { + t.Error("expected error for duration 1") + } + if err := SetHLSSegmentDuration(20); err == nil { + t.Error("expected error for duration 20") + } + if err := SetHLSSegmentDuration(6); err != nil { + t.Errorf("unexpected error for duration 6: %v", err) + } + + if err := SetHLSWindowSize(2); err == nil { + t.Error("expected error for window 2") + } + if err := SetHLSWindowSize(101); err == nil { + t.Error("expected error for window 101") + } + if err := SetHLSWindowSize(24); err != nil { + t.Errorf("unexpected error for window 24: %v", err) + } + + if err := SetMatomoURL("not-a-url"); err == nil { + t.Error("expected error for invalid URL") + } + if err := SetMatomoURL("ftp://example.com"); err == nil { + t.Error("expected error for non-http URL") + } + if err := SetMatomoURL("https://stats.example.com"); err != nil { + t.Errorf("unexpected error for valid URL: %v", err) + } + + if err := SetMatomoSiteID("abc"); err == nil { + t.Error("expected error for non-numeric site ID") + } + if err := SetMatomoSiteID("99"); err != nil { + t.Errorf("unexpected error for numeric site ID: %v", err) + } +} diff --git a/backend/chat/src/db/db.go b/backend/chat/src/db/db.go index e9a3ed9..a0191ce 100644 --- a/backend/chat/src/db/db.go +++ b/backend/chat/src/db/db.go @@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) { return nil, driver, err } - gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}) + gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{}) log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver)) return &DBClient{DB: gormDB}, driver, nil diff --git a/backend/chat/src/db/models.go b/backend/chat/src/db/models.go index 3b3dd52..25a1af8 100644 --- a/backend/chat/src/db/models.go +++ b/backend/chat/src/db/models.go @@ -39,6 +39,14 @@ type Media struct { UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"` } +// User represents a registered chat user account (ADR-006). +type User struct { + ID uint `gorm:"primarykey" json:"id"` + Username string `gorm:"uniqueIndex;size:32" json:"username"` + PasswordHash string `json:"-"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"` +} + // ScheduleOverride represents a scheduled playlist override (ADR-004) type ScheduleOverride struct { ID uint `gorm:"primarykey" json:"id"` diff --git a/backend/chat/src/handlers/handlers.go b/backend/chat/src/handlers/handlers.go index 003d130..090d514 100644 --- a/backend/chat/src/handlers/handlers.go +++ b/backend/chat/src/handlers/handlers.go @@ -13,6 +13,7 @@ import ( "strings" "time" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "chat/src/auth" @@ -197,6 +198,7 @@ type Dependencies struct { Redis db.RedisClient Hub *ws.Hub SessionStore *auth.SessionStore + UserSessions *auth.UserSessionStore DBDriver string } @@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc { } } +// userSessionCookieName is the cookie for registered chat users. +const userSessionCookieName = "linearvoid_user_session" + +// UserCredentialsPayload is used by registration and login. +type UserCredentialsPayload struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// validateUsername checks the username length constraints. +func validateUsername(username string) error { + if len(username) < 3 || len(username) > 32 { + return fmt.Errorf("username must be 3-32 characters") + } + return nil +} + +// validatePassword checks the password length constraints. +func validatePassword(password string) error { + if len(password) < 6 { + return fmt.Errorf("password must be at least 6 characters") + } + return nil +} + +// startUserSession issues a user session cookie for the given username. +func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) { + token, err := auth.GenerateToken() + if err != nil { + return + } + expiresAt := time.Now().Add(config.UserSessionTTL()) + deps.UserSessions.Add(token, username, expiresAt) + auth.SetCookie(w, r, userSessionCookieName, token, expiresAt) +} + +// HandleUserRegister creates a new chat user and signs them in. +func HandleUserRegister(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var payload UserCredentialsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + username := strings.TrimSpace(payload.Username) + if err := validateUsername(username); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := validatePassword(payload.Password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var count int64 + deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count) + if count > 0 { + http.Error(w, "username already taken", http.StatusConflict) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "failed to hash password", http.StatusInternalServerError) + return + } + user := db.User{Username: username, PasswordHash: string(hash)} + if err := deps.DB.Create(&user).Error; err != nil { + http.Error(w, "failed to create user", http.StatusInternalServerError) + return + } + + startUserSession(deps, w, r, user.Username) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"username": user.Username}) + } +} + +// HandleUserLogin verifies credentials and signs the user in. +func HandleUserLogin(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var payload UserCredentialsPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid body", http.StatusBadRequest) + return + } + username := strings.TrimSpace(payload.Username) + + var user db.User + if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil { + http.Error(w, "invalid credentials", http.StatusUnauthorized) + return + } + + startUserSession(deps, w, r, user.Username) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"username": user.Username}) + } +} + +// HandleUserSession returns the current user session state. +func HandleUserSession(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + token, _ := auth.GetTokenFromCookie(r, userSessionCookieName) + username, ok := deps.UserSessions.Username(token) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "authenticated": ok, + "username": username, + }) + } +} + +// HandleUserLogout clears the user session. +func HandleUserLogout(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok { + deps.UserSessions.Remove(token) + } + auth.ClearCookie(w, r, userSessionCookieName) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]bool{"ok": true}) + } +} + +// currentUsername returns the authenticated username from the user session cookie. +func currentUsername(deps *Dependencies, r *http.Request) (string, bool) { + token, ok := auth.GetTokenFromCookie(r, userSessionCookieName) + if !ok { + return "", false + } + return deps.UserSessions.Username(token) +} + +// dmRoomFor returns the canonical DM room for two users (sorted, prefixed). +func dmRoomFor(a, b string) string { + if a > b { + a, b = b, a + } + return "dm:" + a + "-" + b +} + +// HandleUserDMHistory returns paginated private message history with a target user. +func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username, ok := currentUsername(deps, r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + target := strings.TrimSpace(r.URL.Query().Get("with")) + if target == "" { + http.Error(w, "missing target user", http.StatusBadRequest) + return + } + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 { + limit = n + } + } + room := dmRoomFor(username, target) + + query := deps.DB.Where("room = ?", room) + if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" { + if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil { + query = query.Where("timestamp < ?", t) + } + } + var messages []db.Message + if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { + http.Error(w, "failed to load history", http.StatusInternalServerError) + return + } + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + } +} + +// HandleUserDMSearch searches private messages with a target user. +func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + username, ok := currentUsername(deps, r) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + target := strings.TrimSpace(r.URL.Query().Get("with")) + q := strings.TrimSpace(r.URL.Query().Get("q")) + if target == "" || q == "" { + http.Error(w, "missing target or query", http.StatusBadRequest) + return + } + limit := 50 + if raw := r.URL.Query().Get("limit"); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 { + limit = n + } + } + room := dmRoomFor(username, target) + var messages []db.Message + if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%"). + Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { + http.Error(w, "failed to search", http.StatusInternalServerError) + return + } + for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { + messages[i], messages[j] = messages[j], messages[i] + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(messages) + } +} + // HandleAdminOverview returns the admin dashboard overview. func HandleAdminOverview(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc { } } +// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend. +func HandlePublicMatomo() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "matomoUrl": config.MatomoURL(), + "matomoSiteId": config.MatomoSiteID(), + }) + } +} + +// adminConfigResponse builds the admin configuration payload from runtime config +// and read-only runtime facts. +func adminConfigResponse(deps *Dependencies) map[string]any { + resp := config.RuntimeConfigSnapshot() + resp["rateLimitMax"] = config.RateLimitMax() + resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds()) + resp["databaseDriver"] = deps.DBDriver + resp["redisEnabled"] = deps.Redis != nil + resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours()) + return resp +} + // HandleAdminConfig returns/updates runtime configuration. func HandleAdminConfig(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]any{ - "rateLimitMax": config.RateLimitMax(), - "rateLimitWindowSec": int(config.RateLimitWindow().Seconds()), - "databaseDriver": deps.DBDriver, - "redisEnabled": deps.Redis != nil, - "sessionTTLHours": int(config.AdminSessionTTL().Hours()), - }) + json.NewEncoder(w).Encode(adminConfigResponse(deps)) case http.MethodPut: - var payload map[string]any + // Editable runtime fields (Matomo + HLS). Rate limit fields remain + // env-driven and are intentionally read-only here. + var payload struct { + MatomoURL *string `json:"matomoUrl"` + MatomoSiteID *string `json:"matomoSiteId"` + HLSSegmentDuration *int `json:"hlsSegmentDuration"` + HLSWindowSize *int `json:"hlsWindowSize"` + } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } - // Note: rate limit config is read-only at runtime in this refactored version. - // To change, restart with env vars. + if payload.MatomoURL != nil { + if err := config.SetMatomoURL(*payload.MatomoURL); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if payload.MatomoSiteID != nil { + if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if payload.HLSSegmentDuration != nil { + if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if payload.HLSWindowSize != nil { + if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + } + if err := config.SaveRuntimeConfig(); err != nil { + http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + json.NewEncoder(w).Encode(adminConfigResponse(deps)) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } diff --git a/backend/chat/src/handlers/handlers_test.go b/backend/chat/src/handlers/handlers_test.go index 1513af8..ee79baa 100644 --- a/backend/chat/src/handlers/handlers_test.go +++ b/backend/chat/src/handlers/handlers_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/glebarez/sqlite" + "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "chat/src/auth" @@ -102,7 +103,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) { if err != nil { t.Fatalf("failed to open test DB: %v", err) } - gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}) + gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{}) mr := newMockRedis() sessionStore := auth.NewSessionStore() hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername()) @@ -112,6 +113,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) { Redis: mr, Hub: hub, SessionStore: sessionStore, + UserSessions: auth.NewUserSessionStore(), DBDriver: "sqlite", } return deps, gormDB, sessionStore @@ -476,6 +478,7 @@ func TestHandleAdminConfig_Get(t *testing.T) { func TestHandleAdminConfig_Put(t *testing.T) { deps, _, _ := setupTestDeps(t) + setupConfigTest(t) handler := HandleAdminConfig(deps) body := `{"rateLimitMax":20}` @@ -490,6 +493,289 @@ func TestHandleAdminConfig_Put(t *testing.T) { } } +// setupConfigTest points the runtime config at a fresh temp file and resets the store. +func setupConfigTest(t *testing.T) { + t.Helper() + os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json")) + os.Unsetenv("MATOMO_URL") + os.Unsetenv("MATOMO_SITE_ID") + os.Unsetenv("HLS_SEGMENT_DURATION") + os.Unsetenv("HLS_WINDOW_SIZE") + if err := config.LoadRuntimeConfig(); err != nil { + t.Fatalf("LoadRuntimeConfig: %v", err) + } + t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") }) +} + +func TestHandleAdminConfig_Get_NewFields(t *testing.T) { + deps, _, _ := setupTestDeps(t) + setupConfigTest(t) + + handler := HandleAdminConfig(deps) + req := httptest.NewRequest("GET", "/api/admin/config", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var cfg map[string]any + if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} { + if _, ok := cfg[k]; !ok { + t.Errorf("response missing %q", k) + } + } +} + +func TestHandleAdminConfig_Put_Persists(t *testing.T) { + deps, _, _ := setupTestDeps(t) + setupConfigTest(t) + + handler := HandleAdminConfig(deps) + body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}` + req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var cfg map[string]any + if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil { + t.Fatalf("decode: %v", err) + } + if cfg["hlsSegmentDuration"] != float64(5) { + t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"]) + } + if cfg["hlsWindowSize"] != float64(30) { + t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"]) + } + if cfg["matomoUrl"] != "https://stats.example.com" { + t.Errorf("matomoUrl = %v", cfg["matomoUrl"]) + } + if cfg["matomoSiteId"] != "42" { + t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"]) + } +} + +func TestHandleAdminConfig_Put_Invalid(t *testing.T) { + deps, _, _ := setupTestDeps(t) + setupConfigTest(t) + + handler := HandleAdminConfig(deps) + body := `{"hlsSegmentDuration":99}` + req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", w.Code) + } +} + +func TestHandlePublicMatomo(t *testing.T) { + setupConfigTest(t) + + handler := HandlePublicMatomo() + req := httptest.NewRequest("GET", "/api/matomo", nil) + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var resp map[string]any + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"matomoUrl", "matomoSiteId"} { + if _, ok := resp[k]; !ok { + t.Errorf("response missing %q", k) + } + } +} + +func TestHandleUserRegister_Success(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + handler := HandleUserRegister(deps) + body := `{"username":"alice","password":"secret123"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String()) + } + var count int64 + gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count) + if count != 1 { + t.Errorf("user count = %d, want 1", count) + } + if len(w.Result().Cookies()) == 0 { + t.Error("expected session cookie to be set") + } +} + +func TestHandleUserRegister_Duplicate(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"}) + + handler := HandleUserRegister(deps) + body := `{"username":"alice","password":"secret123"}` + req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("status = %d, want 409", w.Code) + } +} + +func TestHandleUserLogin_Success(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost) + gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)}) + + handler := HandleUserLogin(deps) + body := `{"username":"bob","password":"secret123"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String()) + } +} + +func TestHandleUserLogin_WrongPassword(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost) + gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)}) + + handler := HandleUserLogin(deps) + body := `{"username":"bob","password":"wrong"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleUserSession(t *testing.T) { + deps, _, _ := setupTestDeps(t) + handler := HandleUserSession(deps) + + // Unauthenticated + req := httptest.NewRequest("GET", "/api/auth/session", nil) + w := httptest.NewRecorder() + handler(w, req) + var resp map[string]any + json.NewDecoder(w.Body).Decode(&resp) + if resp["authenticated"] != false { + t.Errorf("authenticated = %v, want false", resp["authenticated"]) + } + + // Authenticated + token, _ := auth.GenerateToken() + deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour)) + req2 := httptest.NewRequest("GET", "/api/auth/session", nil) + req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token}) + w2 := httptest.NewRecorder() + handler(w2, req2) + var resp2 map[string]any + json.NewDecoder(w2.Body).Decode(&resp2) + if resp2["username"] != "carol" { + t.Errorf("username = %v, want carol", resp2["username"]) + } +} + +func TestHandleUserDMHistory(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + token, _ := auth.GenerateToken() + deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour)) + + now := time.Now() + gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)}) + gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)}) + + handler := HandleUserDMHistory(deps) + req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil) + req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token}) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var msgs []db.Message + json.NewDecoder(w.Body).Decode(&msgs) + if len(msgs) != 2 { + t.Fatalf("len = %d, want 2", len(msgs)) + } + if msgs[0].Text != "hi bob" { + t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text) + } +} + +func TestHandleUserDMHistory_Unauthorized(t *testing.T) { + deps, _, _ := setupTestDeps(t) + + handler := HandleUserDMHistory(deps) + req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", w.Code) + } +} + +func TestHandleUserDMSearch(t *testing.T) { + deps, gormDB, _ := setupTestDeps(t) + + token, _ := auth.GenerateToken() + deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour)) + + gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"}) + gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"}) + + handler := HandleUserDMSearch(deps) + req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil) + req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token}) + w := httptest.NewRecorder() + handler(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + var msgs []db.Message + json.NewDecoder(w.Body).Decode(&msgs) + if len(msgs) != 1 || msgs[0].Text != "meet at 8" { + t.Fatalf("search results = %v, want [meet at 8]", msgs) + } +} + func TestHandleAdminRestart(t *testing.T) { deps, _, _ := setupTestDeps(t) diff --git a/backend/chat/src/main.go b/backend/chat/src/main.go index 6c17841..517e3db 100644 --- a/backend/chat/src/main.go +++ b/backend/chat/src/main.go @@ -28,6 +28,11 @@ func main() { rateLimitWindow := config.RateLimitWindow() log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow) + // Load persisted runtime configuration (Matomo + HLS settings) + if err := config.LoadRuntimeConfig(); err != nil { + log.Printf("Warning: failed to load runtime config: %v", err) + } + // Initialize Redis redisWrapper := db.InitRedis(config.RedisAddress()) @@ -39,6 +44,7 @@ func main() { // Initialize session store sessionStore := auth.NewSessionStore() + userSessions := auth.NewUserSessionStore() // Initialize WebSocket hub hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername()) @@ -54,6 +60,7 @@ func main() { Redis: redisWrapper, Hub: hub, SessionStore: sessionStore, + UserSessions: userSessions, DBDriver: dbDriver, } @@ -62,6 +69,12 @@ func main() { // Register routes http.HandleFunc("/ws", hub.HandleConnection) + http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps))) + http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps))) + http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps))) + http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps))) + http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps))) + http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps))) http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps))) http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps))) http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps))) @@ -77,6 +90,7 @@ func main() { http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps)))) http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps)))) http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps))) + http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo())) http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps)))) http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps)))) http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps)))) diff --git a/backend/chat/src/ws/ws.go b/backend/chat/src/ws/ws.go index c8054d4..8988e37 100644 --- a/backend/chat/src/ws/ws.go +++ b/backend/chat/src/ws/ws.go @@ -93,6 +93,17 @@ func (h *Hub) Broadcast(msg db.Message) { } } +// BroadcastTyping notifies other users in the room that a user is typing. +func (h *Hub) BroadcastTyping(username, room string) { + h.mu.Lock() + defer h.mu.Unlock() + for conn, info := range h.clientMeta { + if info.Room == room && info.Username != username { + _ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()}) + } + } +} + // PushChatHistory sends the last N messages for a room via WebSocket. func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) { if h.DB == nil || room == "" { @@ -224,6 +235,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) { continue } + // Handle typing indicator (ephemeral — not persisted, not rate-limited) + if msg.Type == "typing" { + h.BroadcastTyping(msg.Username, msg.Room) + continue + } + // Handle private DMs if strings.HasPrefix(msg.Room, "dm:") { h.handlePrivateDM(msg) diff --git a/backend/playout/src/playlist.go b/backend/playout/src/playlist.go index a192f88..f4dede2 100644 --- a/backend/playout/src/playlist.go +++ b/backend/playout/src/playlist.go @@ -612,6 +612,9 @@ func main() { return } + // Load shared HLS runtime config (written by the chat admin) before starting. + loadSharedHLSConfig() + fmt.Println("=== Linear Void Playout Service Starting ===") fmt.Println("Playlist:", playlistPath) fmt.Println("R2 Bucket:", bucket) @@ -996,4 +999,33 @@ func runValidateOnly(playlistPath string) { os.Exit(1) } fmt.Println("Playlist valid.") +} + +// loadSharedHLSConfig reads HLS settings from the shared runtime config file +// (written by the chat admin service) and overrides the env-derived defaults. +func loadSharedHLSConfig() { + path := envWithDefault("PLAYOUT_CONFIG_PATH", "/app/playlist/config.json") + data, err := os.ReadFile(path) + if err != nil { + if !os.IsNotExist(err) { + fmt.Println("Warning: cannot read shared config:", err) + } + return + } + var cfg map[string]string + if err := json.Unmarshal(data, &cfg); err != nil { + fmt.Println("Warning: cannot parse shared config:", err) + return + } + if v := strings.TrimSpace(cfg["hls_segment_duration"]); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + hlsSegmentDuration = strconv.Itoa(n) + } + } + if v := strings.TrimSpace(cfg["hls_window_size"]); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + hlsWindowSize = strconv.Itoa(n) + } + } + fmt.Printf("Shared HLS config: segment=%ss window=%s\n", hlsSegmentDuration, hlsWindowSize) } \ No newline at end of file diff --git a/backend/playout/src/playlist_test.go b/backend/playout/src/playlist_test.go index 8b5ccc8..1851108 100644 --- a/backend/playout/src/playlist_test.go +++ b/backend/playout/src/playlist_test.go @@ -733,4 +733,48 @@ func TestHLSNextStartNumber(t *testing.T) { t.Errorf("seg-%06d should be kept: %v", i, err) } } +} + +func TestLoadSharedHLSConfig(t *testing.T) { + prevDur := hlsSegmentDuration + prevWin := hlsWindowSize + defer func() { + hlsSegmentDuration = prevDur + hlsWindowSize = prevWin + }() + + tmpDir := t.TempDir() + cfgPath := filepath.Join(tmpDir, "config.json") + if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil { + t.Fatal(err) + } + os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath) + defer os.Unsetenv("PLAYOUT_CONFIG_PATH") + + loadSharedHLSConfig() + + if hlsSegmentDuration != "4" { + t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration) + } + if hlsWindowSize != "12" { + t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize) + } +} + +func TestLoadSharedHLSConfig_MissingFile(t *testing.T) { + prevDur := hlsSegmentDuration + prevWin := hlsWindowSize + defer func() { + hlsSegmentDuration = prevDur + hlsWindowSize = prevWin + }() + + os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json")) + defer os.Unsetenv("PLAYOUT_CONFIG_PATH") + + loadSharedHLSConfig() // should be a no-op without erroring + + if hlsSegmentDuration != prevDur { + t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur) + } } \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 8cdb40a..42ac5de 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -168,6 +168,8 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.retention.size=15GB" - "--web.console.libraries=/etc/prometheus/console_libraries" - "--web.console.templates=/etc/prometheus/consoles" - "--web.enable-lifecycle" diff --git a/docker-compose.yml b/docker-compose.yml index 7a40e62..141e2cc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -162,6 +162,8 @@ services: command: - "--config.file=/etc/prometheus/prometheus.yml" - "--storage.tsdb.path=/prometheus" + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.retention.size=15GB" - "--web.console.libraries=/etc/prometheus/console_libraries" - "--web.console.templates=/etc/prometheus/consoles" - "--web.enable-lifecycle" diff --git a/docs/MONITORING.md b/docs/MONITORING.md index 7c9189d..704d8f0 100644 --- a/docs/MONITORING.md +++ b/docs/MONITORING.md @@ -95,3 +95,4 @@ See detailed scripts for querying Prometheus scrape targets: - Keep alert thresholds tied to actual user impact. - Review dashboards after each release that changes endpoints or metrics names. - Periodically test alert routing and incident response flow. +- Metrics retention is set to **30 days** time-based plus a **15GB** size cap (`--storage.tsdb.retention.time=30d --storage.tsdb.retention.size=15GB` on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml`). Increase the size cap before extending the time window so the TSDB does not hit the disk limit first. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0123db7..1927e9d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -39,7 +39,9 @@ - [Video Normalization \& Playout](#video-normalization--playout) - [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1) - [Reference Normalization Command](#reference-normalization-command) - - [Verification Gate (ffprobe)](#verification-gate-ffprobe) + - [Automatic Normalization \& Verification (ffprobe)](#automatic-normalization--verification-ffprobe) + - [Runtime Configuration Store](#runtime-configuration-store) + - [Analytics (Matomo)](#analytics-matomo) - [Monitoring \& Resilience](#monitoring--resilience) - [Prometheus Baseline](#prometheus-baseline) - [Grafana Dashboards](#grafana-dashboards) @@ -48,6 +50,7 @@ - [Chat WebSocket API](#chat-websocket-api) - [Playout and Delivery REST API](#playout-and-delivery-rest-api) - [Admin REST API](#admin-rest-api) + - [Viewer REST API](#viewer-rest-api) - [Cloudflare Configuration Reference](#cloudflare-configuration-reference) - [DNS and Zone Routing](#dns-and-zone-routing) - [Cache Rules Baseline](#cache-rules-baseline) @@ -58,6 +61,7 @@ - [Transport and Edge Protection](#transport-and-edge-protection) - [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx) - [Admin Authentication and Session Security](#admin-authentication-and-session-security) + - [Viewer User Accounts](#viewer-user-accounts) - [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls) - [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior) - [Moderation and Privacy](#moderation-and-privacy) @@ -305,7 +309,10 @@ graph TB - **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths - **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2 - **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081 -- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval) +- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval); `-validate` mode checks the playlist without running the loop +- **Seamless looping**: Only resets HLS output when the playlist fingerprint (SHA-256) changes, on crash/slate recovery, or first run; otherwise continues segment numbering via `-start_number` +- **R2 retention**: `cleanupOldSegments` deletes `hls/r-*/*` objects older than `R2_SEGMENT_RETENTION_HOURS` (default 24h) hourly + at startup +- **Shared config**: Reads the runtime `config.json` (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window #### Chat Service (`backend/chat/src/main.go`) @@ -334,43 +341,50 @@ Module-level breakdown per service. #### Chat Service Modules -| Module | File/Lines | Responsibility | -| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages | -| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler | -| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close | -| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message | -| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events | -| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out | -| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting | -| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary | -| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up | +| Module | File/Lines | Responsibility | +| ---------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages | +| **Message Parser** | `main.go` (readPump, handleMessage) | Parse JSON envelope `{type, room?, ...}`, validate fields, route to handler | +| **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close | +| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message | +| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events | +| **Redis Subsystem** | `main.go` (redisClient, rate limiting, pub/sub, presence) | INCR+EXPIRE rate limit, SETEX presence, PSUBSCRIBE room fan-out | +| **Auth Handler** | `main.go` (handleAdminLogin, adminAuthMiddleware) | Session cookie creation/validation, login rate limiting | +| **User Auth Handler** | `handlers/handlers.go` (HandleUserRegister/Login/Session/Logout) | Viewer register/login/logout/session (bcrypt, user session cookie) | +| **DM History/Search** | `handlers/handlers.go` (HandleUserDMHistory, HandleUserDMSearch) | Paginated DM history + search for authenticated users | +| **Runtime Config** | `config/runtime.go` (GetRuntime, SetRuntime, typed setters) | Thread-safe JSON runtime config (HLS duration/window, Matomo) | +| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary | +| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up | #### Playout Service Modules -| Module | File/Lines | Responsibility | -| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------ | -| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths | -| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash | -| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures | -| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket | -| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime | -| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) | +| Module | File/Lines | Responsibility | +| -------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths | +| **FFmpeg Manager** | `playlist.go` (runPlayout, restart logic) | Spawn/monitor FFmpeg concat process, restart on crash | +| **Loop Controller** | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect playlist changes (SHA-256), continue segment numbering on clean loops | +| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures | +| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket | +| **R2 Cleanup** | `playlist.go` (cleanupOldSegments) | Delete stale `hls/r-*/*` segments older than the retention window | +| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime | +| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total | #### Frontend Modules -| Module | File | Responsibility | -| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | -| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery | -| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers | -| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots | -| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program | -| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading | -| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback | -| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` | -| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer | -| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats | -| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons | +| Module | File | Responsibility | +| ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery | +| **WebSocket Client** | `src/main.ts` | Connect/disconnect, parse envelope, dispatch to UI handlers | +| **Chat UI** | `src/main.ts` | Message list rendering, room tabs, DM panel, presence dots, typing indicator, user-list toggle | +| **DM Search** | `src/main.ts` | Search DM history (`/api/dm/search`) and render results | +| **Auth UI** | `src/main.ts` + `index.html` | Viewer login/register/logout, session refresh, user label | +| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program | +| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading | +| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges | +| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items (gated to `ready` media), save to `/api/admin/playlist` | +| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer | +| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats | +| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons | ## Runtime View @@ -480,7 +494,7 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne ### Video Normalization & Playout -All source assets must be pre-normalized before they are accepted into production playlists. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout. +All source assets must conform to the Linear Void source profile before they are accepted into production playlists. The chat backend **auto-normalizes** non-conforming uploads on ingest (`media.NormalizeVideo`), so admins no longer need to pre-process files manually. This prevents decode resets, timestamp drift, and audio/video discontinuities during continuous concat playout. #### Required Source Profile (Linear Void v1) @@ -508,12 +522,27 @@ ffmpeg -i input.mov \ output-normalized.mp4 ``` -#### Verification Gate (ffprobe) +#### Automatic Normalization & Verification (ffprobe) -- Validate codec, fps, resolution, and keyframe cadence before upload acceptance. -- Reject non-conforming files from admin ingest with actionable error feedback. +- `media.NormalizeVideo` runs ffmpeg with the Linear Void profile (H.264/AAC, 30fps, 2s keyframes, yuv420p, 48kHz stereo), writes `*.normalized.mp4`, and re-verifies the output with ffprobe. +- Media records carry a status: `ready`, `needs_normalization`, `normalizing`, or `normalization_failed`; the admin UI shows a "Normalize" action with status polling. +- `POST /api/admin/media/{id}/normalize` re-triggers normalization manually for failed/re-queued media. - Keep verification logic aligned with runtime playout assumptions to preserve `-c copy` efficiency where possible. +### Runtime Configuration Store + +A thread-safe, JSON-persisted runtime config store lives in `backend/chat/src/config/runtime.go` and is shared with the playout service via the playlist volume (`/app/playlist/config.json`). + +- Editable via `GET/PUT /api/admin/config` from the admin UI. +- Fields: `hlsSegmentDuration` (2–15s, default 6), `hlsWindowSize` (3–100, default 24), `matomoUrl`, `matomoSiteId`. +- Playout reads the same file (`loadSharedHLSConfig`) as an env fallback for HLS segment duration/window. +- Typed setters validate values; invalid input is rejected with `400`. + +### Analytics (Matomo) + +- The viewer sends pageviews to Matomo driven by the public `GET /api/matomo` endpoint, which returns `matomoUrl` + `matomoSiteId` from the runtime config store. +- Analytics is optional per deployment: leave `matomoUrl` empty to disable tracking. + ### Monitoring & Resilience Linear Void uses a two-layer monitoring model: @@ -533,6 +562,7 @@ Linear Void uses a two-layer monitoring model: - `postgres` -> `postgres-exporter:9187` - `node` -> `node-exporter:9100` - `prometheus` -> `localhost:9090` +- TSDB retention: `--storage.tsdb.retention.time=30d` (delete metrics older than 30 days) + `--storage.tsdb.retention.size=15GB` (disk cap), set on the `prometheus` service in `docker-compose.yml` and `docker-compose.prod.yml` #### Grafana Dashboards @@ -545,7 +575,7 @@ Use the app dashboard for service-level behavior and the Prometheus overview das Rules are implemented in `monitoring/prometheus/alerts.yml` and loaded via `rule_files` from `prometheus.yml`. -Current rule set (8): +Current rule set (10): - `ChatDown` - `PlayoutDown` @@ -554,6 +584,8 @@ Current rule set (8): - `HighChatLatency` - `PrometheusWALFailing` - `HighCPU` +- `LowCacheHitRatio` +- `HighRedisMemoryUsage` - `LowDiskSpace` Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition. @@ -606,6 +638,8 @@ Linear Void currently exposes three operational API surfaces. The canonical deta - `/api/admin/session` - Operational/admin domains: - overview, analytics, users, moderation, schedule, playlist, media + - runtime config: `GET/PUT /api/admin/config` (HLS segment duration/window, Matomo) + - media normalization: `POST /api/admin/media/{id}/normalize` - Media upload domain: - `/api/admin/media/uploads` (init/resume) - `/api/admin/media/uploads/{uploadId}/chunk` @@ -615,6 +649,19 @@ Linear Void currently exposes three operational API surfaces. The canonical deta - media metadata + upload orchestration - moderation/audit and system status management +#### Viewer REST API + +- Auth/session: + - `POST /api/auth/register` + - `POST /api/auth/login` + - `POST /api/auth/logout` + - `GET /api/auth/session` +- Private messaging: + - `GET /api/dm/history` (paginated) + - `GET /api/dm/search` +- Analytics: + - `GET /api/matomo` (public; returns Matomo URL + site ID for the viewer pageview snippet) + ### Cloudflare Configuration Reference Cloudflare is part of the production control plane, not only a CDN edge. The following configuration set is required to maintain cost, latency, and reliability targets. @@ -680,6 +727,12 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng - Session cookie controls in backend include `HttpOnly`, `SameSite=Strict`, and `Secure` when request is HTTPS (derived from TLS or `X-Forwarded-Proto`). - Session expiration is enforced server-side. +#### Viewer User Accounts + +- Register/login/logout via `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`; current session via `GET /api/auth/session`. +- Passwords are hashed with bcrypt; sessions use a `linearvoid_user_session` cookie with a 30-day default TTL (`USER_SESSION_TTL_HOURS`). +- Viewer sign-in UI (username/password) lives in `frontend/index.html`. + #### Rate Limiting and Abuse Controls - Chat message rate limiting uses Redis `INCR + EXPIRE` windowing. @@ -701,6 +754,9 @@ Security is split between edge controls (Cloudflare), reverse proxy controls (ng WebSockets + Redis (presence, pub/sub, rate limits) + DB (history, audit). Public rooms + on-demand private chats. Scales independently of video. +- Private DM history (`GET /api/dm/history`, paginated) and search (`GET /api/dm/search`) are backed by persisted DMs. +- Viewer UI includes a typing indicator, user-list toggle, and DM search. + Full details, cache rule table, bitrate/storage calculations, cost model, example configs, ToS notes, and implementation phases are in [`adr/ADR-001.md`](adr/ADR-001.md). ## Architecture Decisions diff --git a/frontend/index.html b/frontend/index.html index b7e4c28..3b83ada 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,81 +1,113 @@ - - - - Linear Void - 24/7 Synchronized Channel - - - -
-
-
- -
-

Linear Void

-

24/7 Synchronized Channel • Live

-
+ + + + Linear Void - 24/7 Synchronized Channel + + + + +
+
+
+ +
+

Linear Void

+

24/7 Synchronized Channel • Live

- -
+
+ +
-
-
- -
- Loading... - +
+
+ +
+ Loading... + +
+
+ +
- -
-
+

Synced with video • Moderation active

+ + + + - + - - - + + + + + \ No newline at end of file diff --git a/frontend/public/admin/system.html b/frontend/public/admin/system.html index e4f5558..b8b6136 100644 --- a/frontend/public/admin/system.html +++ b/frontend/public/admin/system.html @@ -56,8 +56,13 @@

Runtime Configuration

+ + + + +

HLS changes take effect after restarting the playout service.

diff --git a/frontend/src/admin.ts b/frontend/src/admin.ts index 4c289fa..c5d9271 100644 --- a/frontend/src/admin.ts +++ b/frontend/src/admin.ts @@ -16,6 +16,7 @@ type MediaEntry = { runtime?: string; modifiedAt?: string; tags?: string; + path?: string; }; const bodyDataset = document.body.dataset; @@ -246,7 +247,8 @@ async function refreshPlaylist() { const content: string = payload.content || ""; const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null; if (textarea) textarea.value = content; - renderPlaylistSortable(content); + const statusMap = await fetchMediaStatusMap(); + renderPlaylistSortable(content, statusMap); await showPlaylistWarnings(content); setStatus("playlist-status", "Playlist loaded."); } catch (error) { @@ -255,7 +257,7 @@ async function refreshPlaylist() { } } -export function renderPlaylistSortable(content: string) { +export function renderPlaylistSortable(content: string, statusMap?: Map) { const list = document.getElementById("playlist-sortable"); if (!list) return; const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); @@ -265,10 +267,12 @@ export function renderPlaylistSortable(content: string) { .replace(/^file\s+/, "") .replace(/['"]/g, "") .trim(); + const badge = statusMap ? statusBadge(statusMap.get(path)) : ""; return `
  • ${i + 1} ${path} + ${badge}
  • `; }) @@ -352,6 +356,80 @@ async function showPlaylistWarnings(content: string) { } } +function statusBadge(status: string | undefined): string { + if (!status) return `unknown`; + switch (status) { + case "ready": + return `ready`; + case "needs_normalization": + return `needs normalization`; + case "normalizing": + return `normalizing`; + case "normalization_failed": + return `failed`; + default: + return `${status}`; + } +} + +async function fetchMediaStatusMap(): Promise> { + const map = new Map(); + try { + const resp = await fetch("/api/admin/media"); + if (!resp.ok) return map; + const payload = await resp.json(); + for (const f of payload.files || []) { + if (f.path) map.set(f.path, f.status || "unknown"); + } + } catch { + /* ignore */ + } + return map; +} + +async function addMediaToPlaylist(file: MediaEntry) { + if (file.status !== "ready") { + setStatus("media-status", "Only ready media can be added to the playlist.", true); + return; + } + if (!file.path) { + setStatus("media-status", "Cannot add media without a path.", true); + return; + } + setStatus("media-status", `Adding ${file.name} to playlist...`); + try { + const getResp = await fetch("/api/admin/playlist"); + if (!getResp.ok) throw new Error(await readErrorMessage(getResp)); + const payload = await getResp.json(); + const content: string = payload.content || ""; + const lines = content.split("\n").filter((l) => l.trim()); + const entry = `file '${file.path}'`; + const exists = lines.some( + (l) => + l + .replace(/^file\s+/, "") + .replace(/['"]/g, "") + .trim() === file.path, + ); + if (exists) { + setStatus("media-status", `${file.name} is already in the playlist.`, true); + return; + } + lines.push(entry); + const putResp = await fetch("/api/admin/playlist", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content: lines.join("\n") + "\n" }), + }); + if (!putResp.ok) throw new Error(await readErrorMessage(putResp)); + setStatus("media-status", `${file.name} added to playlist.`); + } catch (error) { + console.error("Failed to add media to playlist", error); + const details = error instanceof Error ? error.message : "Unknown error."; + setStatus("media-status", `Failed to add ${file.name} to playlist. (${details})`, true); + } +} + export async function savePlaylist() { if (state.playlistSaving) return; state.playlistSaving = true; @@ -556,16 +634,18 @@ function renderMediaRows(files: MediaEntry[]) { files.forEach((file) => { const tr = document.createElement("tr"); const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed"; + const canAdd = file.status === "ready"; tr.innerHTML = ` ${file.name} ${file.title || "-"} ${file.runtime || "00:00:00"} - ${file.status || "unknown"} + ${statusBadge(file.status)} ${fmtSize(file.size)} ${file.modifiedAt || "-"} ${canNormalize ? `` : ""} + ${canAdd ? `` : ""} `; @@ -573,6 +653,9 @@ function renderMediaRows(files: MediaEntry[]) { if (canNormalize) { tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file)); } + if (canAdd) { + tr.querySelector('[data-action="add"]')?.addEventListener("click", () => addMediaToPlaylist(file)); + } tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file)); tbody.appendChild(tr); }); @@ -1143,6 +1226,10 @@ async function loadConfig() { const cfg = await resp.json(); (document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax; (document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec; + (document.getElementById("config-hls-duration") as HTMLInputElement).value = cfg.hlsSegmentDuration; + (document.getElementById("config-hls-window") as HTMLInputElement).value = cfg.hlsWindowSize; + (document.getElementById("config-matomo-url") as HTMLInputElement).value = cfg.matomoUrl || ""; + (document.getElementById("config-matomo-site-id") as HTMLInputElement).value = cfg.matomoSiteId || ""; } catch { /* non-critical */ } @@ -1157,11 +1244,13 @@ async function saveConfig(e: Event) { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value), - rateLimitWindowSec: Number((document.getElementById("config-rate-window") as HTMLInputElement).value), + hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value), + hlsWindowSize: Number((document.getElementById("config-hls-window") as HTMLInputElement).value), + matomoUrl: (document.getElementById("config-matomo-url") as HTMLInputElement).value.trim(), + matomoSiteId: (document.getElementById("config-matomo-site-id") as HTMLInputElement).value.trim(), }), }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + if (!resp.ok) throw new Error(await readErrorMessage(resp)); if (status) { status.textContent = "Config saved."; status.className = "status-line"; diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 16ffa3c..c008d37 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -15,7 +15,13 @@ let hls: InstanceType | null = null; let ws: WebSocket | null = null; let currentRoom = "main"; let username = "viewer" + Math.floor(Math.random() * 1000); +let authenticated = false; +let currentDMTarget = ""; let chatVisible = true; +let usersVisible = true; +let typingUser = ""; +let lastTypingSent = 0; +let typingTimeout: number | undefined; const onlineUsers = new Set(); // Player error recovery state (STREAMING.md contract) @@ -28,7 +34,11 @@ export function resetViewerStateForTests() { ws = null; currentRoom = "main"; username = "viewer"; + authenticated = false; chatVisible = true; + usersVisible = true; + typingUser = ""; + lastTypingSent = 0; onlineUsers.clear(); playerErrorCount = 0; } @@ -81,6 +91,11 @@ function connectWebSocket(room: string) { ws.onmessage = (event) => { const msg = JSON.parse(event.data); + if (msg.type === "typing") { + showTyping(msg.username); + return; + } + hideTyping(msg.username); addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp); // Track presence from system join/leave messages @@ -183,7 +198,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default: case Hls.ErrorTypes.NETWORK_ERROR: if (playerErrorCount <= PLAYER_MAX_RETRIES) { const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1); - console.warn(`HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`); + console.warn( + `HLS network error (attempt ${playerErrorCount}/${PLAYER_MAX_RETRIES}), retrying in ${delay}ms...`, + ); showReconnecting("Reconnecting..."); setTimeout(() => { hls?.startLoad(); @@ -282,25 +299,192 @@ function sendMessage() { function switchRoom(room: string) { currentRoom = room; + const isDM = room.startsWith("dm:"); document.getElementById("room-name")!.textContent = room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; addMessage("system", `Switched to ${room} room`); + const searchRow = document.getElementById("dm-search-row"); + if (searchRow) searchRow.hidden = !isDM; connectWebSocket(room); } function togglePrivateDM() { const target = prompt("Enter username to start private chat with:"); if (target && target !== username) { + currentDMTarget = target; const dmRoom = `dm:${[username, target].sort().join("-")}`; switchRoom(dmRoom); } } +async function searchDM() { + const input = document.getElementById("dm-search-input") as HTMLInputElement; + const q = input.value.trim(); + if (!q || !currentDMTarget) return; + try { + const res = await fetch(`/api/dm/search?with=${encodeURIComponent(currentDMTarget)}&q=${encodeURIComponent(q)}`); + if (!res.ok) return; + const results = await res.json(); + addMessage("system", `Search results (${results.length}) for "${q}":`); + for (const m of results) { + addMessage(m.type || "private", m.text, m.username, m.timestamp); + } + } catch { + /* ignore */ + } +} + window.switchRoom = switchRoom; window.sendMessage = sendMessage; window.togglePrivateDM = togglePrivateDM; window.toggleChatPanel = toggleChatPanel; +function updateAuthUI() { + const label = document.getElementById("chat-user-label"); + const toggle = document.getElementById("auth-toggle-btn"); + const logoutBtn = document.getElementById("auth-logout-btn"); + const panel = document.getElementById("auth-panel"); + if (label) { + label.textContent = authenticated ? username : ""; + label.hidden = !authenticated; + } + if (toggle) toggle.hidden = authenticated; + if (logoutBtn) logoutBtn.hidden = !authenticated; + if (panel) panel.hidden = true; +} + +async function refreshUserSession() { + try { + const res = await fetch("/api/auth/session"); + if (!res.ok) return; + const data = await res.json(); + const wasAnonymous = username.startsWith("viewer"); + if (data.authenticated && data.username) { + authenticated = true; + username = data.username; + updateAuthUI(); + if (wasAnonymous) connectWebSocket(currentRoom); + } else { + authenticated = false; + updateAuthUI(); + } + } catch { + /* non-critical */ + } +} + +async function submitAuth(path: string) { + const u = (document.getElementById("auth-username") as HTMLInputElement).value.trim(); + const p = (document.getElementById("auth-password") as HTMLInputElement).value; + const status = document.getElementById("auth-status"); + if (!u || !p) { + if (status) { + status.textContent = "Username and password required."; + status.className = "status-line status-error"; + } + return; + } + try { + const res = await fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: u, password: p }), + }); + if (!res.ok) { + const text = (await res.text()).trim(); + throw new Error(text || `HTTP ${res.status}`); + } + const data = await res.json(); + username = data.username || u; + authenticated = true; + updateAuthUI(); + if (status) { + status.textContent = `Signed in as ${username}.`; + status.className = "status-line"; + } + connectWebSocket(currentRoom); + } catch (err) { + if (status) { + status.textContent = `Failed: ${err instanceof Error ? err.message : String(err)}`; + status.className = "status-line status-error"; + } + } +} + +function login() { + void submitAuth("/api/auth/login"); +} + +function register() { + void submitAuth("/api/auth/register"); +} + +async function logout() { + try { + await fetch("/api/auth/logout", { method: "POST" }); + } catch { + /* ignore */ + } + authenticated = false; + username = "viewer" + Math.floor(Math.random() * 1000); + updateAuthUI(); + connectWebSocket(currentRoom); +} + +function toggleUserList() { + const panel = document.getElementById("presence-panel"); + const btn = document.getElementById("users-toggle-btn"); + if (!panel) return; + usersVisible = !usersVisible; + panel.hidden = !usersVisible; + if (btn) btn.setAttribute("aria-expanded", String(usersVisible)); +} + +function showTyping(name: string) { + if (name === username) return; + const el = document.getElementById("typing-indicator"); + if (!el) return; + typingUser = name; + el.textContent = `${name} is typing...`; + el.hidden = false; + window.clearTimeout(typingTimeout); + typingTimeout = window.setTimeout(() => { + typingUser = ""; + el.hidden = true; + el.textContent = ""; + }, 3000); +} + +function hideTyping(name: string) { + if (name !== typingUser) return; + const el = document.getElementById("typing-indicator"); + typingUser = ""; + window.clearTimeout(typingTimeout); + if (el) { + el.hidden = true; + el.textContent = ""; + } +} + +function sendTyping() { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + const now = Date.now(); + if (now - lastTypingSent < 2000) return; + lastTypingSent = now; + ws.send(JSON.stringify({ room: currentRoom, username, text: "", type: "typing" })); +} + document.addEventListener("DOMContentLoaded", () => { void initPlayerAndChat(); + void refreshUserSession(); + document.getElementById("auth-toggle-btn")?.addEventListener("click", () => { + const panel = document.getElementById("auth-panel"); + if (panel) panel.hidden = !panel.hidden; + }); + document.getElementById("auth-login-btn")?.addEventListener("click", login); + document.getElementById("auth-register-btn")?.addEventListener("click", register); + document.getElementById("auth-logout-btn")?.addEventListener("click", logout); + document.getElementById("dm-search-btn")?.addEventListener("click", searchDM); + document.getElementById("users-toggle-btn")?.addEventListener("click", toggleUserList); + document.getElementById("message-input")?.addEventListener("input", sendTyping); }); diff --git a/frontend/src/style.css b/frontend/src/style.css index 6412c99..4dd5db3 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -253,6 +253,79 @@ select { line-height: 1.5; } +.badge { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: 999px; + font-size: 0.72rem; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} + +.badge-ok { + color: var(--ok); + background: rgba(4, 101, 13, 0.12); +} + +.badge-warn { + color: var(--warn); + background: rgba(212, 150, 36, 0.12); +} + +.badge-info { + color: var(--brand); + background: rgba(88, 119, 146, 0.14); +} + +.badge-danger { + color: var(--danger); + background: rgba(196, 74, 94, 0.12); +} + +.badge-muted { + color: var(--ink-soft); + background: var(--bg-2); +} + +.chat-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.chat-user-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--ink-soft); + max-width: 10rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.auth-panel { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + border-top: 1px solid var(--bg-2); +} + +.dm-search-row { + display: flex; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--bg-2); +} + +.typing-indicator { + font-size: 0.78rem; + font-style: italic; + color: var(--ink-soft); + padding: 0.25rem 0.75rem 0; +} + .btn { border: 1px solid transparent; border-radius: var(--radius-1); @@ -1085,7 +1158,9 @@ select { } @keyframes lv-spin { - to { transform: rotate(360deg); } + to { + transform: rotate(360deg); + } } #reconnecting-text {