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

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

No files matched your search

+48
View File
@@ -137,4 +137,52 @@ func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc)
next(w, r)
}
}
}
// UserSessionStore maps user session tokens to usernames with expiry.
type UserSessionStore struct {
mu sync.Mutex
sessions map[string]userSession
}
type userSession struct {
Username string
ExpiresAt time.Time
}
// NewUserSessionStore creates a new user session store.
func NewUserSessionStore() *UserSessionStore {
return &UserSessionStore{sessions: make(map[string]userSession)}
}
// Add stores a user session token with username and expiry.
func (s *UserSessionStore) Add(token, username string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = userSession{Username: username, ExpiresAt: expiresAt}
}
// Username returns the username for a valid, unexpired token.
func (s *UserSessionStore) Username(token string) (string, bool) {
if token == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.sessions[token]
if !ok {
return "", false
}
if time.Now().After(sess.ExpiresAt) {
delete(s.sessions, token)
return "", false
}
return sess.Username, true
}
// Remove deletes a user session token.
func (s *UserSessionStore) Remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
+29
View File
@@ -245,4 +245,33 @@ func TestRequireAdmin(t *testing.T) {
if w3.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w3.Code)
}
}
func TestUserSessionStore(t *testing.T) {
store := NewUserSessionStore()
if _, ok := store.Username(""); ok {
t.Error("Username(\"\") = true, want false")
}
token, _ := GenerateToken()
store.Add(token, "alice", time.Now().Add(1*time.Hour))
username, ok := store.Username(token)
if !ok || username != "alice" {
t.Errorf("Username() = %q, %v; want alice, true", username, ok)
}
// Expired session
expired, _ := GenerateToken()
store.Add(expired, "bob", time.Now().Add(-1*time.Hour))
if _, ok := store.Username(expired); ok {
t.Error("Username(expired) = true, want false")
}
// Remove
store.Remove(token)
if _, ok := store.Username(token); ok {
t.Error("Username after Remove = true, want false")
}
}
+14
View File
@@ -16,6 +16,7 @@ const (
DefaultRateLimitMax = 10
DefaultRateLimitWindow = 10 * time.Second
DefaultAdminSessionTTL = 12 * time.Hour
DefaultUserSessionTTL = 30 * 24 * time.Hour
DefaultChatPort = "3000"
)
@@ -68,6 +69,19 @@ func AdminSessionTTL() time.Duration {
return time.Duration(hours) * time.Hour
}
// UserSessionTTL returns the user session TTL from env or default (30 days).
func UserSessionTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("USER_SESSION_TTL_HOURS"))
if raw == "" {
return DefaultUserSessionTTL
}
hours, err := strconv.Atoi(raw)
if err != nil || hours <= 0 {
return DefaultUserSessionTTL
}
return time.Duration(hours) * time.Hour
}
// RateLimitMax returns the chat rate limit max messages per window.
func RateLimitMax() int {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
+221
View File
@@ -0,0 +1,221 @@
package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
)
// Runtime configuration keys stored in the runtime config file.
const (
keyMatomoURL = "matomo_url"
keyMatomoSiteID = "matomo_site_id"
keyHLSSegmentDur = "hls_segment_duration"
keyHLSWindowSize = "hls_window_size"
)
// Default HLS values used when neither env nor persisted config is set.
const (
defaultHLSSegmentDuration = 6
defaultHLSWindowSize = 24
)
var (
runtimeMu sync.RWMutex
runtimeStore = map[string]string{}
)
// ConfigPath returns the runtime config JSON file path from env or default.
// The default lives in the shared playlist volume so the playout service can
// read the same file (chat has read-write, playout read-only).
func ConfigPath() string {
p := strings.TrimSpace(os.Getenv("CONFIG_PATH"))
if p == "" {
return "/app/playlist/config.json"
}
return p
}
// GetRuntime returns a value from the runtime store and whether it is present.
func GetRuntime(key string) (string, bool) {
runtimeMu.RLock()
defer runtimeMu.RUnlock()
v, ok := runtimeStore[key]
return v, ok
}
// SetRuntime stores a value in the runtime store (in-memory only).
func SetRuntime(key, value string) {
runtimeMu.Lock()
defer runtimeMu.Unlock()
runtimeStore[key] = value
}
// LoadRuntimeConfig loads the persisted runtime config from disk. If the file does
// not exist, it seeds the store from environment variables.
func LoadRuntimeConfig() error {
data, err := os.ReadFile(ConfigPath())
if err != nil {
if os.IsNotExist(err) {
runtimeMu.Lock()
runtimeStore = map[string]string{}
runtimeMu.Unlock()
seedRuntimeDefaults()
return nil
}
return fmt.Errorf("read runtime config: %w", err)
}
values := map[string]string{}
if err := json.Unmarshal(data, &values); err != nil {
return fmt.Errorf("parse runtime config: %w", err)
}
runtimeMu.Lock()
defer runtimeMu.Unlock()
runtimeStore = values
return nil
}
// SaveRuntimeConfig persists the runtime store to disk as JSON.
func SaveRuntimeConfig() error {
runtimeMu.RLock()
data, err := json.MarshalIndent(runtimeStore, "", " ")
runtimeMu.RUnlock()
if err != nil {
return fmt.Errorf("marshal runtime config: %w", err)
}
if dir := filepath.Dir(ConfigPath()); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create runtime config dir: %w", err)
}
}
if err := os.WriteFile(ConfigPath(), append(data, '\n'), 0o644); err != nil {
return fmt.Errorf("write runtime config: %w", err)
}
return nil
}
// seedRuntimeDefaults populates the store from environment variables.
func seedRuntimeDefaults() {
if v := strings.TrimSpace(os.Getenv("MATOMO_URL")); v != "" {
SetRuntime(keyMatomoURL, v)
}
if v := strings.TrimSpace(os.Getenv("MATOMO_SITE_ID")); v != "" {
SetRuntime(keyMatomoSiteID, v)
}
if v := strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION")); v != "" {
SetRuntime(keyHLSSegmentDur, v)
}
if v := strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE")); v != "" {
SetRuntime(keyHLSWindowSize, v)
}
}
// MatomoURL returns the Matomo analytics URL.
func MatomoURL() string {
if v, ok := GetRuntime(keyMatomoURL); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return strings.TrimSpace(os.Getenv("MATOMO_URL"))
}
// MatomoSiteID returns the Matomo site ID.
func MatomoSiteID() string {
if v, ok := GetRuntime(keyMatomoSiteID); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
return strings.TrimSpace(os.Getenv("MATOMO_SITE_ID"))
}
// HLSSegmentDuration returns the HLS segment duration in seconds.
func HLSSegmentDuration() int {
raw := ""
if v, ok := GetRuntime(keyHLSSegmentDur); ok {
raw = strings.TrimSpace(v)
}
if raw == "" {
raw = strings.TrimSpace(os.Getenv("HLS_SEGMENT_DURATION"))
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return defaultHLSSegmentDuration
}
return n
}
// HLSWindowSize returns the HLS playlist window size in segments.
func HLSWindowSize() int {
raw := ""
if v, ok := GetRuntime(keyHLSWindowSize); ok {
raw = strings.TrimSpace(v)
}
if raw == "" {
raw = strings.TrimSpace(os.Getenv("HLS_WINDOW_SIZE"))
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return defaultHLSWindowSize
}
return n
}
// SetMatomoURL validates and stores the Matomo URL.
func SetMatomoURL(raw string) error {
raw = strings.TrimSpace(raw)
if raw == "" {
SetRuntime(keyMatomoURL, "")
return nil
}
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return fmt.Errorf("matomo URL must be an absolute http(s) URL")
}
SetRuntime(keyMatomoURL, raw)
return nil
}
// SetMatomoSiteID validates and stores the Matomo site ID.
func SetMatomoSiteID(raw string) error {
raw = strings.TrimSpace(raw)
if raw != "" {
if _, err := strconv.Atoi(raw); err != nil {
return fmt.Errorf("matomo site ID must be numeric")
}
}
SetRuntime(keyMatomoSiteID, raw)
return nil
}
// SetHLSSegmentDuration validates (2–15s) and stores the HLS segment duration.
func SetHLSSegmentDuration(seconds int) error {
if seconds < 2 || seconds > 15 {
return fmt.Errorf("HLS segment duration must be between 2 and 15 seconds")
}
SetRuntime(keyHLSSegmentDur, strconv.Itoa(seconds))
return nil
}
// SetHLSWindowSize validates (3–100) and stores the HLS window size.
func SetHLSWindowSize(size int) error {
if size < 3 || size > 100 {
return fmt.Errorf("HLS window size must be between 3 and 100 segments")
}
SetRuntime(keyHLSWindowSize, strconv.Itoa(size))
return nil
}
// RuntimeConfigSnapshot returns the resolved runtime configuration values.
func RuntimeConfigSnapshot() map[string]any {
return map[string]any{
"matomoUrl": MatomoURL(),
"matomoSiteId": MatomoSiteID(),
"hlsSegmentDuration": HLSSegmentDuration(),
"hlsWindowSize": HLSWindowSize(),
}
}
+181
View File
@@ -0,0 +1,181 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func resetRuntime(t *testing.T) {
t.Helper()
runtimeMu.Lock()
runtimeStore = map[string]string{}
runtimeMu.Unlock()
os.Unsetenv("CONFIG_PATH")
os.Unsetenv("MATOMO_URL")
os.Unsetenv("MATOMO_SITE_ID")
os.Unsetenv("HLS_SEGMENT_DURATION")
os.Unsetenv("HLS_WINDOW_SIZE")
}
func TestConfigPath(t *testing.T) {
resetRuntime(t)
if got := ConfigPath(); got != "/app/playlist/config.json" {
t.Errorf("ConfigPath() = %q, want default /app/playlist/config.json", got)
}
os.Setenv("CONFIG_PATH", "/tmp/custom.json")
defer os.Unsetenv("CONFIG_PATH")
if got := ConfigPath(); got != "/tmp/custom.json" {
t.Errorf("ConfigPath() = %q, want /tmp/custom.json", got)
}
}
func TestGetSetRuntime(t *testing.T) {
resetRuntime(t)
if _, ok := GetRuntime("foo"); ok {
t.Errorf("expected missing key")
}
SetRuntime("foo", "bar")
v, ok := GetRuntime("foo")
if !ok || v != "bar" {
t.Errorf("GetRuntime() = %q, %v; want bar, true", v, ok)
}
}
func TestGettersDefaults(t *testing.T) {
resetRuntime(t)
if got := MatomoURL(); got != "" {
t.Errorf("MatomoURL() = %q, want empty", got)
}
if got := MatomoSiteID(); got != "" {
t.Errorf("MatomoSiteID() = %q, want empty", got)
}
if got := HLSSegmentDuration(); got != 6 {
t.Errorf("HLSSegmentDuration() = %d, want 6", got)
}
if got := HLSWindowSize(); got != 24 {
t.Errorf("HLSWindowSize() = %d, want 24", got)
}
}
func TestGettersEnvFallback(t *testing.T) {
resetRuntime(t)
os.Setenv("HLS_SEGMENT_DURATION", "4")
os.Setenv("HLS_WINDOW_SIZE", "12")
os.Setenv("MATOMO_URL", "https://analytics.example.com")
os.Setenv("MATOMO_SITE_ID", "7")
if got := HLSSegmentDuration(); got != 4 {
t.Errorf("HLSSegmentDuration() = %d, want 4", got)
}
if got := HLSWindowSize(); got != 12 {
t.Errorf("HLSWindowSize() = %d, want 12", got)
}
if got := MatomoURL(); got != "https://analytics.example.com" {
t.Errorf("MatomoURL() = %q", got)
}
if got := MatomoSiteID(); got != "7" {
t.Errorf("MatomoSiteID() = %q", got)
}
}
func TestLoadRuntimeConfigSeedsFromEnv(t *testing.T) {
resetRuntime(t)
dir := t.TempDir()
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
os.Setenv("HLS_SEGMENT_DURATION", "8")
if err := LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig error: %v", err)
}
if got := HLSSegmentDuration(); got != 8 {
t.Errorf("HLSSegmentDuration() = %d, want 8 (seeded from env)", got)
}
}
func TestSaveAndLoadRuntimeConfig(t *testing.T) {
resetRuntime(t)
dir := t.TempDir()
os.Setenv("CONFIG_PATH", filepath.Join(dir, "config.json"))
if err := SetHLSSegmentDuration(5); err != nil {
t.Fatal(err)
}
if err := SetHLSWindowSize(30); err != nil {
t.Fatal(err)
}
if err := SetMatomoURL("https://stats.example.com"); err != nil {
t.Fatal(err)
}
if err := SetMatomoSiteID("42"); err != nil {
t.Fatal(err)
}
if err := SaveRuntimeConfig(); err != nil {
t.Fatalf("SaveRuntimeConfig error: %v", err)
}
// Reset the store and reload from disk.
runtimeMu.Lock()
runtimeStore = map[string]string{}
runtimeMu.Unlock()
if err := LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig error: %v", err)
}
if got := HLSSegmentDuration(); got != 5 {
t.Errorf("HLSSegmentDuration() = %d, want 5", got)
}
if got := HLSWindowSize(); got != 30 {
t.Errorf("HLSWindowSize() = %d, want 30", got)
}
if got := MatomoURL(); got != "https://stats.example.com" {
t.Errorf("MatomoURL() = %q", got)
}
if got := MatomoSiteID(); got != "42" {
t.Errorf("MatomoSiteID() = %q", got)
}
}
func TestSettersValidation(t *testing.T) {
resetRuntime(t)
if err := SetHLSSegmentDuration(1); err == nil {
t.Error("expected error for duration 1")
}
if err := SetHLSSegmentDuration(20); err == nil {
t.Error("expected error for duration 20")
}
if err := SetHLSSegmentDuration(6); err != nil {
t.Errorf("unexpected error for duration 6: %v", err)
}
if err := SetHLSWindowSize(2); err == nil {
t.Error("expected error for window 2")
}
if err := SetHLSWindowSize(101); err == nil {
t.Error("expected error for window 101")
}
if err := SetHLSWindowSize(24); err != nil {
t.Errorf("unexpected error for window 24: %v", err)
}
if err := SetMatomoURL("not-a-url"); err == nil {
t.Error("expected error for invalid URL")
}
if err := SetMatomoURL("ftp://example.com"); err == nil {
t.Error("expected error for non-http URL")
}
if err := SetMatomoURL("https://stats.example.com"); err != nil {
t.Errorf("unexpected error for valid URL: %v", err)
}
if err := SetMatomoSiteID("abc"); err == nil {
t.Error("expected error for non-numeric site ID")
}
if err := SetMatomoSiteID("99"); err != nil {
t.Errorf("unexpected error for numeric site ID: %v", err)
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ func InitDB(databaseURL string) (*DBClient, string, error) {
return nil, driver, err
}
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{}, &User{})
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
return &DBClient{DB: gormDB}, driver, nil
+8
View File
@@ -39,6 +39,14 @@ type Media struct {
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
}
// User represents a registered chat user account (ADR-006).
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;size:32" json:"username"`
PasswordHash string `json:"-"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
}
// ScheduleOverride represents a scheduled playlist override (ADR-004)
type ScheduleOverride struct {
ID uint `gorm:"primarykey" json:"id"`
+312 -11
View File
@@ -13,6 +13,7 @@ import (
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"chat/src/auth"
@@ -197,6 +198,7 @@ type Dependencies struct {
Redis db.RedisClient
Hub *ws.Hub
SessionStore *auth.SessionStore
UserSessions *auth.UserSessionStore
DBDriver string
}
@@ -333,6 +335,251 @@ func HandleAdminSession(deps *Dependencies) http.HandlerFunc {
}
}
// userSessionCookieName is the cookie for registered chat users.
const userSessionCookieName = "linearvoid_user_session"
// UserCredentialsPayload is used by registration and login.
type UserCredentialsPayload struct {
Username string `json:"username"`
Password string `json:"password"`
}
// validateUsername checks the username length constraints.
func validateUsername(username string) error {
if len(username) < 3 || len(username) > 32 {
return fmt.Errorf("username must be 3-32 characters")
}
return nil
}
// validatePassword checks the password length constraints.
func validatePassword(password string) error {
if len(password) < 6 {
return fmt.Errorf("password must be at least 6 characters")
}
return nil
}
// startUserSession issues a user session cookie for the given username.
func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) {
token, err := auth.GenerateToken()
if err != nil {
return
}
expiresAt := time.Now().Add(config.UserSessionTTL())
deps.UserSessions.Add(token, username, expiresAt)
auth.SetCookie(w, r, userSessionCookieName, token, expiresAt)
}
// HandleUserRegister creates a new chat user and signs them in.
func HandleUserRegister(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload UserCredentialsPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
username := strings.TrimSpace(payload.Username)
if err := validateUsername(username); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := validatePassword(payload.Password); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var count int64
deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count)
if count > 0 {
http.Error(w, "username already taken", http.StatusConflict)
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "failed to hash password", http.StatusInternalServerError)
return
}
user := db.User{Username: username, PasswordHash: string(hash)}
if err := deps.DB.Create(&user).Error; err != nil {
http.Error(w, "failed to create user", http.StatusInternalServerError)
return
}
startUserSession(deps, w, r, user.Username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
}
}
// HandleUserLogin verifies credentials and signs the user in.
func HandleUserLogin(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload UserCredentialsPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
username := strings.TrimSpace(payload.Username)
var user db.User
if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil {
http.Error(w, "invalid credentials", http.StatusUnauthorized)
return
}
startUserSession(deps, w, r, user.Username)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"username": user.Username})
}
}
// HandleUserSession returns the current user session state.
func HandleUserSession(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
token, _ := auth.GetTokenFromCookie(r, userSessionCookieName)
username, ok := deps.UserSessions.Username(token)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"authenticated": ok,
"username": username,
})
}
}
// HandleUserLogout clears the user session.
func HandleUserLogout(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok {
deps.UserSessions.Remove(token)
}
auth.ClearCookie(w, r, userSessionCookieName)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
}
}
// currentUsername returns the authenticated username from the user session cookie.
func currentUsername(deps *Dependencies, r *http.Request) (string, bool) {
token, ok := auth.GetTokenFromCookie(r, userSessionCookieName)
if !ok {
return "", false
}
return deps.UserSessions.Username(token)
}
// dmRoomFor returns the canonical DM room for two users (sorted, prefixed).
func dmRoomFor(a, b string) string {
if a > b {
a, b = b, a
}
return "dm:" + a + "-" + b
}
// HandleUserDMHistory returns paginated private message history with a target user.
func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username, ok := currentUsername(deps, r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
target := strings.TrimSpace(r.URL.Query().Get("with"))
if target == "" {
http.Error(w, "missing target user", http.StatusBadRequest)
return
}
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
limit = n
}
}
room := dmRoomFor(username, target)
query := deps.DB.Where("room = ?", room)
if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" {
if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil {
query = query.Where("timestamp < ?", t)
}
}
var messages []db.Message
if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
http.Error(w, "failed to load history", http.StatusInternalServerError)
return
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
}
// HandleUserDMSearch searches private messages with a target user.
func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
username, ok := currentUsername(deps, r)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
target := strings.TrimSpace(r.URL.Query().Get("with"))
q := strings.TrimSpace(r.URL.Query().Get("q"))
if target == "" || q == "" {
http.Error(w, "missing target or query", http.StatusBadRequest)
return
}
limit := 50
if raw := r.URL.Query().Get("limit"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 {
limit = n
}
}
room := dmRoomFor(username, target)
var messages []db.Message
if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%").
Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil {
http.Error(w, "failed to search", http.StatusInternalServerError)
return
}
for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 {
messages[i], messages[j] = messages[j], messages[i]
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(messages)
}
}
// HandleAdminOverview returns the admin dashboard overview.
func HandleAdminOverview(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
@@ -757,29 +1004,83 @@ func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc {
}
}
// HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend.
func HandlePublicMatomo() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"matomoUrl": config.MatomoURL(),
"matomoSiteId": config.MatomoSiteID(),
})
}
}
// adminConfigResponse builds the admin configuration payload from runtime config
// and read-only runtime facts.
func adminConfigResponse(deps *Dependencies) map[string]any {
resp := config.RuntimeConfigSnapshot()
resp["rateLimitMax"] = config.RateLimitMax()
resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds())
resp["databaseDriver"] = deps.DBDriver
resp["redisEnabled"] = deps.Redis != nil
resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours())
return resp
}
// HandleAdminConfig returns/updates runtime configuration.
func HandleAdminConfig(deps *Dependencies) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"rateLimitMax": config.RateLimitMax(),
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
"databaseDriver": deps.DBDriver,
"redisEnabled": deps.Redis != nil,
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
})
json.NewEncoder(w).Encode(adminConfigResponse(deps))
case http.MethodPut:
var payload map[string]any
// Editable runtime fields (Matomo + HLS). Rate limit fields remain
// env-driven and are intentionally read-only here.
var payload struct {
MatomoURL *string `json:"matomoUrl"`
MatomoSiteID *string `json:"matomoSiteId"`
HLSSegmentDuration *int `json:"hlsSegmentDuration"`
HLSWindowSize *int `json:"hlsWindowSize"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
// Note: rate limit config is read-only at runtime in this refactored version.
// To change, restart with env vars.
if payload.MatomoURL != nil {
if err := config.SetMatomoURL(*payload.MatomoURL); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if payload.MatomoSiteID != nil {
if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if payload.HLSSegmentDuration != nil {
if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if payload.HLSWindowSize != nil {
if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if err := config.SaveRuntimeConfig(); err != nil {
http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
json.NewEncoder(w).Encode(adminConfigResponse(deps))
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
+287 -1
View File
@@ -15,6 +15,7 @@ import (
"time"
"github.com/glebarez/sqlite"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"chat/src/auth"
@@ -102,7 +103,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{}, &db.Media{}, &db.ScheduleOverride{}, &db.User{})
mr := newMockRedis()
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, config.AdminUsername())
@@ -112,6 +113,7 @@ func setupTestDeps(t *testing.T) (*Dependencies, *gorm.DB, *auth.SessionStore) {
Redis: mr,
Hub: hub,
SessionStore: sessionStore,
UserSessions: auth.NewUserSessionStore(),
DBDriver: "sqlite",
}
return deps, gormDB, sessionStore
@@ -476,6 +478,7 @@ func TestHandleAdminConfig_Get(t *testing.T) {
func TestHandleAdminConfig_Put(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
body := `{"rateLimitMax":20}`
@@ -490,6 +493,289 @@ func TestHandleAdminConfig_Put(t *testing.T) {
}
}
// setupConfigTest points the runtime config at a fresh temp file and resets the store.
func setupConfigTest(t *testing.T) {
t.Helper()
os.Setenv("CONFIG_PATH", filepath.Join(t.TempDir(), "config.json"))
os.Unsetenv("MATOMO_URL")
os.Unsetenv("MATOMO_SITE_ID")
os.Unsetenv("HLS_SEGMENT_DURATION")
os.Unsetenv("HLS_WINDOW_SIZE")
if err := config.LoadRuntimeConfig(); err != nil {
t.Fatalf("LoadRuntimeConfig: %v", err)
}
t.Cleanup(func() { os.Unsetenv("CONFIG_PATH") })
}
func TestHandleAdminConfig_Get_NewFields(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
req := httptest.NewRequest("GET", "/api/admin/config", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg map[string]any
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
t.Fatalf("decode: %v", err)
}
for _, k := range []string{"matomoUrl", "matomoSiteId", "hlsSegmentDuration", "hlsWindowSize"} {
if _, ok := cfg[k]; !ok {
t.Errorf("response missing %q", k)
}
}
}
func TestHandleAdminConfig_Put_Persists(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
body := `{"hlsSegmentDuration":5,"hlsWindowSize":30,"matomoUrl":"https://stats.example.com","matomoSiteId":"42"}`
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var cfg map[string]any
if err := json.NewDecoder(w.Body).Decode(&cfg); err != nil {
t.Fatalf("decode: %v", err)
}
if cfg["hlsSegmentDuration"] != float64(5) {
t.Errorf("hlsSegmentDuration = %v, want 5", cfg["hlsSegmentDuration"])
}
if cfg["hlsWindowSize"] != float64(30) {
t.Errorf("hlsWindowSize = %v, want 30", cfg["hlsWindowSize"])
}
if cfg["matomoUrl"] != "https://stats.example.com" {
t.Errorf("matomoUrl = %v", cfg["matomoUrl"])
}
if cfg["matomoSiteId"] != "42" {
t.Errorf("matomoSiteId = %v", cfg["matomoSiteId"])
}
}
func TestHandleAdminConfig_Put_Invalid(t *testing.T) {
deps, _, _ := setupTestDeps(t)
setupConfigTest(t)
handler := HandleAdminConfig(deps)
body := `{"hlsSegmentDuration":99}`
req := httptest.NewRequest("PUT", "/api/admin/config", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", w.Code)
}
}
func TestHandlePublicMatomo(t *testing.T) {
setupConfigTest(t)
handler := HandlePublicMatomo()
req := httptest.NewRequest("GET", "/api/matomo", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp map[string]any
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
for _, k := range []string{"matomoUrl", "matomoSiteId"} {
if _, ok := resp[k]; !ok {
t.Errorf("response missing %q", k)
}
}
}
func TestHandleUserRegister_Success(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
handler := HandleUserRegister(deps)
body := `{"username":"alice","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
}
var count int64
gormDB.Model(&db.User{}).Where("username = ?", "alice").Count(&count)
if count != 1 {
t.Errorf("user count = %d, want 1", count)
}
if len(w.Result().Cookies()) == 0 {
t.Error("expected session cookie to be set")
}
}
func TestHandleUserRegister_Duplicate(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
gormDB.Create(&db.User{Username: "alice", PasswordHash: "x"})
handler := HandleUserRegister(deps)
body := `{"username":"alice","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/register", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusConflict {
t.Errorf("status = %d, want 409", w.Code)
}
}
func TestHandleUserLogin_Success(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
handler := HandleUserLogin(deps)
body := `{"username":"bob","password":"secret123"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (got %s)", w.Code, w.Body.String())
}
}
func TestHandleUserLogin_WrongPassword(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
hash, _ := bcrypt.GenerateFromPassword([]byte("secret123"), bcrypt.MinCost)
gormDB.Create(&db.User{Username: "bob", PasswordHash: string(hash)})
handler := HandleUserLogin(deps)
body := `{"username":"bob","password":"wrong"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleUserSession(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleUserSession(deps)
// Unauthenticated
req := httptest.NewRequest("GET", "/api/auth/session", nil)
w := httptest.NewRecorder()
handler(w, req)
var resp map[string]any
json.NewDecoder(w.Body).Decode(&resp)
if resp["authenticated"] != false {
t.Errorf("authenticated = %v, want false", resp["authenticated"])
}
// Authenticated
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "carol", time.Now().Add(1*time.Hour))
req2 := httptest.NewRequest("GET", "/api/auth/session", nil)
req2.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w2 := httptest.NewRecorder()
handler(w2, req2)
var resp2 map[string]any
json.NewDecoder(w2.Body).Decode(&resp2)
if resp2["username"] != "carol" {
t.Errorf("username = %v, want carol", resp2["username"])
}
}
func TestHandleUserDMHistory(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
now := time.Now()
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "hi bob", Type: "private", Timestamp: now.Add(-2 * time.Hour)})
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "hi alice", Type: "private", Timestamp: now.Add(-1 * time.Hour)})
handler := HandleUserDMHistory(deps)
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var msgs []db.Message
json.NewDecoder(w.Body).Decode(&msgs)
if len(msgs) != 2 {
t.Fatalf("len = %d, want 2", len(msgs))
}
if msgs[0].Text != "hi bob" {
t.Errorf("first message = %q, want hi bob (chronological)", msgs[0].Text)
}
}
func TestHandleUserDMHistory_Unauthorized(t *testing.T) {
deps, _, _ := setupTestDeps(t)
handler := HandleUserDMHistory(deps)
req := httptest.NewRequest("GET", "/api/dm/history?with=bob", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestHandleUserDMSearch(t *testing.T) {
deps, gormDB, _ := setupTestDeps(t)
token, _ := auth.GenerateToken()
deps.UserSessions.Add(token, "alice", time.Now().Add(1*time.Hour))
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "alice", Text: "meet at 8", Type: "private"})
gormDB.Create(&db.Message{Room: "dm:alice-bob", Username: "bob", Text: "see you", Type: "private"})
handler := HandleUserDMSearch(deps)
req := httptest.NewRequest("GET", "/api/dm/search?with=bob&q=meet", nil)
req.AddCookie(&http.Cookie{Name: "linearvoid_user_session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var msgs []db.Message
json.NewDecoder(w.Body).Decode(&msgs)
if len(msgs) != 1 || msgs[0].Text != "meet at 8" {
t.Fatalf("search results = %v, want [meet at 8]", msgs)
}
}
func TestHandleAdminRestart(t *testing.T) {
deps, _, _ := setupTestDeps(t)
+14
View File
@@ -28,6 +28,11 @@ func main() {
rateLimitWindow := config.RateLimitWindow()
log.Printf("Rate limit: %d messages per %v", rateLimitMax, rateLimitWindow)
// Load persisted runtime configuration (Matomo + HLS settings)
if err := config.LoadRuntimeConfig(); err != nil {
log.Printf("Warning: failed to load runtime config: %v", err)
}
// Initialize Redis
redisWrapper := db.InitRedis(config.RedisAddress())
@@ -39,6 +44,7 @@ func main() {
// Initialize session store
sessionStore := auth.NewSessionStore()
userSessions := auth.NewUserSessionStore()
// Initialize WebSocket hub
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
@@ -54,6 +60,7 @@ func main() {
Redis: redisWrapper,
Hub: hub,
SessionStore: sessionStore,
UserSessions: userSessions,
DBDriver: dbDriver,
}
@@ -62,6 +69,12 @@ func main() {
// Register routes
http.HandleFunc("/ws", hub.HandleConnection)
http.HandleFunc("/api/auth/register", metrics.WrapRequestTiming(handlers.HandleUserRegister(deps)))
http.HandleFunc("/api/auth/login", metrics.WrapRequestTiming(handlers.HandleUserLogin(deps)))
http.HandleFunc("/api/auth/session", metrics.WrapRequestTiming(handlers.HandleUserSession(deps)))
http.HandleFunc("/api/auth/logout", metrics.WrapRequestTiming(handlers.HandleUserLogout(deps)))
http.HandleFunc("/api/dm/history", metrics.WrapRequestTiming(handlers.HandleUserDMHistory(deps)))
http.HandleFunc("/api/dm/search", metrics.WrapRequestTiming(handlers.HandleUserDMSearch(deps)))
http.HandleFunc("/api/admin/login", metrics.WrapRequestTiming(handlers.HandleAdminLogin(deps)))
http.HandleFunc("/api/admin/logout", metrics.WrapRequestTiming(handlers.HandleAdminLogout(deps)))
http.HandleFunc("/api/admin/session", metrics.WrapRequestTiming(handlers.HandleAdminSession(deps)))
@@ -77,6 +90,7 @@ func main() {
http.HandleFunc("/api/admin/schedule", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSchedule(deps))))
http.HandleFunc("/api/admin/schedule/overrides", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminScheduleOverrides(deps))))
http.HandleFunc("/api/schedule", metrics.WrapRequestTiming(handlers.HandlePublicSchedule(deps)))
http.HandleFunc("/api/matomo", metrics.WrapRequestTiming(handlers.HandlePublicMatomo()))
http.HandleFunc("/api/admin/media", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMedia(deps))))
http.HandleFunc("/api/admin/media/uploads", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
http.HandleFunc("/api/admin/media/uploads/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaUploads(deps))))
+17
View File
@@ -93,6 +93,17 @@ func (h *Hub) Broadcast(msg db.Message) {
}
}
// BroadcastTyping notifies other users in the room that a user is typing.
func (h *Hub) BroadcastTyping(username, room string) {
h.mu.Lock()
defer h.mu.Unlock()
for conn, info := range h.clientMeta {
if info.Room == room && info.Username != username {
_ = conn.WriteJSON(db.Message{Room: room, Username: username, Text: "", Type: "typing", Timestamp: time.Now()})
}
}
}
// PushChatHistory sends the last N messages for a room via WebSocket.
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
if h.DB == nil || room == "" {
@@ -224,6 +235,12 @@ func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
continue
}
// Handle typing indicator (ephemeral — not persisted, not rate-limited)
if msg.Type == "typing" {
h.BroadcastTyping(msg.Username, msg.Room)
continue
}
// Handle private DMs
if strings.HasPrefix(msg.Room, "dm:") {
h.handlePrivateDM(msg)