feat: runtime config, Matomo analytics, chat accounts, DM history/search, and Prometheus retention
This commit is contained in:
1 parent
06ab5b4655
commit
121fdbacdc
27 files changed
+1841
-161
No files matched your search
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -137,4 +137,52 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
|
|||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserSessionStore maps user session tokens to usernames with expiry.
|
||||||
|
type UserSessionStore struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]userSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type userSession struct {
|
||||||
|
Username string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserSessionStore creates a new user session store.
|
||||||
|
func NewUserSessionStore() *UserSessionStore {
|
||||||
|
return &UserSessionStore{sessions: make(map[string]userSession)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add stores a user session token with username and expiry.
|
||||||
|
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Username returns the username for a valid, unexpired token.
|
||||||
|
func (s *UserSessionStore) Username(token string) (string, bool) {
|
||||||
|
if token == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
sess, ok := s.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if time.Now().After(sess.ExpiresAt) {
|
||||||
|
delete(s.sessions, token)
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return sess.Username, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove deletes a user session token.
|
||||||
|
func (s *UserSessionStore) Remove(token string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
delete(s.sessions, token)
|
||||||
}
|
}
|
||||||
@@ -245,4 +245,33 @@ func TestRequireAdmin(t *testing.T) {
|
|||||||
if w3.Code != http.StatusUnauthorized {
|
if w3.Code != http.StatusUnauthorized {
|
||||||
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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))))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -996,4 +999,33 @@ func runValidateOnly(playlistPath string) {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -733,4 +733,48 @@ func TestHLSNextStartNumber(t *testing.T) {
|
|||||||
t.Errorf("seg-%06d should be kept: %v", i, err)
|
t.Errorf("seg-%06d should be kept: %v", i, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
cfgPath := filepath.Join(tmpDir, "config.json")
|
||||||
|
if err := os.WriteFile(cfgPath, []byte(`{"hls_segment_duration":"4","hls_window_size":"12"}`), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", cfgPath)
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig()
|
||||||
|
|
||||||
|
if hlsSegmentDuration != "4" {
|
||||||
|
t.Errorf("hlsSegmentDuration = %q, want 4", hlsSegmentDuration)
|
||||||
|
}
|
||||||
|
if hlsWindowSize != "12" {
|
||||||
|
t.Errorf("hlsWindowSize = %q, want 12", hlsWindowSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSharedHLSConfig_MissingFile(t *testing.T) {
|
||||||
|
prevDur := hlsSegmentDuration
|
||||||
|
prevWin := hlsWindowSize
|
||||||
|
defer func() {
|
||||||
|
hlsSegmentDuration = prevDur
|
||||||
|
hlsWindowSize = prevWin
|
||||||
|
}()
|
||||||
|
|
||||||
|
os.Setenv("PLAYOUT_CONFIG_PATH", filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
defer os.Unsetenv("PLAYOUT_CONFIG_PATH")
|
||||||
|
|
||||||
|
loadSharedHLSConfig() // should be a no-op without erroring
|
||||||
|
|
||||||
|
if hlsSegmentDuration != prevDur {
|
||||||
|
t.Errorf("hlsSegmentDuration changed to %q, want unchanged %q", hlsSegmentDuration, prevDur)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
+94
-38
@@ -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`)
|
||||||
|
|
||||||
@@ -334,43 +341,50 @@ 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 |
|
||||||
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
|
| **DM Router** | `main.go` (handleDirectMessage) | Resolve target user in clients map, deliver private message |
|
||||||
| **Persistence Layer** | `main.go` (GORM models: Message, DirectMessage, ModerationLog) | Store/retrieve chat messages, DMs, moderation events |
|
| **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 |
|
||||||
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
| **User Auth Handler** | `handlers/handlers.go` (HandleUserRegister/Login/Session/Logout) | Viewer register/login/logout/session (bcrypt, user session cookie) |
|
||||||
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
| **DM History/Search** | `handlers/handlers.go` (HandleUserDMHistory, HandleUserDMSearch) | Paginated DM history + search for authenticated users |
|
||||||
|
| **Runtime Config** | `config/runtime.go` (GetRuntime, SetRuntime, typed setters) | Thread-safe JSON runtime config (HLS duration/window, Matomo) |
|
||||||
|
| **Admin API Router** | `main.go` (handleAdmin\*) | Upload, playlist CRUD, schedule overrides, moderation actions, analytics, system summary |
|
||||||
|
| **Metrics Exporter** | `main.go` (prometheus.MustRegister) | Expose connected_clients, messages_total, dms_total, moderation_actions, redis_up |
|
||||||
|
|
||||||
#### Playout Service Modules
|
#### 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 |
|
||||||
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
| **Loop Controller** | `playlist.go` (playlistFingerprint, hlsNextStartNumber) | Detect playlist changes (SHA-256), continue segment numbering on clean loops |
|
||||||
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
| **Slate Controller** | `playlist.go` (activateSlate, deactivateSlate) | Switch output to slate loop on excessive failures |
|
||||||
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
| **R2 Sync** | `playlist.go` (syncToR2) | Upload HLS segments + manifest to R2 bucket |
|
||||||
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate) |
|
| **R2 Cleanup** | `playlist.go` (cleanupOldSegments) | Delete stale `hls/r-*/*` segments older than the retention window |
|
||||||
|
| **Health Endpoint** | `playlist.go` (healthHandler) | `/health` → JSON with mode, segment count, uptime |
|
||||||
|
| **Metrics Exporter** | `playlist.go` (prometheus) | Expose restarts_total, mode (live/slate), loops_total, run_duration_seconds, r2_cleanup_objects_total |
|
||||||
|
|
||||||
#### Frontend Modules
|
#### 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 |
|
||||||
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
| **DM Search** | `src/main.ts` | Search DM history (`/api/dm/search`) and render results |
|
||||||
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
| **Auth UI** | `src/main.ts` + `index.html` | Viewer login/register/logout, session refresh, user label |
|
||||||
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, ffprobe validation feedback |
|
| **Schedule Overlay** | `src/main.ts` | Poll `/api/schedule`, render current/next program |
|
||||||
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items, save to `/api/admin/playlist` |
|
| **Admin Router** | `src/admin.ts` | Tab-based navigation, dynamic page loading |
|
||||||
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
| **Upload Manager** | `src/admin.ts` | Multipart upload with chunk progress, normalization status badges |
|
||||||
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
| **Playlist Editor** | `src/admin.ts` | Drag-reorder, add/remove items (gated to `ready` media), save to `/api/admin/playlist` |
|
||||||
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, config metadata, restart buttons |
|
| **Moderation Panel** | `src/admin.ts` + `public/admin/moderation.html` | Inline mute/ban, action log viewer |
|
||||||
|
| **Analytics Dashboard** | `src/admin.ts` + `public/admin/analytics.html` | Message counts, top rooms, moderation stats |
|
||||||
|
| **System Config** | `src/admin.ts` + `public/admin/system.html` | Service health, runtime config form (HLS duration/window, Matomo), restart buttons |
|
||||||
|
|
||||||
## Runtime View
|
## 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
|
||||||
|
|||||||
+102
-70
@@ -1,81 +1,113 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Linear Void - 24/7 Synchronized Channel</title>
|
|
||||||
<link rel="stylesheet" href="/src/style.css" />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body class="lv-body">
|
<head>
|
||||||
<main class="viewer-shell" aria-label="Live player and chat">
|
<meta charset="UTF-8" />
|
||||||
<header class="viewer-header">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<div class="viewer-brand-group">
|
<title>Linear Void - 24/7 Synchronized Channel</title>
|
||||||
<div class="brand-badge" aria-hidden="true">LV</div>
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
<div>
|
</head>
|
||||||
<h1 class="viewer-title">Linear Void</h1>
|
|
||||||
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
<body class="lv-body">
|
||||||
</div>
|
<main class="viewer-shell" aria-label="Live player and chat">
|
||||||
|
<header class="viewer-header">
|
||||||
|
<div class="viewer-brand-group">
|
||||||
|
<div class="brand-badge" aria-hidden="true">LV</div>
|
||||||
|
<div>
|
||||||
|
<h1 class="viewer-title">Linear Void</h1>
|
||||||
|
<p class="viewer-subtitle">24/7 Synchronized Channel • Live</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</div>
|
||||||
id="chat-toggle"
|
<button id="chat-toggle" type="button" class="btn btn-secondary chat-toggle-btn" onclick="toggleChatPanel()" aria-controls="chat-panel" aria-expanded="true">
|
||||||
type="button"
|
Hide Chat
|
||||||
class="btn btn-secondary chat-toggle-btn"
|
</button>
|
||||||
onclick="toggleChatPanel()"
|
</header>
|
||||||
aria-controls="chat-panel"
|
|
||||||
aria-expanded="true"
|
|
||||||
>
|
|
||||||
Hide Chat
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="viewer-content" aria-label="Live stream and chat layout">
|
<section class="viewer-content" aria-label="Live stream and chat layout">
|
||||||
<section class="player-stage" aria-label="Video stage">
|
<section class="player-stage" aria-label="Video stage">
|
||||||
<video id="player" class="player" controls autoplay muted playsinline></video>
|
<video id="player" class="player" controls autoplay muted playsinline></video>
|
||||||
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
<div id="now-playing" class="now-playing-overlay" aria-live="polite">
|
||||||
<span id="np-title">Loading...</span>
|
<span id="np-title">Loading...</span>
|
||||||
<span id="np-next" class="np-next"></span>
|
<span id="np-next" class="np-next"></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
||||||
|
<header class="chat-header">
|
||||||
|
<div class="chat-room-title">
|
||||||
|
<span class="chat-live-marker" aria-hidden="true">●</span>
|
||||||
|
<span id="room-name">Main Channel</span>
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div id="presence-list" class="presence-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside id="chat-panel" class="chat-sidebar" aria-label="Chat panel">
|
<div class="chat-input-area">
|
||||||
<header class="chat-header">
|
<div class="chat-input-row">
|
||||||
<div class="chat-room-title">
|
<label for="message-input" class="sr-only">Message input</label>
|
||||||
<span class="chat-live-marker" aria-hidden="true">●</span>
|
<input id="message-input" type="text" placeholder="Type a message..." class="input" onkeydown="if (event.key === 'Enter') sendMessage();" />
|
||||||
<span id="room-name">Main Channel</span>
|
<button type="button" class="btn btn-primary" onclick="sendMessage()">Send</button>
|
||||||
</div>
|
|
||||||
<button type="button" class="btn btn-secondary" onclick="togglePrivateDM()">New DM</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section id="messages" class="chat-messages" aria-live="polite" aria-label="Message stream"></section>
|
|
||||||
|
|
||||||
<section id="presence-panel" class="presence-panel" aria-label="Online users">
|
|
||||||
<div id="presence-list" class="presence-list"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="chat-input-area">
|
|
||||||
<div class="chat-input-row">
|
|
||||||
<label for="message-input" class="sr-only">Message input</label>
|
|
||||||
<input
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
<p class="chat-note">Synced with video • Moderation active</p>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
<p class="chat-note">Synced with video • Moderation active</p>
|
||||||
</section>
|
</div>
|
||||||
</main>
|
</aside>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
<div id="reconnecting-overlay" class="reconnecting-overlay hidden" role="alert" aria-live="assertive">
|
||||||
<div class="reconnecting-spinner"></div>
|
<div class="reconnecting-spinner"></div>
|
||||||
<span id="reconnecting-text">Reconnecting...</span>
|
<span id="reconnecting-text">Reconnecting...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script>
|
||||||
</body>
|
(function () {
|
||||||
</html>
|
// 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>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user