feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention

This commit is contained in:
zwitschi committed 2026-08-13 13:12:35 +02:00
1 parent 06ab5b4655
commit 121fdbacdc
27 files changed
+1766 -86

No files matched your search

+5
View File
@@ -57,6 +57,11 @@ PLAYLIST_PATH=/app/playlist/playlist.txt
HLS_SEGMENT_DURATION=6 HLS_SEGMENT_DURATION=6
HLS_WINDOW_SIZE=24 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 # Logging / Monitoring
LOG_LEVEL=info LOG_LEVEL=info
PROMETHEUS_PORT=9090 PROMETHEUS_PORT=9090
+1 -1
View File
@@ -1,6 +1,6 @@
# BACKLOG # 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 ## Cloudflare Configuration
+62 -1
View File
@@ -1,6 +1,6 @@
# DONE # 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 ## 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) - 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` - Add metrics `linearvoid_playout_loops_total` (clean completions) and `linearvoid_playout_run_duration_seconds`
- Tests: `TestPlaylistFingerprint`, `TestHLSNextStartNumber` - 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
+2 -29
View File
@@ -4,36 +4,9 @@
## Enhancements ## 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` - [ ] 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.
- [ ] 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
## Setup phases remaining ## Setup phases remaining
+1 -1
View File
@@ -6,6 +6,7 @@ require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0 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/driver/postgres v1.5.11
gorm.io/gorm v1.25.12 gorm.io/gorm v1.25.12
) )
@@ -24,7 +25,6 @@ require (
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-isatty v0.0.17 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/text v0.24.0 // indirect
+48
View File
@@ -138,3 +138,51 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
} }
} }
} }
// 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)
}
+29
View File
@@ -246,3 +246,32 @@ func TestRequireAdmin(t *testing.T) {
t.Errorf("status = %d, want 401", w3.Code) 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")
}
}
+14
View File
@@ -16,6 +16,7 @@ const (
DefaultRateLimitMax = 10 DefaultRateLimitMax = 10
DefaultRateLimitWindow = 10 * time.Second DefaultRateLimitWindow = 10 * time.Second
DefaultAdminSessionTTL = 12 * time.Hour DefaultAdminSessionTTL = 12 * time.Hour
DefaultUserSessionTTL = 30 * 24 * time.Hour
DefaultChatPort = "3000" DefaultChatPort = "3000"
) )
@@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration {
return time.Duration(hours) * time.Hour 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. // RateLimitMax returns the chat rate limit max messages per window.
func RateLimitMax() int { func RateLimitMax() int {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX")) raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
+221
View File
@@ -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(),
}
}
+181
View File
@@ -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)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) {
return nil, driver, err 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)) log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
return &DBClient{DB: gormDB}, driver, nil return &DBClient{DB: gormDB}, driver, nil
+8
View File
@@ -39,6 +39,14 @@ type Media struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"` 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) // ScheduleOverride represents a scheduled playlist override (ADR-004)
type ScheduleOverride struct { type ScheduleOverride struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
+312 -11
View File
@@ -13,6 +13,7 @@ import (
"strings" "strings"
"time" "time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/auth" "chat/src/auth"
@@ -197,6 +198,7 @@ type Dependencies struct {
Redis db.RedisClient Redis db.RedisClient
Hub *ws.Hub Hub *ws.Hub
SessionStore *auth.SessionStore SessionStore *auth.SessionStore
UserSessions *auth.UserSessionStore
DBDriver string 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. // HandleAdminOverview returns the admin dashboard overview.
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc { func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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. // HandleAdminConfig returns/updates runtime configuration.
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc { func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{ json.NewEncoder(w).Encode(adminConfigResponse(deps))
"rateLimitMax": config.RateLimitMax(),
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
"databaseDriver": deps.DBDriver,
"redisEnabled": deps.Redis != nil,
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
})
case http.MethodPut: 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 { if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest) http.Error(w, "invalid body", http.StatusBadRequest)
return return
} }
// Note: rate limit config is read-only at runtime in this refactored version. if payload.MatomoURL != nil {
// To change, restart with env vars. 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") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) json.NewEncoder(w).Encode(adminConfigResponse(deps))
default: default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
} }
+287 -1
View File
@@ -15,6 +15,7 @@ import (
"time" "time"
"github.com/glebarez/sqlite" "github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm" "gorm.io/gorm"
"chat/src/auth" "chat/src/auth"
@@ -102,7 +103,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
if err != nil { if err != nil {
t.Fatalf("failed to open test DB: %v", err) 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() mr := newMockRedis()
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername()) 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, Redis: mr,
Hub: hub, Hub: hub,
SessionStore: sessionStore, SessionStore: sessionStore,
UserSessions: auth.NewUserSessionStore(),
DBDriver: "sqlite", DBDriver: "sqlite",
} }
return deps, gormDB, sessionStore return deps, gormDB, sessionStore
@@ -476,6 +478,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
func TestHandleAdminConfig_Put(t *testing.T) { func TestHandleAdminConfig_Put(t *testing.T) {
deps, _, _ := setupTestDeps(t) deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps) handler := HandleAdminConfig(deps)
body := `{"rateLimitMax":20}` 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) { func TestHandleAdminRestart(t *testing.T) {
deps, _, _ := setupTestDeps(t) deps, _, _ := setupTestDeps(t)
+14
View File
@@ -28,6 +28,11 @@ func main() {
rateLimitWindow := config.RateLimitWindow() rateLimitWindow := config.RateLimitWindow()
log.Printf("Rate limit: %d messages per %v", rateLimitMax, 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 // Initialize Redis
redisWrapper := db.InitRedis(config.RedisAddress()) redisWrapper := db.InitRedis(config.RedisAddress())
@@ -39,6 +44,7 @@ func main() {
// Initialize session store // Initialize session store
sessionStore := auth.NewSessionStore() sessionStore := auth.NewSessionStore()
userSessions := auth.NewUserSessionStore()
// Initialize WebSocket hub // Initialize WebSocket hub
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername()) hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
@@ -54,6 +60,7 @@ func main() {
Redis: redisWrapper, Redis: redisWrapper,
Hub: hub, Hub: hub,
SessionStore: sessionStore, SessionStore: sessionStore,
UserSessions: userSessions,
DBDriver: dbDriver, DBDriver: dbDriver,
} }
@@ -62,6 +69,12 @@ func main() {
// Register routes // Register routes
http.HandleFunc("/ws", hub.HandleConnection) 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/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps))) http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(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", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(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/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", 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))))
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps)))) http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
+17
View File
@@ -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. // PushChatHistory sends the last N messages for a room via WebSocket.
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) { func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
if h.DB == nil || room == "" { if h.DB == nil || room == "" {
@@ -224,6 +235,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
continue continue
} }
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
if msg.Type == "typing" {
h.BroadcastTyping(msg.Username, msg.Room)
continue
}
// Handle private DMs // Handle private DMs
if strings.HasPrefix(msg.Room, "dm:") { if strings.HasPrefix(msg.Room, "dm:") {
h.handlePrivateDM(msg) h.handlePrivateDM(msg)
+32
View File
@@ -612,6 +612,9 @@ func main() {
return return
} }
// Load shared HLS runtime config (written by the chat admin) before starting.
loadSharedHLSConfig()
fmt.Println("=== Linear Void Playout Service Starting ===") fmt.Println("=== Linear Void Playout Service Starting ===")
fmt.Println("Playlist:", playlistPath) fmt.Println("Playlist:", playlistPath)
fmt.Println("R2 Bucket:", bucket) fmt.Println("R2 Bucket:", bucket)
@@ -997,3 +1000,32 @@ func runValidateOnly(playlistPath string) {
} }
fmt.Println("Playlist valid.") 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)
}
+44
View File
@@ -734,3 +734,47 @@ func TestHLSNextStartNumber(t *testing.T) {
} }
} }
} }
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)
}
}
+2
View File
@@ -168,6 +168,8 @@ services:
command: command:
- "--config.file=/etc/prometheus/prometheus.yml" - "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus" - "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--storage.tsdb.retention.size=15GB"
- "--web.console.libraries=/etc/prometheus/console_libraries" - "--web.console.libraries=/etc/prometheus/console_libraries"
- "--web.console.templates=/etc/prometheus/consoles" - "--web.console.templates=/etc/prometheus/consoles"
- "--web.enable-lifecycle" - "--web.enable-lifecycle"
+2
View File
@@ -162,6 +162,8 @@ services:
command: command:
- "--config.file=/etc/prometheus/prometheus.yml" - "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus" - "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--storage.tsdb.retention.size=15GB"
- "--web.console.libraries=/etc/prometheus/console_libraries" - "--web.console.libraries=/etc/prometheus/console_libraries"
- "--web.console.templates=/etc/prometheus/consoles" - "--web.console.templates=/etc/prometheus/consoles"
- "--web.enable-lifecycle" - "--web.enable-lifecycle"
+1
View File
@@ -95,3 +95,4 @@ See detailed scripts for querying Prometheus scrape targets:
- Keep alert thresholds tied to actual user impact. - Keep alert thresholds tied to actual user impact.
- Review dashboards after each release that changes endpoints or metrics names. - Review dashboards after each release that changes endpoints or metrics names.
- Periodically test alert routing and incident response flow. - 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.
+71 -15
View File
@@ -39,7 +39,9 @@
- [Video Normalization \& Playout](#video-normalization--playout) - [Video Normalization \& Playout](#video-normalization--playout)
- [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1) - [Required Source Profile (Linear Void v1)](#required-source-profile-linear-void-v1)
- [Reference Normalization Command](#reference-normalization-command) - [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) - [Monitoring \& Resilience](#monitoring--resilience)
- [Prometheus Baseline](#prometheus-baseline) - [Prometheus Baseline](#prometheus-baseline)
- [Grafana Dashboards](#grafana-dashboards) - [Grafana Dashboards](#grafana-dashboards)
@@ -48,6 +50,7 @@
- [Chat WebSocket API](#chat-websocket-api) - [Chat WebSocket API](#chat-websocket-api)
- [Playout and Delivery REST API](#playout-and-delivery-rest-api) - [Playout and Delivery REST API](#playout-and-delivery-rest-api)
- [Admin REST API](#admin-rest-api) - [Admin REST API](#admin-rest-api)
- [Viewer REST API](#viewer-rest-api)
- [Cloudflare Configuration Reference](#cloudflare-configuration-reference) - [Cloudflare Configuration Reference](#cloudflare-configuration-reference)
- [DNS and Zone Routing](#dns-and-zone-routing) - [DNS and Zone Routing](#dns-and-zone-routing)
- [Cache Rules Baseline](#cache-rules-baseline) - [Cache Rules Baseline](#cache-rules-baseline)
@@ -58,6 +61,7 @@
- [Transport and Edge Protection](#transport-and-edge-protection) - [Transport and Edge Protection](#transport-and-edge-protection)
- [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx) - [Reverse Proxy Enforcement (nginx)](#reverse-proxy-enforcement-nginx)
- [Admin Authentication and Session Security](#admin-authentication-and-session-security) - [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) - [Rate Limiting and Abuse Controls](#rate-limiting-and-abuse-controls)
- [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior) - [CORS and Cross-Origin Behavior](#cors-and-cross-origin-behavior)
- [Moderation and Privacy](#moderation-and-privacy) - [Moderation and Privacy](#moderation-and-privacy)
@@ -305,7 +309,10 @@ graph TB
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths - **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 - **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 - **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`) #### Chat Service (`backend/chat/src/main.go`)
@@ -335,7 +342,7 @@ Module-level breakdown per service.
#### Chat Service Modules #### Chat Service Modules
| Module | File/Lines | Responsibility | | Module | File/Lines | Responsibility |
| ---------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | ---------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| **Connection Manager** | `main.go` (hub, clients map) | Track active WebSocket connections per room, fan-out messages | | **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 | | **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 | | **Room Registry** | `main.go` (rooms map, room broadcasts) | Map room name → client set, handle join/leave/close |
@@ -343,34 +350,41 @@ Module-level breakdown per service.
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events | | **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 | | **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 | | **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 | | **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 | | **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
#### Playout Service Modules #### Playout Service Modules
| Module | File/Lines | Responsibility | | Module | File/Lines | Responsibility |
| -------------------- | ---------------------------------------------- | ------------------------------------------------------------------ | | -------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| **Playlist Parser** | `playlist.go` (parsePlaylist) | Read `playlist.txt`, skip comments/blank lines, resolve file paths | | **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 | | **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 | | **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 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 | | **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) | | **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total |
#### Frontend Modules #### Frontend Modules
| Module | File | Responsibility | | Module | File | Responsibility |
| ----------------------- | ----------------------------------------------- | ----------------------------------------------------------------- | | ----------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| **HLS Player** | `src/main.ts` | hls.js initialization, attach to video element, error recovery | | **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 | | **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 | | **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 | | **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading | | **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback | | **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges |
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` | | **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 | | **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 | | **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 | | **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons |
## Runtime View ## Runtime View
@@ -480,7 +494,7 @@ Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = ne
### Video Normalization & Playout ### 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) #### Required Source Profile (Linear Void v1)
@@ -508,12 +522,27 @@ ffmpeg -i input.mov \
output-normalized.mp4 output-normalized.mp4
``` ```
#### Verification Gate (ffprobe) #### Automatic Normalization & Verification (ffprobe)
- Validate codec, fps, resolution, and keyframe cadence before upload acceptance. - `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.
- Reject non-conforming files from admin ingest with actionable error feedback. - 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. - 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 ### Monitoring & Resilience
Linear Void uses a two-layer monitoring model: Linear Void uses a two-layer monitoring model:
@@ -533,6 +562,7 @@ Linear Void uses a two-layer monitoring model:
- `postgres` -> `postgres-exporter:9187` - `postgres` -> `postgres-exporter:9187`
- `node` -> `node-exporter:9100` - `node` -> `node-exporter:9100`
- `prometheus` -> `localhost:9090` - `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 #### 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`. 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` - `ChatDown`
- `PlayoutDown` - `PlayoutDown`
@@ -554,6 +584,8 @@ Current rule set (8):
- `HighChatLatency` - `HighChatLatency`
- `PrometheusWALFailing` - `PrometheusWALFailing`
- `HighCPU` - `HighCPU`
- `LowCacheHitRatio`
- `HighRedisMemoryUsage`
- `LowDiskSpace` - `LowDiskSpace`
Route warning/critical severities to the operations channel and keep runbook links attached to each alert definition. 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` - `/api/admin/session`
- Operational/admin domains: - Operational/admin domains:
- overview, analytics, users, moderation, schedule, playlist, media - 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: - Media upload domain:
- `/api/admin/media/uploads` (init/resume) - `/api/admin/media/uploads` (init/resume)
- `/api/admin/media/uploads/{uploadId}/chunk` - `/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 - media metadata + upload orchestration
- moderation/audit and system status management - 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 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. 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 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. - 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 #### Rate Limiting and Abuse Controls
- Chat message rate limiting uses Redis `INCR + EXPIRE` windowing. - 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. 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). 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 ## Architecture Decisions
+50 -18
View File
@@ -1,5 +1,6 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -17,14 +18,7 @@
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p> <p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
</div> </div>
</div> </div>
<button <button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
id="chat-toggle"
type="button"
class="btn btn-secondary chat-toggle-btn"
onclick="toggleChatPanel()"
aria-controls="chat-panel"
aria-expanded="true"
>
Hide Chat Hide Chat
</button> </button>
</header> </header>
@@ -44,25 +38,35 @@
<span class="chat-live-marker" aria-hidden="true">●</span> <span class="chat-live-marker" aria-hidden="true">●</span>
<span id="room-name">Main Channel</span> <span id="room-name">Main Channel</span>
</div> </div>
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button> <div class="chat-header-actions">
<span id="chat-user-label" class="chat-user-label" hidden></span>
<button id="auth-toggle-btn" type="button" class="btn btn-secondary">Sign in</button>
<button id="auth-logout-btn" type="button" class="btn btn-secondary" hidden>Sign out</button> <button id="users-toggle-btn" type="button" class="btn btn-secondary" aria-controls="presence-panel" aria-expanded="true">Users</button> <button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
</div>
</header> </header>
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section> <div id="auth-panel" class="auth-panel" hidden>
<input id="auth-username" type="text" class="input" placeholder="Username" autocomplete="username" />
<input id="auth-password" type="password" class="input" placeholder="Password" autocomplete="current-password" />
<div class="button-row">
<button id="auth-login-btn" type="button" class="btn btn-primary">Log in</button>
<button id="auth-register-btn" type="button" class="btn btn-secondary">Register</button>
</div>
<p id="auth-status" class="status-line" aria-live="polite"></p>
</div>
<div id="dm-search-row" class="dm-search-row" hidden>
<input id="dm-search-input" type="text" class="input" placeholder="Search this conversation..." />
<button id="dm-search-btn" type="button" class="btn btn-secondary">Search</button>
</div>
<section id="presence-panel" class="presence-panel" aria-label="Online users">
<div id="presence-list" class="presence-list"></div> <div id="presence-list" class="presence-list"></div>
</section> </section>
<div class="chat-input-area"> <div class="chat-input-area">
<div class="chat-input-row"> <div class="chat-input-row">
<label for="message-input" class="sr-only">Message input</label> <label for="message-input" class="sr-only">Message input</label>
<input <input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
id="message-input"
type="text"
placeholder="Type a message..."
class="input"
onkeydown="if (event.key === 'Enter') sendMessage();"
/>
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button> <button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
</div> </div>
<p class="chat-note">Synced with video • Moderation active</p> <p class="chat-note">Synced with video • Moderation active</p>
@@ -76,6 +80,34 @@
<span id="reconnecting-text">Reconnecting...</span> <span id="reconnecting-text">Reconnecting...</span>
</div> </div>
<script>
(function () {
// Load Matomo analytics when configured via the admin runtime config.
fetch("/api/matomo")
.then(function (r) {
return r.ok ? r.json() : null;
})
.then(function (cfg) {
if (!cfg || !cfg.matomoUrl || !cfg.matomoSiteId) return;
var _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
var base = String(cfg.matomoUrl).replace(/\/+$/, "");
_paq.push(["setTrackerUrl", base + "/matomo.php"]);
_paq.push(["setSiteId", String(cfg.matomoSiteId)]);
var d = document;
var g = d.createElement("script");
var s = d.getElementsByTagName("script")[0];
g.async = true;
g.src = base + "/matomo.js";
s.parentNode.insertBefore(g, s);
})
.catch(function () {
/* analytics is optional */
});
})();
</script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+5
View File
@@ -56,8 +56,13 @@
<div class="panel-card"> <div class="panel-card">
<h2>Runtime Configuration</h2> <h2>Runtime Configuration</h2>
<form id="config-form" class="form-stack" style="max-width: 400px"> <form id="config-form" class="form-stack" style="max-width: 400px">
<label>HLS Segment Duration (seconds) <input id="config-hls-duration" type="number" min="2" max="15" class="text-input" /></label>
<label>HLS Window Size (segments) <input id="config-hls-window" type="number" min="3" max="100" class="text-input" /></label>
<label>Matomo URL <input id="config-matomo-url" type="url" class="text-input" placeholder="https://..." /></label>
<label>Matomo Site ID <input id="config-matomo-site-id" type="text" class="text-input" /></label>
<label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label> <label>Rate Limit Max <input id="config-rate-max" type="number" class="text-input" /></label>
<label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label> <label>Rate Limit Window (seconds) <input id="config-rate-window" type="number" class="text-input" /></label>
<p class="small-muted">HLS changes take effect after restarting the playout service.</p>
<button type="submit" class="btn btn-primary">Save Config</button> <button type="submit" class="btn btn-primary">Save Config</button>
</form> </form>
<p id="config-status" class="status-line" aria-live="polite"></p> <p id="config-status" class="status-line" aria-live="polite"></p>
+95 -6
View File
@@ -16,6 +16,7 @@ type MediaEntry = {
runtime?: string; runtime?: string;
modifiedAt?: string; modifiedAt?: string;
tags?: string; tags?: string;
path?: string;
}; };
const bodyDataset = document.body.dataset; const bodyDataset = document.body.dataset;
@@ -246,7 +247,8 @@ async function refreshPlaylist() {
const content: string = payload.content || ""; const content: string = payload.content || "";
const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null; const textarea = document.getElementById("playlist-content") as HTMLTextAreaElement | null;
if (textarea) textarea.value = content; if (textarea) textarea.value = content;
renderPlaylistSortable(content); const statusMap = await fetchMediaStatusMap();
renderPlaylistSortable(content, statusMap);
await showPlaylistWarnings(content); await showPlaylistWarnings(content);
setStatus("playlist-status", "Playlist loaded."); setStatus("playlist-status", "Playlist loaded.");
} catch (error) { } catch (error) {
@@ -255,7 +257,7 @@ async function refreshPlaylist() {
} }
} }
export function renderPlaylistSortable(content: string) { export function renderPlaylistSortable(content: string, statusMap?: Map<string, string>) {
const list = document.getElementById("playlist-sortable"); const list = document.getElementById("playlist-sortable");
if (!list) return; if (!list) return;
const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#")); const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
@@ -265,10 +267,12 @@ export function renderPlaylistSortable(content: string) {
.replace(/^file\s+/, "") .replace(/^file\s+/, "")
.replace(/['"]/g, "") .replace(/['"]/g, "")
.trim(); .trim();
const badge = statusMap ? statusBadge(statusMap.get(path)) : "";
return `<li class="sortable-item" draggable="true" data-index="${i}"> return `<li class="sortable-item" draggable="true" data-index="${i}">
<span class="drag-handle" aria-hidden="true">⋮⋮</span> <span class="drag-handle" aria-hidden="true">⋮⋮</span>
<span class="sortable-index">${i + 1}</span> <span class="sortable-index">${i + 1}</span>
<span class="sortable-path">${path}</span> <span class="sortable-path">${path}</span>
${badge}
<button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button> <button type="button" class="btn btn-secondary btn-sm" data-remove="${i}">✕</button>
</li>`; </li>`;
}) })
@@ -352,6 +356,80 @@ async function showPlaylistWarnings(content: string) {
} }
} }
function statusBadge(status: string | undefined): string {
if (!status) return `<span class="badge badge-muted">unknown</span>`;
switch (status) {
case "ready":
return `<span class="badge badge-ok">ready</span>`;
case "needs_normalization":
return `<span class="badge badge-warn">needs normalization</span>`;
case "normalizing":
return `<span class="badge badge-info">normalizing</span>`;
case "normalization_failed":
return `<span class="badge badge-danger">failed</span>`;
default:
return `<span class="badge badge-muted">${status}</span>`;
}
}
async function fetchMediaStatusMap(): Promise<Map<string, string>> {
const map = new Map<string, string>();
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() { export async function savePlaylist() {
if (state.playlistSaving) return; if (state.playlistSaving) return;
state.playlistSaving = true; state.playlistSaving = true;
@@ -556,16 +634,18 @@ function renderMediaRows(files: MediaEntry[]) {
files.forEach((file) => { files.forEach((file) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed"; const canNormalize = file.status === "needs_normalization" || file.status === "normalization_failed";
const canAdd = file.status === "ready";
tr.innerHTML = ` tr.innerHTML = `
<td>${file.name}</td> <td>${file.name}</td>
<td>${file.title || "-"}</td> <td>${file.title || "-"}</td>
<td>${file.runtime || "00:00:00"}</td> <td>${file.runtime || "00:00:00"}</td>
<td>${file.status || "unknown"}</td> <td>${statusBadge(file.status)}</td>
<td>${fmtSize(file.size)}</td> <td>${fmtSize(file.size)}</td>
<td>${file.modifiedAt || "-"}</td> <td>${file.modifiedAt || "-"}</td>
<td class="action-cell"> <td class="action-cell">
<button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button> <button type="button" class="btn btn-secondary btn-small" data-action="edit" data-id="${file.id || ""}">Edit</button>
${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""} ${canNormalize ? `<button type="button" class="btn btn-secondary btn-small" data-action="normalize" data-id="${file.id || ""}">Normalize</button>` : ""}
${canAdd ? `<button type="button" class="btn btn-secondary btn-small" data-action="add" data-id="${file.id || ""}">Add to playlist</button>` : ""}
<button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button> <button type="button" class="btn btn-danger btn-small" data-action="delete" data-id="${file.id || ""}">Delete</button>
</td> </td>
`; `;
@@ -573,6 +653,9 @@ function renderMediaRows(files: MediaEntry[]) {
if (canNormalize) { if (canNormalize) {
tr.querySelector('[data-action="normalize"]')?.addEventListener("click", () => normalizeMedia(file)); 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)); tr.querySelector('[data-action="delete"]')?.addEventListener("click", () => deleteMedia(file));
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
@@ -1143,6 +1226,10 @@ async function loadConfig() {
const cfg = await resp.json(); const cfg = await resp.json();
(document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax; (document.getElementById("config-rate-max") as HTMLInputElement).value = cfg.rateLimitMax;
(document.getElementById("config-rate-window") as HTMLInputElement).value = cfg.rateLimitWindowSec; (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 { } catch {
/* non-critical */ /* non-critical */
} }
@@ -1157,11 +1244,13 @@ async function saveConfig(e: Event) {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
rateLimitMax: Number((document.getElementById("config-rate-max") as HTMLInputElement).value), hlsSegmentDuration: Number((document.getElementById("config-hls-duration") as HTMLInputElement).value),
rateLimitWindowSec: Number((document.getElementById("config-rate-window") 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) { if (status) {
status.textContent = "Config saved."; status.textContent = "Config saved.";
status.className = "status-line"; status.className = "status-line";
+185 -1
View File
@@ -15,7 +15,13 @@ let hls: InstanceType<HlsClass> | null = null;
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let currentRoom = "main"; let currentRoom = "main";
let username = "viewer" + Math.floor(Math.random() * 1000); let username = "viewer" + Math.floor(Math.random() * 1000);
let authenticated = false;
let currentDMTarget = "";
let chatVisible = true; let chatVisible = true;
let usersVisible = true;
let typingUser = "";
let lastTypingSent = 0;
let typingTimeout: number | undefined;
const onlineUsers = new Set<string>(); const onlineUsers = new Set<string>();
// Player error recovery state (STREAMING.md contract) // Player error recovery state (STREAMING.md contract)
@@ -28,7 +34,11 @@ export function resetViewerStateForTests() {
ws = null; ws = null;
currentRoom = "main"; currentRoom = "main";
username = "viewer"; username = "viewer";
authenticated = false;
chatVisible = true; chatVisible = true;
usersVisible = true;
typingUser = "";
lastTypingSent = 0;
onlineUsers.clear(); onlineUsers.clear();
playerErrorCount = 0; playerErrorCount = 0;
} }
@@ -81,6 +91,11 @@ function connectWebSocket(room: string) {
ws.onmessage = (event) => { ws.onmessage = (event) => {
const msg = JSON.parse(event.data); 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); addMessage(msg.type || "public", msg.text, msg.username, msg.timestamp);
// Track presence from system join/leave messages // Track presence from system join/leave messages
@@ -183,7 +198,9 @@ export async function initPlayerAndChat(loadHlsModule: () => Promise<{ default:
case Hls.ErrorTypes.NETWORK_ERROR: case Hls.ErrorTypes.NETWORK_ERROR:
if (playerErrorCount <= PLAYER_MAX_RETRIES) { if (playerErrorCount <= PLAYER_MAX_RETRIES) {
const delay = PLAYER_BASE_DELAY_MS * Math.pow(2, playerErrorCount - 1); 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..."); showReconnecting("Reconnecting...");
setTimeout(() => { setTimeout(() => {
hls?.startLoad(); hls?.startLoad();
@@ -282,25 +299,192 @@ function sendMessage() {
function switchRoom(room: string) { function switchRoom(room: string) {
currentRoom = room; currentRoom = room;
const isDM = room.startsWith("dm:");
document.getElementById("room-name")!.textContent = document.getElementById("room-name")!.textContent =
room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room"; room === "main" ? "Main Channel" : room.charAt(0).toUpperCase() + room.slice(1) + " Room";
addMessage("system", `Switched to ${room} room`); addMessage("system", `Switched to ${room} room`);
const searchRow = document.getElementById("dm-search-row");
if (searchRow) searchRow.hidden = !isDM;
connectWebSocket(room); connectWebSocket(room);
} }
function togglePrivateDM() { function togglePrivateDM() {
const target = prompt("Enter username to start private chat with:"); const target = prompt("Enter username to start private chat with:");
if (target && target !== username) { if (target && target !== username) {
currentDMTarget = target;
const dmRoom = `dm:${[username, target].sort().join("-")}`; const dmRoom = `dm:${[username, target].sort().join("-")}`;
switchRoom(dmRoom); 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.switchRoom = switchRoom;
window.sendMessage = sendMessage; window.sendMessage = sendMessage;
window.togglePrivateDM = togglePrivateDM; window.togglePrivateDM = togglePrivateDM;
window.toggleChatPanel = toggleChatPanel; 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", () => { document.addEventListener("DOMContentLoaded", () => {
void initPlayerAndChat(); 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);
}); });
+76 -1
View File
@@ -253,6 +253,79 @@ select {
line-height: 1.5; 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 { .btn {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-1); border-radius: var(--radius-1);
@@ -1085,7 +1158,9 @@ select {
} }
@keyframes lv-spin { @keyframes lv-spin {
to { transform: rotate(360deg); } to {
transform: rotate(360deg);
}
} }
#reconnecting-text { #reconnecting-text {