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
+1
-1
@@ -6,6 +6,7 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
@@ -24,7 +25,6 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-isatty v0.0.17 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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))))
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user