Refactor load testing scripts to support Bash and PowerShell
CI / test (push) Successful in 2m50s
CI / docker (push) Successful in 5m12s

- Updated README.md to reflect changes in load testing commands and added usage examples for Bash scripts.
- Converted PowerShell load testing scripts to Bash equivalents for better cross-platform compatibility.
- Added new Bash scripts: run-loadtests.sh, summarize-artillery.sh, and summarize-k6.sh to mirror existing PowerShell functionality.
- Updated admin-api.js and ws-chat.yml for consistency and improved performance metrics reporting.
- Enhanced error handling and output formatting in summary scripts for better readability.
This commit is contained in:
zwitschi committed 2026-09-02 19:47:34 +02:00
1 parent 9f60087a4c
commit 33b6296c5e
82 files changed
+19682 -21133

No files matched your search

+206 -206
View File
@@ -1,207 +1,207 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
)
// SessionStore manages admin authentication sessions.
type SessionStore struct {
mu sync.Mutex
sessions map[string]time.Time
}
// NewSessionStore creates a new session store.
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]time.Time),
}
}
// GenerateToken creates a cryptographically random 64-char hex session token.
func GenerateToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// Validate checks if a session token is valid and not expired.
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
func (s *SessionStore) Validate(token string) bool {
if token == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
expiresAt, ok := s.sessions[token]
if !ok {
return false
}
if time.Now().After(expiresAt) {
delete(s.sessions, token)
return false
}
return true
}
// Add stores a session token with expiry.
func (s *SessionStore) Add(token string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = expiresAt
}
// Remove deletes a session token.
func (s *SessionStore) Remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
// CountActive returns the number of unexpired sessions.
func (s *SessionStore) CountActive() int {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for token, expiresAt := range s.sessions {
if now.After(expiresAt) {
delete(s.sessions, token)
}
}
return len(s.sessions)
}
// CookieSecure determines if the session cookie should have Secure flag.
func CookieSecure(r *http.Request) bool {
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
return strings.EqualFold(forwardedProto, "https")
}
return r.TLS != nil
}
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
func SameSiteForUserCookie(r *http.Request) http.SameSite {
if CookieSecure(r) {
return http.SameSiteNoneMode
}
return http.SameSiteLaxMode
}
// SetCookie writes a session cookie with the given SameSite policy.
// SameSite=None is only valid together with Secure, which is enforced here.
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: sameSite,
Secure: secure,
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
})
}
// ClearCookie removes a session cookie with the given SameSite policy.
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: sameSite,
Secure: secure,
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
// GetTokenFromCookie extracts the session token from the request cookie.
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
cookie, err := r.Cookie(cookieName)
if err != nil || cookie.Value == "" {
return "", false
}
return cookie.Value, true
}
// RequireAdmin returns middleware that checks for a valid admin session.
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := GetTokenFromCookie(r, cookieName)
if !ok || !store.Validate(token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
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)
package auth
import (
"crypto/rand"
"encoding/hex"
"net/http"
"strings"
"sync"
"time"
)
// SessionStore manages admin authentication sessions.
type SessionStore struct {
mu sync.Mutex
sessions map[string]time.Time
}
// NewSessionStore creates a new session store.
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]time.Time),
}
}
// GenerateToken creates a cryptographically random 64-char hex session token.
func GenerateToken() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
// Validate checks if a session token is valid and not expired.
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
func (s *SessionStore) Validate(token string) bool {
if token == "" {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
expiresAt, ok := s.sessions[token]
if !ok {
return false
}
if time.Now().After(expiresAt) {
delete(s.sessions, token)
return false
}
return true
}
// Add stores a session token with expiry.
func (s *SessionStore) Add(token string, expiresAt time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = expiresAt
}
// Remove deletes a session token.
func (s *SessionStore) Remove(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
}
// CountActive returns the number of unexpired sessions.
func (s *SessionStore) CountActive() int {
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for token, expiresAt := range s.sessions {
if now.After(expiresAt) {
delete(s.sessions, token)
}
}
return len(s.sessions)
}
// CookieSecure determines if the session cookie should have Secure flag.
func CookieSecure(r *http.Request) bool {
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
return strings.EqualFold(forwardedProto, "https")
}
return r.TLS != nil
}
// SameSiteForUserCookie returns the SameSite policy for the viewer session cookie.
// Over HTTPS it returns SameSite=None so the cookie is sent cross-site from the
// Workers-served SPA; over plain HTTP (local dev) it falls back to Lax.
func SameSiteForUserCookie(r *http.Request) http.SameSite {
if CookieSecure(r) {
return http.SameSiteNoneMode
}
return http.SameSiteLaxMode
}
// SetCookie writes a session cookie with the given SameSite policy.
// SameSite=None is only valid together with Secure, which is enforced here.
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: sameSite,
Secure: secure,
Expires: expiresAt,
MaxAge: int(time.Until(expiresAt).Seconds()),
})
}
// ClearCookie removes a session cookie with the given SameSite policy.
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string, sameSite http.SameSite) {
secure := CookieSecure(r)
if sameSite == http.SameSiteNoneMode {
secure = true
}
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: sameSite,
Secure: secure,
Expires: time.Unix(0, 0),
MaxAge: -1,
})
}
// GetTokenFromCookie extracts the session token from the request cookie.
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
cookie, err := r.Cookie(cookieName)
if err != nil || cookie.Value == "" {
return "", false
}
return cookie.Value, true
}
// RequireAdmin returns middleware that checks for a valid admin session.
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token, ok := GetTokenFromCookie(r, cookieName)
if !ok || !store.Validate(token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
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)
}
+308 -308
View File
@@ -1,309 +1,309 @@
package auth
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGenerateToken(t *testing.T) {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if len(token) != 64 {
t.Errorf("GenerateToken() length = %d, want 64", len(token))
}
// Uniqueness check
tokens := make(map[string]bool)
for i := 0; i < 1000; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
}
if tokens[tok] {
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
}
tokens[tok] = true
}
}
func TestSessionStore_Validate(t *testing.T) {
store := NewSessionStore()
// Empty token
if store.Validate("") {
t.Error("Validate(\"\") = true, want false")
}
// Missing token
if store.Validate("nonexistent") {
t.Error("Validate(\"nonexistent\") = true, want false")
}
// Valid token
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate(valid) = false, want true")
}
// Expired token
expiredToken, _ := GenerateToken()
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
if store.Validate(expiredToken) {
t.Error("Validate(expired) = true, want false")
}
// Expired token should be cleaned up
if store.Validate(expiredToken) {
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
}
}
func TestSessionStore_AddRemove(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate after Add = false, want true")
}
store.Remove(token)
if store.Validate(token) {
t.Error("Validate after Remove = true, want false")
}
}
func TestSessionStore_CountActive(t *testing.T) {
store := NewSessionStore()
if count := store.CountActive(); count != 0 {
t.Errorf("CountActive() = %d, want 0", count)
}
t1, _ := GenerateToken()
t2, _ := GenerateToken()
t3, _ := GenerateToken()
store.Add(t1, time.Now().Add(1*time.Hour))
store.Add(t2, time.Now().Add(1*time.Hour))
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
if count := store.CountActive(); count != 2 {
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
}
}
func TestCookieSecure(t *testing.T) {
tests := []struct {
name string
forwardedProto string
tls bool
expected bool
}{
{"https forwarded", "https", false, true},
{"http forwarded", "http", false, false},
{"empty forwarded, no TLS", "", false, false},
{"empty forwarded, TLS", "", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
if tt.tls {
req.TLS = &tls.ConnectionState{}
}
if got := CookieSecure(req); got != tt.expected {
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
}
})
}
}
func TestSetCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "abc123" {
t.Errorf("cookie value = %q, want abc123", c.Value)
}
if !c.HttpOnly {
t.Error("cookie HttpOnly = false, want true")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
}
if c.Path != "/" {
t.Errorf("cookie Path = %q, want /", c.Path)
}
}
func TestClearCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "" {
t.Errorf("cookie value = %q, want empty", c.Value)
}
if c.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
}
}
func TestSameSiteForUserCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
}
req2 := httptest.NewRequest("GET", "/", nil)
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
}
}
func TestSetCookie_NoneForcesSecure(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.SameSite != http.SameSiteNoneMode {
t.Errorf("cookie SameSite = %v, want None", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
}
}
func TestGetTokenFromCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
token, ok := GetTokenFromCookie(req, "session")
if !ok {
t.Error("GetTokenFromCookie() ok = false, want true")
}
if token != "token123" {
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
}
// Missing cookie
_, ok = GetTokenFromCookie(req, "nonexistent")
if ok {
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
}
// Empty value
req2 := httptest.NewRequest("GET", "/", nil)
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
_, ok = GetTokenFromCookie(req2, "session")
if ok {
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
}
}
func TestRequireAdmin(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// Valid session
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
// No cookie
req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder()
handler(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w2.Code)
}
// Invalid token
req3 := httptest.NewRequest("GET", "/", nil)
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
w3 := httptest.NewRecorder()
handler(w3, req3)
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")
}
package auth
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestGenerateToken(t *testing.T) {
token, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if len(token) != 64 {
t.Errorf("GenerateToken() length = %d, want 64", len(token))
}
// Uniqueness check
tokens := make(map[string]bool)
for i := 0; i < 1000; i++ {
tok, err := GenerateToken()
if err != nil {
t.Fatalf("GenerateToken() iteration %d error = %v", i, err)
}
if tokens[tok] {
t.Errorf("GenerateToken() produced duplicate token at iteration %d", i)
}
tokens[tok] = true
}
}
func TestSessionStore_Validate(t *testing.T) {
store := NewSessionStore()
// Empty token
if store.Validate("") {
t.Error("Validate(\"\") = true, want false")
}
// Missing token
if store.Validate("nonexistent") {
t.Error("Validate(\"nonexistent\") = true, want false")
}
// Valid token
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate(valid) = false, want true")
}
// Expired token
expiredToken, _ := GenerateToken()
store.Add(expiredToken, time.Now().Add(-1*time.Hour))
if store.Validate(expiredToken) {
t.Error("Validate(expired) = true, want false")
}
// Expired token should be cleaned up
if store.Validate(expiredToken) {
t.Error("Validate(expired) second call = true, want false (should be cleaned)")
}
}
func TestSessionStore_AddRemove(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
if !store.Validate(token) {
t.Error("Validate after Add = false, want true")
}
store.Remove(token)
if store.Validate(token) {
t.Error("Validate after Remove = true, want false")
}
}
func TestSessionStore_CountActive(t *testing.T) {
store := NewSessionStore()
if count := store.CountActive(); count != 0 {
t.Errorf("CountActive() = %d, want 0", count)
}
t1, _ := GenerateToken()
t2, _ := GenerateToken()
t3, _ := GenerateToken()
store.Add(t1, time.Now().Add(1*time.Hour))
store.Add(t2, time.Now().Add(1*time.Hour))
store.Add(t3, time.Now().Add(-1*time.Hour)) // expired
if count := store.CountActive(); count != 2 {
t.Errorf("CountActive() = %d, want 2 (expired should be cleaned)", count)
}
}
func TestCookieSecure(t *testing.T) {
tests := []struct {
name string
forwardedProto string
tls bool
expected bool
}{
{"https forwarded", "https", false, true},
{"http forwarded", "http", false, false},
{"empty forwarded, no TLS", "", false, false},
{"empty forwarded, TLS", "", true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
if tt.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwardedProto)
}
if tt.tls {
req.TLS = &tls.ConnectionState{}
}
if got := CookieSecure(req); got != tt.expected {
t.Errorf("CookieSecure() = %v, want %v", got, tt.expected)
}
})
}
}
func TestSetCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "abc123" {
t.Errorf("cookie value = %q, want abc123", c.Value)
}
if !c.HttpOnly {
t.Error("cookie HttpOnly = false, want true")
}
if c.SameSite != http.SameSiteStrictMode {
t.Errorf("cookie SameSite = %v, want Strict", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (X-Forwarded-Proto: https)")
}
if c.Path != "/" {
t.Errorf("cookie Path = %q, want /", c.Path)
}
}
func TestClearCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
ClearCookie(w, req, "test_cookie", http.SameSiteStrictMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.Name != "test_cookie" {
t.Errorf("cookie name = %q, want test_cookie", c.Name)
}
if c.Value != "" {
t.Errorf("cookie value = %q, want empty", c.Value)
}
if c.MaxAge != -1 {
t.Errorf("cookie MaxAge = %d, want -1", c.MaxAge)
}
}
func TestSameSiteForUserCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
if got := SameSiteForUserCookie(req); got != http.SameSiteNoneMode {
t.Errorf("SameSiteForUserCookie(https) = %v, want None", got)
}
req2 := httptest.NewRequest("GET", "/", nil)
if got := SameSiteForUserCookie(req2); got != http.SameSiteLaxMode {
t.Errorf("SameSiteForUserCookie(http) = %v, want Lax", got)
}
}
func TestSetCookie_NoneForcesSecure(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
expiresAt := time.Now().Add(1 * time.Hour)
SetCookie(w, req, "test_cookie", "abc123", expiresAt, http.SameSiteNoneMode)
cookies := w.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("expected 1 cookie, got %d", len(cookies))
}
c := cookies[0]
if c.SameSite != http.SameSiteNoneMode {
t.Errorf("cookie SameSite = %v, want None", c.SameSite)
}
if !c.Secure {
t.Error("cookie Secure = false, want true (SameSite=None requires Secure)")
}
}
func TestGetTokenFromCookie(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: "token123"})
token, ok := GetTokenFromCookie(req, "session")
if !ok {
t.Error("GetTokenFromCookie() ok = false, want true")
}
if token != "token123" {
t.Errorf("GetTokenFromCookie() = %q, want token123", token)
}
// Missing cookie
_, ok = GetTokenFromCookie(req, "nonexistent")
if ok {
t.Error("GetTokenFromCookie() for missing cookie ok = true, want false")
}
// Empty value
req2 := httptest.NewRequest("GET", "/", nil)
req2.AddCookie(&http.Cookie{Name: "session", Value: ""})
_, ok = GetTokenFromCookie(req2, "session")
if ok {
t.Error("GetTokenFromCookie() for empty value ok = true, want false")
}
}
func TestRequireAdmin(t *testing.T) {
store := NewSessionStore()
token, _ := GenerateToken()
store.Add(token, time.Now().Add(1*time.Hour))
handler := RequireAdmin(store, "session")(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
// Valid session
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{Name: "session", Value: token})
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
// No cookie
req2 := httptest.NewRequest("GET", "/", nil)
w2 := httptest.NewRecorder()
handler(w2, req2)
if w2.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w2.Code)
}
// Invalid token
req3 := httptest.NewRequest("GET", "/", nil)
req3.AddCookie(&http.Cookie{Name: "session", Value: "invalid"})
w3 := httptest.NewRecorder()
handler(w3, req3)
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")
}
}
+131 -131
View File
@@ -1,132 +1,132 @@
package config
import (
"os"
"strconv"
"strings"
"time"
)
// Defaults and constants
const (
AdminSessionCookieName = "linearvoid_admin_session"
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
MaxAdminChunkSize int64 = 32 * 1024 * 1024
DefaultRateLimitMax = 10
DefaultRateLimitWindow = 10 * time.Second
DefaultAdminSessionTTL = 12 * time.Hour
DefaultUserSessionTTL = 30 * 24 * time.Hour
DefaultChatPort = "3000"
)
// PlaylistPath returns the playlist file path from env or default.
func PlaylistPath() string {
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
if p == "" {
return "/app/playlist/playlist.txt"
}
return p
}
// MediaDir returns the media directory from env or default.
func MediaDir() string {
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
if d == "" {
return "/app/media"
}
return d
}
// AdminUsername returns the admin username from env or default.
func AdminUsername() string {
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
if u == "" {
return "admin"
}
return u
}
// AdminPassword returns the admin password from env or default.
func AdminPassword() string {
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if p == "" {
return "admin"
}
return p
}
// AdminSessionTTL returns the admin session TTL from env or default.
func AdminSessionTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
if raw == "" {
return DefaultAdminSessionTTL
}
hours, err := strconv.Atoi(raw)
if err != nil || hours <= 0 {
return DefaultAdminSessionTTL
}
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"))
if raw == "" {
return DefaultRateLimitMax
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return DefaultRateLimitMax
}
return v
}
// RateLimitWindow returns the chat rate limit window duration.
func RateLimitWindow() time.Duration {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
if raw == "" {
return DefaultRateLimitWindow
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return DefaultRateLimitWindow
}
return time.Duration(v) * time.Second
}
// ChatPort returns the chat service port from env or default.
func ChatPort() string {
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
if p == "" {
return DefaultChatPort
}
return p
}
// RedisAddress returns the Redis address from env or default.
func RedisAddress() string {
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
if a == "" {
return "redis:6379"
}
return a
}
// DatabaseURL returns the database URL from env (empty = SQLite default).
func DatabaseURL() string {
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
package config
import (
"os"
"strconv"
"strings"
"time"
)
// Defaults and constants
const (
AdminSessionCookieName = "linearvoid_admin_session"
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
MaxAdminChunkSize int64 = 32 * 1024 * 1024
DefaultRateLimitMax = 10
DefaultRateLimitWindow = 10 * time.Second
DefaultAdminSessionTTL = 12 * time.Hour
DefaultUserSessionTTL = 30 * 24 * time.Hour
DefaultChatPort = "3000"
)
// PlaylistPath returns the playlist file path from env or default.
func PlaylistPath() string {
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
if p == "" {
return "/app/playlist/playlist.txt"
}
return p
}
// MediaDir returns the media directory from env or default.
func MediaDir() string {
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
if d == "" {
return "/app/media"
}
return d
}
// AdminUsername returns the admin username from env or default.
func AdminUsername() string {
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
if u == "" {
return "admin"
}
return u
}
// AdminPassword returns the admin password from env or default.
func AdminPassword() string {
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
if p == "" {
return "admin"
}
return p
}
// AdminSessionTTL returns the admin session TTL from env or default.
func AdminSessionTTL() time.Duration {
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
if raw == "" {
return DefaultAdminSessionTTL
}
hours, err := strconv.Atoi(raw)
if err != nil || hours <= 0 {
return DefaultAdminSessionTTL
}
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"))
if raw == "" {
return DefaultRateLimitMax
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return DefaultRateLimitMax
}
return v
}
// RateLimitWindow returns the chat rate limit window duration.
func RateLimitWindow() time.Duration {
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
if raw == "" {
return DefaultRateLimitWindow
}
v, err := strconv.Atoi(raw)
if err != nil || v <= 0 {
return DefaultRateLimitWindow
}
return time.Duration(v) * time.Second
}
// ChatPort returns the chat service port from env or default.
func ChatPort() string {
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
if p == "" {
return DefaultChatPort
}
return p
}
// RedisAddress returns the Redis address from env or default.
func RedisAddress() string {
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
if a == "" {
return "redis:6379"
}
return a
}
// DatabaseURL returns the database URL from env (empty = SQLite default).
func DatabaseURL() string {
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
}
+266 -266
View File
@@ -1,267 +1,267 @@
package config
import (
"os"
"testing"
"time"
)
func TestPlaylistPath(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/playlist/playlist.txt"},
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("PLAYLIST_PATH", tt.env)
defer os.Unsetenv("PLAYLIST_PATH")
} else {
os.Unsetenv("PLAYLIST_PATH")
}
if got := PlaylistPath(); got != tt.expected {
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
}
})
}
}
func TestMediaDir(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/media"},
{"custom", "/data/media", "/data/media"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("MEDIA_DIR", tt.env)
defer os.Unsetenv("MEDIA_DIR")
} else {
os.Unsetenv("MEDIA_DIR")
}
if got := MediaDir(); got != tt.expected {
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminUsername(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "superadmin", "superadmin"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_USERNAME", tt.env)
defer os.Unsetenv("ADMIN_USERNAME")
} else {
os.Unsetenv("ADMIN_USERNAME")
}
if got := AdminUsername(); got != tt.expected {
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminPassword(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "secret123", "secret123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_PASSWORD", tt.env)
defer os.Unsetenv("ADMIN_PASSWORD")
} else {
os.Unsetenv("ADMIN_PASSWORD")
}
if got := AdminPassword(); got != tt.expected {
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminSessionTTL(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 12 * time.Hour},
{"custom", "24", 24 * time.Hour},
{"zero", "0", 12 * time.Hour},
{"negative", "-5", 12 * time.Hour},
{"invalid", "abc", 12 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
} else {
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
}
if got := AdminSessionTTL(); got != tt.expected {
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
}
})
}
}
func TestRateLimitMax(t *testing.T) {
tests := []struct {
name string
env string
expected int
}{
{"default", "", 10},
{"custom", "20", 20},
{"zero", "0", 10},
{"negative", "-5", 10},
{"invalid", "abc", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
}
if got := RateLimitMax(); got != tt.expected {
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
}
})
}
}
func TestRateLimitWindow(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 10 * time.Second},
{"custom", "30", 30 * time.Second},
{"zero", "0", 10 * time.Second},
{"negative", "-5", 10 * time.Second},
{"invalid", "abc", 10 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
}
if got := RateLimitWindow(); got != tt.expected {
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
}
})
}
}
func TestChatPort(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "3000"},
{"custom", "8080", "8080"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_PORT", tt.env)
defer os.Unsetenv("CHAT_PORT")
} else {
os.Unsetenv("CHAT_PORT")
}
if got := ChatPort(); got != tt.expected {
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
}
})
}
}
func TestRedisAddress(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "redis:6379"},
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("REDIS_URL", tt.env)
defer os.Unsetenv("REDIS_URL")
} else {
os.Unsetenv("REDIS_URL")
}
if got := RedisAddress(); got != tt.expected {
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
}
})
}
}
func TestDatabaseURL(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", ""},
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("DATABASE_URL", tt.env)
defer os.Unsetenv("DATABASE_URL")
} else {
os.Unsetenv("DATABASE_URL")
}
if got := DatabaseURL(); got != tt.expected {
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
}
})
}
package config
import (
"os"
"testing"
"time"
)
func TestPlaylistPath(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/playlist/playlist.txt"},
{"custom", "/custom/playlist.txt", "/custom/playlist.txt"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("PLAYLIST_PATH", tt.env)
defer os.Unsetenv("PLAYLIST_PATH")
} else {
os.Unsetenv("PLAYLIST_PATH")
}
if got := PlaylistPath(); got != tt.expected {
t.Errorf("PlaylistPath() = %q, want %q", got, tt.expected)
}
})
}
}
func TestMediaDir(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "/app/media"},
{"custom", "/data/media", "/data/media"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("MEDIA_DIR", tt.env)
defer os.Unsetenv("MEDIA_DIR")
} else {
os.Unsetenv("MEDIA_DIR")
}
if got := MediaDir(); got != tt.expected {
t.Errorf("MediaDir() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminUsername(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "superadmin", "superadmin"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_USERNAME", tt.env)
defer os.Unsetenv("ADMIN_USERNAME")
} else {
os.Unsetenv("ADMIN_USERNAME")
}
if got := AdminUsername(); got != tt.expected {
t.Errorf("AdminUsername() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminPassword(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "admin"},
{"custom", "secret123", "secret123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_PASSWORD", tt.env)
defer os.Unsetenv("ADMIN_PASSWORD")
} else {
os.Unsetenv("ADMIN_PASSWORD")
}
if got := AdminPassword(); got != tt.expected {
t.Errorf("AdminPassword() = %q, want %q", got, tt.expected)
}
})
}
}
func TestAdminSessionTTL(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 12 * time.Hour},
{"custom", "24", 24 * time.Hour},
{"zero", "0", 12 * time.Hour},
{"negative", "-5", 12 * time.Hour},
{"invalid", "abc", 12 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("ADMIN_SESSION_TTL_HOURS", tt.env)
defer os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
} else {
os.Unsetenv("ADMIN_SESSION_TTL_HOURS")
}
if got := AdminSessionTTL(); got != tt.expected {
t.Errorf("AdminSessionTTL() = %v, want %v", got, tt.expected)
}
})
}
}
func TestRateLimitMax(t *testing.T) {
tests := []struct {
name string
env string
expected int
}{
{"default", "", 10},
{"custom", "20", 20},
{"zero", "0", 10},
{"negative", "-5", 10},
{"invalid", "abc", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_MAX", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_MAX")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_MAX")
}
if got := RateLimitMax(); got != tt.expected {
t.Errorf("RateLimitMax() = %d, want %d", got, tt.expected)
}
})
}
}
func TestRateLimitWindow(t *testing.T) {
tests := []struct {
name string
env string
expected time.Duration
}{
{"default", "", 10 * time.Second},
{"custom", "30", 30 * time.Second},
{"zero", "0", 10 * time.Second},
{"negative", "-5", 10 * time.Second},
{"invalid", "abc", 10 * time.Second},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_RATE_LIMIT_WINDOW_S", tt.env)
defer os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
} else {
os.Unsetenv("CHAT_RATE_LIMIT_WINDOW_S")
}
if got := RateLimitWindow(); got != tt.expected {
t.Errorf("RateLimitWindow() = %v, want %v", got, tt.expected)
}
})
}
}
func TestChatPort(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "3000"},
{"custom", "8080", "8080"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("CHAT_PORT", tt.env)
defer os.Unsetenv("CHAT_PORT")
} else {
os.Unsetenv("CHAT_PORT")
}
if got := ChatPort(); got != tt.expected {
t.Errorf("ChatPort() = %q, want %q", got, tt.expected)
}
})
}
}
func TestRedisAddress(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", "redis:6379"},
{"custom", "redis.example.com:6380", "redis.example.com:6380"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("REDIS_URL", tt.env)
defer os.Unsetenv("REDIS_URL")
} else {
os.Unsetenv("REDIS_URL")
}
if got := RedisAddress(); got != tt.expected {
t.Errorf("RedisAddress() = %q, want %q", got, tt.expected)
}
})
}
}
func TestDatabaseURL(t *testing.T) {
tests := []struct {
name string
env string
expected string
}{
{"default", "", ""},
{"sqlite", "sqlite://test.db", "sqlite://test.db"},
{"postgres", "postgres://user:pass@localhost/db", "postgres://user:pass@localhost/db"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.env != "" {
os.Setenv("DATABASE_URL", tt.env)
defer os.Unsetenv("DATABASE_URL")
} else {
os.Unsetenv("DATABASE_URL")
}
if got := DatabaseURL(); got != tt.expected {
t.Errorf("DatabaseURL() = %q, want %q", got, tt.expected)
}
})
}
}
+221 -221
View File
@@ -1,221 +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 = 2
defaultHLSWindowSize = 12
)
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(),
}
}
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 = 2
defaultHLSWindowSize = 12
)
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 -181
View File
@@ -1,181 +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 != 2 {
t.Errorf("HLSSegmentDuration() = %d, want 2", got)
}
if got := HLSWindowSize(); got != 12 {
t.Errorf("HLSWindowSize() = %d, want 12", got)
}
}
func TestGettersEnvFallback(t *testing.T) {
resetRuntime(t)
os.Setenv("HLS_SEGMENT_DURATION", "4")
os.Setenv("HLS_WINDOW_SIZE", "30")
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 != 30 {
t.Errorf("HLSWindowSize() = %d, want 30", 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)
}
}
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 != 2 {
t.Errorf("HLSSegmentDuration() = %d, want 2", got)
}
if got := HLSWindowSize(); got != 12 {
t.Errorf("HLSWindowSize() = %d, want 12", got)
}
}
func TestGettersEnvFallback(t *testing.T) {
resetRuntime(t)
os.Setenv("HLS_SEGMENT_DURATION", "4")
os.Setenv("HLS_WINDOW_SIZE", "30")
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 != 30 {
t.Errorf("HLSWindowSize() = %d, want 30", 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)
}
}
+81 -81
View File
@@ -1,82 +1,82 @@
package db
import (
"context"
"log"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// DBClient wraps gorm.DB for testability.
type DBClient struct {
*gorm.DB
}
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
// Returns the client and the driver name.
func InitDB(databaseURL string) (*DBClient, string, error) {
var (
gormDB *gorm.DB
err error
driver = "sqlite"
)
switch {
case databaseURL == "":
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
case strings.HasPrefix(databaseURL, "sqlite://"):
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
if sqlitePath == "" {
sqlitePath = "chat.db"
}
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
driver = "postgres"
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
default:
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
}
if err != nil {
return nil, driver, err
}
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
}
// RedisClient wraps the Redis client for testability.
// Interface defined for mocking in tests.
type RedisClient interface {
Ping(ctx context.Context) error
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Get(ctx context.Context, key string) (string, error)
Del(ctx context.Context, keys ...string) error
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error
Exists(ctx context.Context, keys ...string) (int64, error)
Publish(ctx context.Context, channel string, message interface{}) error
PSubscribe(ctx context.Context, channels ...string) PubSub
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
Info(ctx context.Context, sections ...string) (string, error)
Close() error
}
// PubSub wraps Redis pub/sub subscription.
type PubSub interface {
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
Close() error
}
// PubSubMessage represents a received pub/sub message.
type PubSubMessage struct {
Channel string
Payload string
package db
import (
"context"
"log"
"strings"
"time"
"github.com/glebarez/sqlite"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// DBClient wraps gorm.DB for testability.
type DBClient struct {
*gorm.DB
}
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
// Returns the client and the driver name.
func InitDB(databaseURL string) (*DBClient, string, error) {
var (
gormDB *gorm.DB
err error
driver = "sqlite"
)
switch {
case databaseURL == "":
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
case strings.HasPrefix(databaseURL, "sqlite://"):
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
if sqlitePath == "" {
sqlitePath = "chat.db"
}
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
driver = "postgres"
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
default:
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
}
if err != nil {
return nil, driver, err
}
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
}
// RedisClient wraps the Redis client for testability.
// Interface defined for mocking in tests.
type RedisClient interface {
Ping(ctx context.Context) error
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
Get(ctx context.Context, key string) (string, error)
Del(ctx context.Context, keys ...string) error
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error
Exists(ctx context.Context, keys ...string) (int64, error)
Publish(ctx context.Context, channel string, message interface{}) error
PSubscribe(ctx context.Context, channels ...string) PubSub
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
Info(ctx context.Context, sections ...string) (string, error)
Close() error
}
// PubSub wraps Redis pub/sub subscription.
type PubSub interface {
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
Close() error
}
// PubSubMessage represents a received pub/sub message.
type PubSubMessage struct {
Channel string
Payload string
}
+178 -178
View File
@@ -1,179 +1,179 @@
package db
import (
"testing"
"time"
)
func TestInitDB_SQLite(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
// Verify tables exist by running AutoMigrate (idempotent)
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
if err != nil {
t.Errorf("AutoMigrate error = %v", err)
}
// Verify CRUD on Message
msg := Message{
Room: "test",
Username: "user1",
Text: "hello",
Timestamp: time.Now(),
Type: "public",
}
if err := client.DB.Create(&msg).Error; err != nil {
t.Fatalf("Create message error = %v", err)
}
if msg.ID == 0 {
t.Error("Create message did not set ID")
}
var count int64
client.DB.Model(&Message{}).Count(&count)
if count != 1 {
t.Errorf("Message count = %d, want 1", count)
}
// Verify read
var read Message
if err := client.DB.First(&read, msg.ID).Error; err != nil {
t.Fatalf("First message error = %v", err)
}
if read.Text != "hello" {
t.Errorf("read.Text = %q, want hello", read.Text)
}
// Verify delete
client.DB.Delete(&Message{}, msg.ID)
client.DB.Model(&Message{}).Count(&count)
if count != 0 {
t.Errorf("Message count after delete = %d, want 0", count)
}
}
func TestInitDB_ModerationLog(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
log := ModerationLog{
Action: "mute",
Target: "user1",
Moderator: "admin",
Reason: "spam",
Timestamp: time.Now(),
}
if err := client.DB.Create(&log).Error; err != nil {
t.Fatalf("Create moderation log error = %v", err)
}
if log.ID == 0 {
t.Error("Create moderation log did not set ID")
}
var count int64
client.DB.Model(&ModerationLog{}).Count(&count)
if count != 1 {
t.Errorf("ModerationLog count = %d, want 1", count)
}
}
func TestInitDB_Media(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
media := Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test Video",
Tags: "tag1,tag2",
}
if err := client.DB.Create(&media).Error; err != nil {
t.Fatalf("Create media error = %v", err)
}
if media.ID == 0 {
t.Error("Create media did not set ID")
}
// Verify update
client.DB.Model(&media).Update("status", "needs_normalization")
var updated Media
client.DB.First(&updated, media.ID)
if updated.Status != "needs_normalization" {
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
}
}
func TestInitDB_ScheduleOverride(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
now := time.Now()
ov := ScheduleOverride{
MediaID: 1,
StartTime: now,
EndTime: now.Add(1 * time.Hour),
Reason: "special event",
}
if err := client.DB.Create(&ov).Error; err != nil {
t.Fatalf("Create schedule override error = %v", err)
}
if ov.ID == 0 {
t.Error("Create schedule override did not set ID")
}
// Conflict detection
var conflict int64
client.DB.Model(&ScheduleOverride{}).
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
Count(&conflict)
if conflict != 1 {
t.Errorf("conflict count = %d, want 1", conflict)
}
}
func TestInitDB_EmptyURL(t *testing.T) {
// Empty URL should default to SQLite chat.db
// This creates a file, clean up after
client, driver, err := InitDB("")
if err != nil {
t.Fatalf("InitDB(\"\") error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB(\"\") client = nil")
}
}
func TestInitDB_SQLitePath(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
package db
import (
"testing"
"time"
)
func TestInitDB_SQLite(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
// Verify tables exist by running AutoMigrate (idempotent)
err = client.DB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
if err != nil {
t.Errorf("AutoMigrate error = %v", err)
}
// Verify CRUD on Message
msg := Message{
Room: "test",
Username: "user1",
Text: "hello",
Timestamp: time.Now(),
Type: "public",
}
if err := client.DB.Create(&msg).Error; err != nil {
t.Fatalf("Create message error = %v", err)
}
if msg.ID == 0 {
t.Error("Create message did not set ID")
}
var count int64
client.DB.Model(&Message{}).Count(&count)
if count != 1 {
t.Errorf("Message count = %d, want 1", count)
}
// Verify read
var read Message
if err := client.DB.First(&read, msg.ID).Error; err != nil {
t.Fatalf("First message error = %v", err)
}
if read.Text != "hello" {
t.Errorf("read.Text = %q, want hello", read.Text)
}
// Verify delete
client.DB.Delete(&Message{}, msg.ID)
client.DB.Model(&Message{}).Count(&count)
if count != 0 {
t.Errorf("Message count after delete = %d, want 0", count)
}
}
func TestInitDB_ModerationLog(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
log := ModerationLog{
Action: "mute",
Target: "user1",
Moderator: "admin",
Reason: "spam",
Timestamp: time.Now(),
}
if err := client.DB.Create(&log).Error; err != nil {
t.Fatalf("Create moderation log error = %v", err)
}
if log.ID == 0 {
t.Error("Create moderation log did not set ID")
}
var count int64
client.DB.Model(&ModerationLog{}).Count(&count)
if count != 1 {
t.Errorf("ModerationLog count = %d, want 1", count)
}
}
func TestInitDB_Media(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
media := Media{
Filename: "test.mp4",
OriginalName: "test.mp4",
Path: "/media/test.mp4",
SizeBytes: 1024,
DurationSeconds: 60,
Status: "ready",
Title: "Test Video",
Tags: "tag1,tag2",
}
if err := client.DB.Create(&media).Error; err != nil {
t.Fatalf("Create media error = %v", err)
}
if media.ID == 0 {
t.Error("Create media did not set ID")
}
// Verify update
client.DB.Model(&media).Update("status", "needs_normalization")
var updated Media
client.DB.First(&updated, media.ID)
if updated.Status != "needs_normalization" {
t.Errorf("updated.Status = %q, want needs_normalization", updated.Status)
}
}
func TestInitDB_ScheduleOverride(t *testing.T) {
client, _, err := InitDB("sqlite://:memory:")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
now := time.Now()
ov := ScheduleOverride{
MediaID: 1,
StartTime: now,
EndTime: now.Add(1 * time.Hour),
Reason: "special event",
}
if err := client.DB.Create(&ov).Error; err != nil {
t.Fatalf("Create schedule override error = %v", err)
}
if ov.ID == 0 {
t.Error("Create schedule override did not set ID")
}
// Conflict detection
var conflict int64
client.DB.Model(&ScheduleOverride{}).
Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime).
Count(&conflict)
if conflict != 1 {
t.Errorf("conflict count = %d, want 1", conflict)
}
}
func TestInitDB_EmptyURL(t *testing.T) {
// Empty URL should default to SQLite chat.db
// This creates a file, clean up after
client, driver, err := InitDB("")
if err != nil {
t.Fatalf("InitDB(\"\") error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB(\"\") driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB(\"\") client = nil")
}
}
func TestInitDB_SQLitePath(t *testing.T) {
client, driver, err := InitDB("sqlite://:memory:?cache=shared")
if err != nil {
t.Fatalf("InitDB() error = %v", err)
}
if driver != "sqlite" {
t.Errorf("InitDB() driver = %q, want sqlite", driver)
}
if client == nil {
t.Fatal("InitDB() client = nil")
}
}
+64 -64
View File
@@ -1,65 +1,65 @@
package db
import (
"time"
)
// Message represents a chat message (persisted in SQL DB per ADR-006)
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Room string `json:"room"`
Username string `json:"username"`
Text string `json:"text"`
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"` // "public", "private", "system", "moderation"
}
// ModerationLog for audit trail (immutable per ADR-005)
type ModerationLog struct {
ID uint `gorm:"primarykey" json:"id"`
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
Target string `json:"target"`
Moderator string `json:"moderator"`
Reason string `json:"reason"`
Timestamp time.Time `json:"timestamp"`
}
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
type Media struct {
ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"index" json:"filename"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
DurationSeconds int `json:"durationSeconds"`
Status string `gorm:"index" json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
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"`
MediaID uint `json:"mediaId"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"`
}
// Presence tracks online users per room (using Redis per ADR-007)
type Presence struct {
Username string `json:"username"`
Room string `json:"room"`
Online bool `json:"online"`
package db
import (
"time"
)
// Message represents a chat message (persisted in SQL DB per ADR-006)
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Room string `json:"room"`
Username string `json:"username"`
Text string `json:"text"`
Timestamp time.Time `json:"timestamp"`
Type string `json:"type"` // "public", "private", "system", "moderation"
}
// ModerationLog for audit trail (immutable per ADR-005)
type ModerationLog struct {
ID uint `gorm:"primarykey" json:"id"`
Action string `json:"action"` // "mute", "ban", "delete", "timeout"
Target string `json:"target"`
Moderator string `json:"moderator"`
Reason string `json:"reason"`
Timestamp time.Time `json:"timestamp"`
}
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
type Media struct {
ID uint `gorm:"primarykey" json:"id"`
Filename string `gorm:"index" json:"filename"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
DurationSeconds int `json:"durationSeconds"`
Status string `gorm:"index" json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
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"`
MediaID uint `json:"mediaId"`
StartTime time.Time `json:"startTime"`
EndTime time.Time `json:"endTime"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"createdAt"`
}
// Presence tracks online users per room (using Redis per ADR-007)
type Presence struct {
Username string `json:"username"`
Room string `json:"room"`
Online bool `json:"online"`
}
+162 -162
View File
@@ -1,163 +1,163 @@
package db
import (
"context"
"log"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// RedisWrapper wraps go-redis client to implement RedisClient interface.
type RedisWrapper struct {
client *redis.Client
}
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
return &RedisWrapper{client: client}
}
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
// Returns nil if Redis is unavailable (non-fatal).
func InitRedis(redisAddress string) *RedisWrapper {
var options *redis.Options
var err error
if strings.Contains(redisAddress, "://") {
options, err = redis.ParseURL(redisAddress)
if err != nil {
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
return nil
}
} else {
options = &redis.Options{Addr: redisAddress}
}
client := redis.NewClient(options)
if err := client.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
client.Close()
// Start auto-reconnect goroutine
go redisReconnectLoop(redisAddress, options)
return nil
}
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
return &RedisWrapper{client: client}
}
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
backoff := 2 * time.Second
maxBackoff := 60 * time.Second
for {
time.Sleep(backoff)
testClient := redis.NewClient(baseOptions)
if err := testClient.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
testClient.Close()
continue
}
// Reconnected — caller must replace their reference.
// This is a best-effort background reconnect; the wrapper
// returned by InitRedis remains nil until a new InitRedis call.
log.Println("Redis reconnected successfully (background)")
testClient.Close()
return
}
}
// Ping checks Redis connectivity.
func (r *RedisWrapper) Ping(ctx context.Context) error {
if r == nil || r.client == nil {
return redis.ErrClosed
}
return r.client.Ping(ctx).Err()
}
// Set stores a key with TTL.
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return r.client.Set(ctx, key, value, expiration).Err()
}
// Get retrieves a key.
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
return r.client.Get(ctx, key).Result()
}
// Del removes keys.
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err()
}
// Incr increments a key.
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
return r.client.Incr(ctx, key).Result()
}
// Decr decrements a key.
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
return r.client.Decr(ctx, key).Result()
}
// Expire sets TTL on a key.
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
return r.client.Expire(ctx, key, expiration).Err()
}
// Exists checks if keys exist.
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
return r.client.Exists(ctx, keys...).Result()
}
// Publish publishes a message to a channel.
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
return r.client.Publish(ctx, channel, message).Err()
}
// PSubscribe subscribes to channels by pattern.
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
ps := r.client.PSubscribe(ctx, channels...)
return &redisPubSubWrapper{ps: ps}
}
// Scan scans keys matching a pattern.
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return r.client.Scan(ctx, cursor, match, count).Result()
}
// Info returns Redis server info.
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
return r.client.Info(ctx, sections...).Result()
}
// Close closes the Redis connection.
func (r *RedisWrapper) Close() error {
if r == nil || r.client == nil {
return nil
}
return r.client.Close()
}
// redisPubSubWrapper wraps go-redis PubSub.
type redisPubSubWrapper struct {
ps *redis.PubSub
}
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
msg, err := w.ps.ReceiveMessage(ctx)
if err != nil {
return nil, err
}
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
}
func (w *redisPubSubWrapper) Close() error {
return w.ps.Close()
package db
import (
"context"
"log"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
// RedisWrapper wraps go-redis client to implement RedisClient interface.
type RedisWrapper struct {
client *redis.Client
}
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
return &RedisWrapper{client: client}
}
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
// Returns nil if Redis is unavailable (non-fatal).
func InitRedis(redisAddress string) *RedisWrapper {
var options *redis.Options
var err error
if strings.Contains(redisAddress, "://") {
options, err = redis.ParseURL(redisAddress)
if err != nil {
log.Printf("Redis configuration warning: %v (rate limiting and presence will be disabled)", err)
return nil
}
} else {
options = &redis.Options{Addr: redisAddress}
}
client := redis.NewClient(options)
if err := client.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
client.Close()
// Start auto-reconnect goroutine
go redisReconnectLoop(redisAddress, options)
return nil
}
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
return &RedisWrapper{client: client}
}
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
backoff := 2 * time.Second
maxBackoff := 60 * time.Second
for {
time.Sleep(backoff)
testClient := redis.NewClient(baseOptions)
if err := testClient.Ping(context.Background()).Err(); err != nil {
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
testClient.Close()
continue
}
// Reconnected — caller must replace their reference.
// This is a best-effort background reconnect; the wrapper
// returned by InitRedis remains nil until a new InitRedis call.
log.Println("Redis reconnected successfully (background)")
testClient.Close()
return
}
}
// Ping checks Redis connectivity.
func (r *RedisWrapper) Ping(ctx context.Context) error {
if r == nil || r.client == nil {
return redis.ErrClosed
}
return r.client.Ping(ctx).Err()
}
// Set stores a key with TTL.
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return r.client.Set(ctx, key, value, expiration).Err()
}
// Get retrieves a key.
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
return r.client.Get(ctx, key).Result()
}
// Del removes keys.
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err()
}
// Incr increments a key.
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
return r.client.Incr(ctx, key).Result()
}
// Decr decrements a key.
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
return r.client.Decr(ctx, key).Result()
}
// Expire sets TTL on a key.
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
return r.client.Expire(ctx, key, expiration).Err()
}
// Exists checks if keys exist.
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
return r.client.Exists(ctx, keys...).Result()
}
// Publish publishes a message to a channel.
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
return r.client.Publish(ctx, channel, message).Err()
}
// PSubscribe subscribes to channels by pattern.
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
ps := r.client.PSubscribe(ctx, channels...)
return &redisPubSubWrapper{ps: ps}
}
// Scan scans keys matching a pattern.
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return r.client.Scan(ctx, cursor, match, count).Result()
}
// Info returns Redis server info.
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
return r.client.Info(ctx, sections...).Result()
}
// Close closes the Redis connection.
func (r *RedisWrapper) Close() error {
if r == nil || r.client == nil {
return nil
}
return r.client.Close()
}
// redisPubSubWrapper wraps go-redis PubSub.
type redisPubSubWrapper struct {
ps *redis.PubSub
}
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
msg, err := w.ps.ReceiveMessage(ctx)
if err != nil {
return nil, err
}
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
}
func (w *redisPubSubWrapper) Close() error {
return w.ps.Close()
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+110 -110
View File
@@ -1,111 +1,111 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
"chat/src/auth"
"chat/src/config"
"chat/src/db"
"chat/src/handlers"
"chat/src/metrics"
"chat/src/ws"
)
func generateShortID() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}
func main() {
instanceID := fmt.Sprintf("chat-%s", generateShortID())
rateLimitMax := config.RateLimitMax()
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())
// Initialize DB
dbClient, dbDriver, err := db.InitDB(config.DatabaseURL())
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Initialize session store
sessionStore := auth.NewSessionStore()
userSessions := auth.NewUserSessionStore()
// Initialize WebSocket hub
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
// Start Redis pub/sub subscriber
go hub.SubscribeRoomMessages()
log.Printf("Instance ID: %s", instanceID)
// Build dependencies
deps := &handlers.Dependencies{
DB: dbClient.DB,
Redis: redisWrapper,
Hub: hub,
SessionStore: sessionStore,
UserSessions: userSessions,
DBDriver: dbDriver,
}
// Auth middleware
requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName)
// 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)))
http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(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/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))))
http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
port := config.ChatPort()
log.Printf("Chat service starting on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"net/http"
"chat/src/auth"
"chat/src/config"
"chat/src/db"
"chat/src/handlers"
"chat/src/metrics"
"chat/src/ws"
)
func generateShortID() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}
func main() {
instanceID := fmt.Sprintf("chat-%s", generateShortID())
rateLimitMax := config.RateLimitMax()
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())
// Initialize DB
dbClient, dbDriver, err := db.InitDB(config.DatabaseURL())
if err != nil {
log.Fatalf("Failed to initialize database: %v", err)
}
// Initialize session store
sessionStore := auth.NewSessionStore()
userSessions := auth.NewUserSessionStore()
// Initialize WebSocket hub
hub := ws.NewHub(dbClient.DB, redisWrapper, instanceID, rateLimitMax, rateLimitWindow, config.AdminUsername())
// Start Redis pub/sub subscriber
go hub.SubscribeRoomMessages()
log.Printf("Instance ID: %s", instanceID)
// Build dependencies
deps := &handlers.Dependencies{
DB: dbClient.DB,
Redis: redisWrapper,
Hub: hub,
SessionStore: sessionStore,
UserSessions: userSessions,
DBDriver: dbDriver,
}
// Auth middleware
requireAdmin := auth.RequireAdmin(sessionStore, config.AdminSessionCookieName)
// 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)))
http.HandleFunc("/api/admin/overview", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminOverview(deps))))
http.HandleFunc("/api/admin/playlist", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminPlaylist(deps))))
http.HandleFunc("/api/admin/moderation/logs", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationLogs(deps))))
http.HandleFunc("/api/admin/moderation/actions", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminModerationAction(deps))))
http.HandleFunc("/api/admin/analytics", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminAnalytics(deps))))
http.HandleFunc("/api/admin/system", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminSystemSummary(deps))))
http.HandleFunc("/api/admin/config", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminConfig(deps))))
http.HandleFunc("/api/admin/restart", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminRestart(deps))))
http.HandleFunc("/api/admin/users", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminUsers(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/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))))
http.HandleFunc("/api/admin/media/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminMediaByID(deps))))
http.HandleFunc("/api/admin/chat-log/bulk", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogBulk(deps))))
http.HandleFunc("/api/admin/chat-log/export", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogExport(deps))))
http.HandleFunc("/api/admin/chat-log/", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLogByID(deps))))
http.HandleFunc("/api/admin/chat-log", metrics.WrapRequestTiming(requireAdmin(handlers.HandleAdminChatLog(deps))))
http.HandleFunc("/metrics", metrics.HandleMetrics(dbClient.DB, redisWrapper, hub, sessionStore, dbDriver, instanceID))
http.HandleFunc("/health", metrics.WrapRequestTiming(metrics.HandleHealth()))
http.HandleFunc("/", metrics.WrapRequestTiming(metrics.HandleNotFound()))
port := config.ChatPort()
log.Printf("Chat service starting on :%s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
+449 -449
View File
@@ -1,450 +1,450 @@
package media
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// FormatDuration converts seconds to HH:MM:SS string.
func FormatDuration(seconds int) string {
if seconds < 0 {
seconds = 0
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
remainingSeconds := seconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
// NormalizationResult holds ffprobe validation output for admin uploads.
type NormalizationResult struct {
Valid bool `json:"valid"`
Errors []string `json:"errors"`
}
// ValidateNormalization runs ffprobe against a media file and checks
// conformance with the Linear Void normalization profile (ADR-001).
func ValidateNormalization(mediaPath string) NormalizationResult {
result := NormalizationResult{Valid: true, Errors: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx,
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
}
if err != nil {
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
}
var probe struct {
Streams []struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &probe); err != nil {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
}
var hasVideo, hasAudio bool
for _, s := range probe.Streams {
switch s.CodecType {
case "video":
hasVideo = true
if s.CodecName != "h264" {
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
}
if s.RFrameRate != "30/1" {
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
}
if s.Width != 1280 && s.Width != 1920 {
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
}
if s.Height != 720 && s.Height != 1080 {
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
}
if s.PixFmt != "yuv420p" {
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
}
case "audio":
hasAudio = true
if s.CodecName != "aac" {
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
}
if s.SampleRate != "48000" {
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
}
}
}
if !hasVideo {
result.Errors = append(result.Errors, "no video stream found")
}
if !hasAudio {
result.Errors = append(result.Errors, "no audio stream found")
}
if len(result.Errors) > 0 {
result.Valid = false
}
return result
}
// ProbeDurationSeconds returns the duration of a media file in seconds.
func ProbeDurationSeconds(mediaPath string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := exec.CommandContext(
ctx,
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("ffprobe timed out")
}
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
rawDuration := strings.TrimSpace(string(out))
if rawDuration == "" {
return 0, fmt.Errorf("ffprobe returned empty duration")
}
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
}
if secondsFloat < 0 {
secondsFloat = 0
}
seconds := int(secondsFloat + 0.5)
if secondsFloat > 0 && seconds == 0 {
seconds = 1
}
return seconds, nil
}
// Media status values (ADR-006 media library states).
const (
StatusReady = "ready"
StatusNeedsNormalization = "needs_normalization"
StatusNormalizing = "normalizing"
StatusNormalizationFailed = "normalization_failed"
)
// runCommand executes an external command and returns its combined output on failure.
// It is a package variable so tests can substitute a stub.
var runCommand = func(ctx context.Context, name string, args ...string) error {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
func normalizationArgs(srcPath, dstPath string) []string {
return []string{
"-y",
"-i", srcPath,
"-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
"-pix_fmt", "yuv420p",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
"-movflags", "+faststart",
dstPath,
}
}
// normalizeVideo transcodes srcPath to the Linear Void normalization profile,
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
return 0, 0, err
}
if res := ValidateNormalization(dstPath); !res.Valid {
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
}
duration, err := ProbeDurationSeconds(dstPath)
if err != nil {
return 0, 0, fmt.Errorf("probe duration failed: %w", err)
}
info, err := os.Stat(dstPath)
if err != nil {
return 0, 0, fmt.Errorf("stat output failed: %w", err)
}
return duration, info.Size(), nil
}
// NormalizeVideo is the injectable normalization entry point.
var NormalizeVideo = normalizeVideo
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
func DetectMime(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, err := f.Read(head)
if err != nil && err != io.EOF {
return "", err
}
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
}
// IsAllowedExtension checks if the file extension is a supported video format.
func IsAllowedExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
return true
default:
return false
}
}
// IsAllowedMime checks if the MIME type is a supported video type.
func IsAllowedMime(mime string) bool {
mime = strings.TrimSpace(strings.ToLower(mime))
if mime == "" {
return true
}
if strings.HasPrefix(mime, "video/") {
return true
}
return mime == "application/octet-stream"
}
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
func UniqueTargetPath(mediaDir, fileName string) string {
baseName := filepath.Base(fileName)
ext := filepath.Ext(baseName)
nameOnly := strings.TrimSuffix(baseName, ext)
candidate := filepath.Join(mediaDir, baseName)
for i := 1; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
}
}
// FileEntry represents a media file for API responses.
type FileEntry struct {
ID uint `json:"id"`
Name string `json:"name"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
Size int64 `json:"size"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt string `json:"uploadedAt"`
ModifiedAt string `json:"modifiedAt"`
}
// MediaRecord is the interface for media DB records.
type MediaRecord interface {
GetID() uint
GetFilename() string
GetOriginalName() string
GetPath() string
GetSizeBytes() int64
GetDurationSeconds() int
GetStatus() string
GetTitle() string
GetTags() string
GetUploadedAt() time.Time
GetUpdatedAt() time.Time
}
// ListFiles returns all media files sorted by upload date descending.
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
type record struct {
ID uint
Filename string
OriginalName string
Path string
SizeBytes int64
DurationSeconds int
Status string
Title string
Tags string
UploadedAt time.Time
UpdatedAt time.Time
}
var records []record
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
return nil, err
}
files := make([]FileEntry, 0, len(records))
for _, r := range records {
files = append(files, FileEntry{
ID: r.ID,
Name: r.Filename,
OriginalName: r.OriginalName,
Path: r.Path,
Size: r.SizeBytes,
DurationSeconds: r.DurationSeconds,
Runtime: FormatDuration(r.DurationSeconds),
Status: r.Status,
Title: r.Title,
Tags: r.Tags,
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
if files[i].UploadedAt == files[j].UploadedAt {
return files[i].Name < files[j].Name
}
return files[i].UploadedAt > files[j].UploadedAt
})
return files, nil
}
// ParsePlaylistEntries parses playlist content into schedule entries.
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
}
// ParsePlaylist parses playlist text content into entries.
func ParsePlaylist(content string) []ScheduleEntry {
entries := []ScheduleEntry{}
lines := strings.Split(content, "\n")
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
entries = append(entries, ScheduleEntry{
Index: len(entries) + 1,
Path: line,
Label: filepath.Base(line),
})
}
return entries
}
// UploadSessionDir returns the directory for an upload session's chunks.
func UploadSessionDir(mediaDir, uploadID string) string {
return filepath.Join(mediaDir, ".uploads", uploadID)
}
// UploadSessionMetaPath returns the path to the session metadata file.
func UploadSessionMetaPath(mediaDir, uploadID string) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
}
// UploadChunkPath returns the path for a specific chunk file.
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
}
// CountUploadedChunks counts how many chunks exist for a session.
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
uploaded := 0
for i := 0; i < totalChunks; i++ {
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
uploaded++
}
}
return uploaded
}
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
func ValidateUploadID(uploadID string) bool {
if len(uploadID) < 16 || len(uploadID) > 128 {
return false
}
for _, r := range uploadID {
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
return false
}
}
return true
}
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
func GetMediaIDFromPath(path, prefix string) (uint, error) {
if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf("invalid media path")
}
idPart := strings.TrimPrefix(path, prefix)
idPart = strings.TrimSpace(idPart)
if idPart == "" || strings.Contains(idPart, "/") {
return 0, fmt.Errorf("invalid media id")
}
id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid media id")
}
return uint(id), nil
package media
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
// FormatDuration converts seconds to HH:MM:SS string.
func FormatDuration(seconds int) string {
if seconds < 0 {
seconds = 0
}
hours := seconds / 3600
minutes := (seconds % 3600) / 60
remainingSeconds := seconds % 60
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
}
// NormalizationResult holds ffprobe validation output for admin uploads.
type NormalizationResult struct {
Valid bool `json:"valid"`
Errors []string `json:"errors"`
}
// ValidateNormalization runs ffprobe against a media file and checks
// conformance with the Linear Void normalization profile (ADR-001).
func ValidateNormalization(mediaPath string) NormalizationResult {
result := NormalizationResult{Valid: true, Errors: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx,
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
}
if err != nil {
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
}
var probe struct {
Streams []struct {
CodecName string `json:"codec_name"`
CodecType string `json:"codec_type"`
Width int `json:"width"`
Height int `json:"height"`
RFrameRate string `json:"r_frame_rate"`
PixFmt string `json:"pix_fmt"`
SampleRate string `json:"sample_rate"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &probe); err != nil {
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
}
var hasVideo, hasAudio bool
for _, s := range probe.Streams {
switch s.CodecType {
case "video":
hasVideo = true
if s.CodecName != "h264" {
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
}
if s.RFrameRate != "30/1" {
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
}
if s.Width != 1280 && s.Width != 1920 {
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
}
if s.Height != 720 && s.Height != 1080 {
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
}
if s.PixFmt != "yuv420p" {
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
}
case "audio":
hasAudio = true
if s.CodecName != "aac" {
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
}
if s.SampleRate != "48000" {
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
}
}
}
if !hasVideo {
result.Errors = append(result.Errors, "no video stream found")
}
if !hasAudio {
result.Errors = append(result.Errors, "no audio stream found")
}
if len(result.Errors) > 0 {
result.Valid = false
}
return result
}
// ProbeDurationSeconds returns the duration of a media file in seconds.
func ProbeDurationSeconds(mediaPath string) (int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
out, err := exec.CommandContext(
ctx,
"ffprobe",
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
mediaPath,
).CombinedOutput()
if ctx.Err() == context.DeadlineExceeded {
return 0, fmt.Errorf("ffprobe timed out")
}
if err != nil {
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
}
rawDuration := strings.TrimSpace(string(out))
if rawDuration == "" {
return 0, fmt.Errorf("ffprobe returned empty duration")
}
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
}
if secondsFloat < 0 {
secondsFloat = 0
}
seconds := int(secondsFloat + 0.5)
if secondsFloat > 0 && seconds == 0 {
seconds = 1
}
return seconds, nil
}
// Media status values (ADR-006 media library states).
const (
StatusReady = "ready"
StatusNeedsNormalization = "needs_normalization"
StatusNormalizing = "normalizing"
StatusNormalizationFailed = "normalization_failed"
)
// runCommand executes an external command and returns its combined output on failure.
// It is a package variable so tests can substitute a stub.
var runCommand = func(ctx context.Context, name string, args ...string) error {
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("%s failed: %v (%s)", name, err, strings.TrimSpace(string(out)))
}
return nil
}
// normalizationArgs builds the ffmpeg arguments for the Linear Void normalization profile.
func normalizationArgs(srcPath, dstPath string) []string {
return []string{
"-y",
"-i", srcPath,
"-map", "0:v:0", "-map", "0:a:0?",
"-vf", "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,fps=30,format=yuv420p",
"-c:v", "libx264", "-preset", "medium", "-crf", "21",
"-pix_fmt", "yuv420p",
"-r", "30", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
"-c:a", "aac", "-b:a", "160k", "-ar", "48000", "-ac", "2",
"-avoid_negative_ts", "make_zero", "-max_muxing_queue_size", "1024",
"-movflags", "+faststart",
dstPath,
}
}
// normalizeVideo transcodes srcPath to the Linear Void normalization profile,
// re-verifies the output with ffprobe, and returns its duration (seconds) and size (bytes).
func normalizeVideo(srcPath, dstPath string) (int, int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
defer cancel()
if err := runCommand(ctx, "ffmpeg", normalizationArgs(srcPath, dstPath)...); err != nil {
return 0, 0, err
}
if res := ValidateNormalization(dstPath); !res.Valid {
return 0, 0, fmt.Errorf("normalized output failed validation: %s", strings.Join(res.Errors, "; "))
}
duration, err := ProbeDurationSeconds(dstPath)
if err != nil {
return 0, 0, fmt.Errorf("probe duration failed: %w", err)
}
info, err := os.Stat(dstPath)
if err != nil {
return 0, 0, fmt.Errorf("stat output failed: %w", err)
}
return duration, info.Size(), nil
}
// NormalizeVideo is the injectable normalization entry point.
var NormalizeVideo = normalizeVideo
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
func DetectMime(filePath string) (string, error) {
f, err := os.Open(filePath)
if err != nil {
return "", err
}
defer f.Close()
head := make([]byte, 512)
n, err := f.Read(head)
if err != nil && err != io.EOF {
return "", err
}
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
}
// IsAllowedExtension checks if the file extension is a supported video format.
func IsAllowedExtension(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
switch ext {
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
return true
default:
return false
}
}
// IsAllowedMime checks if the MIME type is a supported video type.
func IsAllowedMime(mime string) bool {
mime = strings.TrimSpace(strings.ToLower(mime))
if mime == "" {
return true
}
if strings.HasPrefix(mime, "video/") {
return true
}
return mime == "application/octet-stream"
}
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
func UniqueTargetPath(mediaDir, fileName string) string {
baseName := filepath.Base(fileName)
ext := filepath.Ext(baseName)
nameOnly := strings.TrimSuffix(baseName, ext)
candidate := filepath.Join(mediaDir, baseName)
for i := 1; ; i++ {
if _, err := os.Stat(candidate); os.IsNotExist(err) {
return candidate
}
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
}
}
// FileEntry represents a media file for API responses.
type FileEntry struct {
ID uint `json:"id"`
Name string `json:"name"`
OriginalName string `json:"originalName"`
Path string `json:"path"`
Size int64 `json:"size"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
Tags string `json:"tags"`
UploadedAt string `json:"uploadedAt"`
ModifiedAt string `json:"modifiedAt"`
}
// MediaRecord is the interface for media DB records.
type MediaRecord interface {
GetID() uint
GetFilename() string
GetOriginalName() string
GetPath() string
GetSizeBytes() int64
GetDurationSeconds() int
GetStatus() string
GetTitle() string
GetTags() string
GetUploadedAt() time.Time
GetUpdatedAt() time.Time
}
// ListFiles returns all media files sorted by upload date descending.
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
type record struct {
ID uint
Filename string
OriginalName string
Path string
SizeBytes int64
DurationSeconds int
Status string
Title string
Tags string
UploadedAt time.Time
UpdatedAt time.Time
}
var records []record
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
return nil, err
}
files := make([]FileEntry, 0, len(records))
for _, r := range records {
files = append(files, FileEntry{
ID: r.ID,
Name: r.Filename,
OriginalName: r.OriginalName,
Path: r.Path,
Size: r.SizeBytes,
DurationSeconds: r.DurationSeconds,
Runtime: FormatDuration(r.DurationSeconds),
Status: r.Status,
Title: r.Title,
Tags: r.Tags,
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
})
}
sort.Slice(files, func(i, j int) bool {
if files[i].UploadedAt == files[j].UploadedAt {
return files[i].Name < files[j].Name
}
return files[i].UploadedAt > files[j].UploadedAt
})
return files, nil
}
// ParsePlaylistEntries parses playlist content into schedule entries.
type ScheduleEntry struct {
Index int `json:"index"`
Path string `json:"path"`
Label string `json:"label"`
DurationSeconds int `json:"durationSeconds"`
Runtime string `json:"runtime"`
Status string `json:"status"`
Title string `json:"title"`
}
// ParsePlaylist parses playlist text content into entries.
func ParsePlaylist(content string) []ScheduleEntry {
entries := []ScheduleEntry{}
lines := strings.Split(content, "\n")
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "file ") {
line = strings.TrimSpace(strings.TrimPrefix(line, "file "))
line = strings.Trim(line, "'\"")
}
entries = append(entries, ScheduleEntry{
Index: len(entries) + 1,
Path: line,
Label: filepath.Base(line),
})
}
return entries
}
// UploadSessionDir returns the directory for an upload session's chunks.
func UploadSessionDir(mediaDir, uploadID string) string {
return filepath.Join(mediaDir, ".uploads", uploadID)
}
// UploadSessionMetaPath returns the path to the session metadata file.
func UploadSessionMetaPath(mediaDir, uploadID string) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
}
// UploadChunkPath returns the path for a specific chunk file.
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
}
// CountUploadedChunks counts how many chunks exist for a session.
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
uploaded := 0
for i := 0; i < totalChunks; i++ {
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
uploaded++
}
}
return uploaded
}
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
func ValidateUploadID(uploadID string) bool {
if len(uploadID) < 16 || len(uploadID) > 128 {
return false
}
for _, r := range uploadID {
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
return false
}
}
return true
}
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
func GetMediaIDFromPath(path, prefix string) (uint, error) {
if !strings.HasPrefix(path, prefix) {
return 0, fmt.Errorf("invalid media path")
}
idPart := strings.TrimPrefix(path, prefix)
idPart = strings.TrimSpace(idPart)
if idPart == "" || strings.Contains(idPart, "/") {
return 0, fmt.Errorf("invalid media id")
}
id, err := strconv.ParseUint(idPart, 10, 64)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid media id")
}
return uint(id), nil
}
+245 -245
View File
@@ -1,246 +1,246 @@
package media
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
seconds int
expected string
}{
{"zero", 0, "00:00:00"},
{"one second", 1, "00:00:01"},
{"one minute", 60, "00:01:00"},
{"one hour", 3600, "01:00:00"},
{"mixed", 3661, "01:01:01"},
{"large", 86399, "23:59:59"},
{"negative", -5, "00:00:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FormatDuration(tt.seconds); got != tt.expected {
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
}
})
}
}
func TestIsAllowedExtension(t *testing.T) {
tests := []struct {
name string
expected bool
}{
{"video.mp4", true},
{"video.mov", true},
{"video.mkv", true},
{"video.webm", true},
{"video.m4v", true},
{"video.ts", true},
{"video.MP4", true},
{"video.avi", false},
{"video.flv", false},
{"video.wmv", false},
{"video", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAllowedExtension(tt.name); got != tt.expected {
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
}
})
}
}
func TestIsAllowedMime(t *testing.T) {
tests := []struct {
mime string
expected bool
}{
{"video/mp4", true},
{"video/quicktime", true},
{"video/x-matroska", true},
{"video/webm", true},
{"application/octet-stream", true},
{"", true},
{"image/jpeg", false},
{"audio/mpeg", false},
{"text/plain", false},
}
for _, tt := range tests {
t.Run(tt.mime, func(t *testing.T) {
if got := IsAllowedMime(tt.mime); got != tt.expected {
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
}
})
}
}
func TestValidateUploadID(t *testing.T) {
tests := []struct {
id string
expected bool
}{
{"abcdef0123456789", true}, // 16 hex chars
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
{"abc", false}, // too short
{"abcdef012345678", false}, // 15 chars (too short)
{"abcdef0123456789g", false}, // non-hex char
{"ABCDEF0123456789", false}, // uppercase (not allowed)
{"", false},
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
if got := ValidateUploadID(tt.id); got != tt.expected {
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
}
})
}
}
func TestParsePlaylist(t *testing.T) {
tests := []struct {
name string
content string
expected int
first string
}{
{
name: "simple",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "empty",
content: "",
expected: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entries := ParsePlaylist(tt.content)
if len(entries) != tt.expected {
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
}
if tt.expected > 0 && entries[0].Path != tt.first {
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
}
// Check indices
for i, e := range entries {
if e.Index != i+1 {
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
}
}
})
}
}
func TestGetMediaIDFromPath(t *testing.T) {
tests := []struct {
path string
prefix string
expected uint
expectErr bool
}{
{"/api/admin/media/123", "/api/admin/media/", 123, false},
{"/api/admin/media/1", "/api/admin/media/", 1, false},
{"/api/admin/media/", "/api/admin/media/", 0, true},
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
{"/api/admin/media/0", "/api/admin/media/", 0, true},
{"/other/path", "/api/admin/media/", 0, true},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
if tt.expectErr && err == nil {
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
}
if !tt.expectErr && err != nil {
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
}
if id != tt.expected {
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
}
})
}
}
func TestUniqueTargetPath(t *testing.T) {
// Test that the function returns a path with the mediaDir prefix
// and the correct filename. We can't easily test collision behavior
// without filesystem setup, but we can verify the basic path.
expected := filepath.Join("/tmp/media", "video.mp4")
path := UniqueTargetPath("/tmp/media", "video.mp4")
if path != expected {
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
}
}
func TestNormalizationArgs(t *testing.T) {
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
joined := strings.Join(args, " ")
for _, want := range []string{
"-map 0:v:0",
"-map 0:a:0?",
"-c:v libx264",
"-r 30",
"-g 60",
"-keyint_min 60",
"-pix_fmt yuv420p",
"-c:a aac",
"-ar 48000",
"-ac 2",
"-avoid_negative_ts make_zero",
"/out/movie.normalized.mp4",
} {
if !strings.Contains(joined, want) {
t.Errorf("normalization args missing %q: %v", want, args)
}
}
}
func TestNormalizeVideo_FFmpegError(t *testing.T) {
origRun := runCommand
t.Cleanup(func() { runCommand = origRun })
runCommand = func(ctx context.Context, name string, args ...string) error {
if name != "ffmpeg" {
t.Errorf("runCommand name = %q, want ffmpeg", name)
}
return fmt.Errorf("simulated ffmpeg failure")
}
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
t.Fatal("expected error when ffmpeg fails")
}
package media
import (
"context"
"fmt"
"path/filepath"
"strings"
"testing"
)
func TestFormatDuration(t *testing.T) {
tests := []struct {
name string
seconds int
expected string
}{
{"zero", 0, "00:00:00"},
{"one second", 1, "00:00:01"},
{"one minute", 60, "00:01:00"},
{"one hour", 3600, "01:00:00"},
{"mixed", 3661, "01:01:01"},
{"large", 86399, "23:59:59"},
{"negative", -5, "00:00:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FormatDuration(tt.seconds); got != tt.expected {
t.Errorf("FormatDuration(%d) = %q, want %q", tt.seconds, got, tt.expected)
}
})
}
}
func TestIsAllowedExtension(t *testing.T) {
tests := []struct {
name string
expected bool
}{
{"video.mp4", true},
{"video.mov", true},
{"video.mkv", true},
{"video.webm", true},
{"video.m4v", true},
{"video.ts", true},
{"video.MP4", true},
{"video.avi", false},
{"video.flv", false},
{"video.wmv", false},
{"video", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsAllowedExtension(tt.name); got != tt.expected {
t.Errorf("IsAllowedExtension(%q) = %v, want %v", tt.name, got, tt.expected)
}
})
}
}
func TestIsAllowedMime(t *testing.T) {
tests := []struct {
mime string
expected bool
}{
{"video/mp4", true},
{"video/quicktime", true},
{"video/x-matroska", true},
{"video/webm", true},
{"application/octet-stream", true},
{"", true},
{"image/jpeg", false},
{"audio/mpeg", false},
{"text/plain", false},
}
for _, tt := range tests {
t.Run(tt.mime, func(t *testing.T) {
if got := IsAllowedMime(tt.mime); got != tt.expected {
t.Errorf("IsAllowedMime(%q) = %v, want %v", tt.mime, got, tt.expected)
}
})
}
}
func TestValidateUploadID(t *testing.T) {
tests := []struct {
id string
expected bool
}{
{"abcdef0123456789", true}, // 16 hex chars
{"abcdef0123456789abcdef0123456789", true}, // 32 hex chars
{"abc", false}, // too short
{"abcdef012345678", false}, // 15 chars (too short)
{"abcdef0123456789g", false}, // non-hex char
{"ABCDEF0123456789", false}, // uppercase (not allowed)
{"", false},
}
for _, tt := range tests {
t.Run(tt.id, func(t *testing.T) {
if got := ValidateUploadID(tt.id); got != tt.expected {
t.Errorf("ValidateUploadID(%q) = %v, want %v", tt.id, got, tt.expected)
}
})
}
}
func TestParsePlaylist(t *testing.T) {
tests := []struct {
name string
content string
expected int
first string
}{
{
name: "simple",
content: "/videos/video1.mp4\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with file prefix",
content: "file /videos/video1.mp4\nfile '/videos/video2.mp4'\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "with comments and blanks",
content: "# comment\n\n/videos/video1.mp4\n\n# another comment\n/videos/video2.mp4\n",
expected: 2,
first: "/videos/video1.mp4",
},
{
name: "empty",
content: "",
expected: 0,
},
{
name: "only comments",
content: "# comment\n# another\n",
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entries := ParsePlaylist(tt.content)
if len(entries) != tt.expected {
t.Errorf("ParsePlaylist() len = %d, want %d", len(entries), tt.expected)
}
if tt.expected > 0 && entries[0].Path != tt.first {
t.Errorf("ParsePlaylist()[0].Path = %q, want %q", entries[0].Path, tt.first)
}
// Check indices
for i, e := range entries {
if e.Index != i+1 {
t.Errorf("ParsePlaylist()[%d].Index = %d, want %d", i, e.Index, i+1)
}
}
})
}
}
func TestGetMediaIDFromPath(t *testing.T) {
tests := []struct {
path string
prefix string
expected uint
expectErr bool
}{
{"/api/admin/media/123", "/api/admin/media/", 123, false},
{"/api/admin/media/1", "/api/admin/media/", 1, false},
{"/api/admin/media/", "/api/admin/media/", 0, true},
{"/api/admin/media/abc", "/api/admin/media/", 0, true},
{"/api/admin/media/0", "/api/admin/media/", 0, true},
{"/other/path", "/api/admin/media/", 0, true},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
id, err := GetMediaIDFromPath(tt.path, tt.prefix)
if tt.expectErr && err == nil {
t.Errorf("GetMediaIDFromPath(%q) expected error, got nil", tt.path)
}
if !tt.expectErr && err != nil {
t.Errorf("GetMediaIDFromPath(%q) error = %v", tt.path, err)
}
if id != tt.expected {
t.Errorf("GetMediaIDFromPath(%q) = %d, want %d", tt.path, id, tt.expected)
}
})
}
}
func TestUniqueTargetPath(t *testing.T) {
// Test that the function returns a path with the mediaDir prefix
// and the correct filename. We can't easily test collision behavior
// without filesystem setup, but we can verify the basic path.
expected := filepath.Join("/tmp/media", "video.mp4")
path := UniqueTargetPath("/tmp/media", "video.mp4")
if path != expected {
t.Errorf("UniqueTargetPath() = %q, want %q", path, expected)
}
}
func TestNormalizationArgs(t *testing.T) {
args := normalizationArgs("/in/movie.mov", "/out/movie.normalized.mp4")
joined := strings.Join(args, " ")
for _, want := range []string{
"-map 0:v:0",
"-map 0:a:0?",
"-c:v libx264",
"-r 30",
"-g 60",
"-keyint_min 60",
"-pix_fmt yuv420p",
"-c:a aac",
"-ar 48000",
"-ac 2",
"-avoid_negative_ts make_zero",
"/out/movie.normalized.mp4",
} {
if !strings.Contains(joined, want) {
t.Errorf("normalization args missing %q: %v", want, args)
}
}
}
func TestNormalizeVideo_FFmpegError(t *testing.T) {
origRun := runCommand
t.Cleanup(func() { runCommand = origRun })
runCommand = func(ctx context.Context, name string, args ...string) error {
if name != "ffmpeg" {
t.Errorf("runCommand name = %q, want ffmpeg", name)
}
return fmt.Errorf("simulated ffmpeg failure")
}
if _, _, err := normalizeVideo("/in/a.mov", "/out/a.normalized.mp4"); err == nil {
t.Fatal("expected error when ffmpeg fails")
}
}
+277 -277
View File
@@ -1,278 +1,278 @@
package metrics
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/db"
"chat/src/ws"
)
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
type requestDurationHistogram struct {
mu sync.Mutex
buckets []float64
counts []uint64
sum float64
total uint64
}
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
return &requestDurationHistogram{
buckets: append([]float64(nil), buckets...),
counts: make([]uint64, len(buckets)+1),
}
}
func (h *requestDurationHistogram) observe(durationSeconds float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.total++
h.sum += durationSeconds
for index, bucket := range h.buckets {
if durationSeconds <= bucket {
h.counts[index]++
return
}
}
h.counts[len(h.counts)-1]++
}
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
h.mu.Lock()
defer h.mu.Unlock()
counts = append([]uint64(nil), h.counts...)
return counts, h.sum, h.total
}
// ObserveRequestDuration records a chat HTTP request duration in seconds.
func ObserveRequestDuration(duration time.Duration) {
chatRequestDurationHistogram.observe(duration.Seconds())
}
// WrapRequestTiming records request duration for the provided handler.
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
next(w, r)
}
}
// HandleMetrics returns Prometheus-format metrics for the chat service.
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var storedMessages int64
if gormDB != nil {
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
}
var moderationActions int64
if gormDB != nil {
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
}
redisEnabled := 0
redisUp := 0
redisClients := 0
redisMemoryUsedBytes := int64(0)
redisMemoryMaxBytes := int64(0)
redisMemoryUsageRatio := 0.0
if redis != nil {
redisEnabled = 1
if err := redis.Ping(context.Background()); err == nil {
redisUp = 1
if info, err := redis.Info(context.Background(), "clients"); err == nil {
for _, line := range strings.Split(info, "\n") {
if strings.HasPrefix(line, "connected_clients:") {
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
redisClients = value
}
break
}
}
}
if info, err := redis.Info(context.Background(), "memory"); err == nil {
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
redisMemoryUsedBytes = value
}
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
redisMemoryMaxBytes = value
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
redisMemoryMaxBytes = value
}
if redisMemoryMaxBytes > 0 {
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
}
}
}
}
connectedClients := 0
dmsSent := int64(0)
if hub != nil {
connectedClients = hub.ConnectedClients()
dmsSent = hub.DMsSent
}
activeSessions := 0
if sessionStore != nil {
activeSessions = sessionStore.CountActive()
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writeLine := func(name string, value any) {
fmt.Fprintf(w, "%s %v\n", name, value)
}
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
writeLine("linearvoid_chat_connected_clients", connectedClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
writeLine("linearvoid_chat_messages_total", storedMessages)
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
writeLine("linearvoid_chat_dms_total", dmsSent)
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
writeLine("linearvoid_chat_redis_up", redisUp)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
counts, sum, total := chatRequestDurationHistogram.snapshot()
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
cumulative := uint64(0)
for index, bucket := range requestDurationBuckets {
cumulative += counts[index]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
}
cumulative += counts[len(counts)-1]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
}
}
// HandleHealth returns a simple health check endpoint.
func HandleHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
}
}
// HandleNotFound returns a 404 JSON response for all unhandled paths.
func HandleNotFound() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`)
}
}
func parseRedisMemoryBytes(info, key string) int64 {
for _, line := range strings.Split(info, "\n") {
if !strings.HasPrefix(line, key+":") {
continue
}
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
if value == "" {
return 0
}
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
return raw
}
return parseHumanBytes(value)
}
return 0
}
func parseHumanBytes(value string) int64 {
value = strings.TrimSpace(strings.ToUpper(value))
if value == "" {
return 0
}
multiplier := float64(1)
suffixes := map[string]float64{
"B": 1,
"K": 1024,
"KB": 1024,
"M": 1024 * 1024,
"MB": 1024 * 1024,
"G": 1024 * 1024 * 1024,
"GB": 1024 * 1024 * 1024,
"T": 1024 * 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024,
}
for suffix, factor := range suffixes {
if strings.HasSuffix(value, suffix) {
multiplier = factor
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
break
}
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return int64(parsed * multiplier)
package metrics
import (
"context"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/db"
"chat/src/ws"
)
var requestDurationBuckets = []float64{0.05, 0.1, 0.25, 0.5, 1, 2, 5}
var chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
type requestDurationHistogram struct {
mu sync.Mutex
buckets []float64
counts []uint64
sum float64
total uint64
}
func newRequestDurationHistogram(buckets []float64) *requestDurationHistogram {
return &requestDurationHistogram{
buckets: append([]float64(nil), buckets...),
counts: make([]uint64, len(buckets)+1),
}
}
func (h *requestDurationHistogram) observe(durationSeconds float64) {
h.mu.Lock()
defer h.mu.Unlock()
h.total++
h.sum += durationSeconds
for index, bucket := range h.buckets {
if durationSeconds <= bucket {
h.counts[index]++
return
}
}
h.counts[len(h.counts)-1]++
}
func (h *requestDurationHistogram) snapshot() (counts []uint64, sum float64, total uint64) {
h.mu.Lock()
defer h.mu.Unlock()
counts = append([]uint64(nil), h.counts...)
return counts, h.sum, h.total
}
// ObserveRequestDuration records a chat HTTP request duration in seconds.
func ObserveRequestDuration(duration time.Duration) {
chatRequestDurationHistogram.observe(duration.Seconds())
}
// WrapRequestTiming records request duration for the provided handler.
func WrapRequestTiming(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
startedAt := time.Now()
defer func() { ObserveRequestDuration(time.Since(startedAt)) }()
next(w, r)
}
}
// HandleMetrics returns Prometheus-format metrics for the chat service.
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var storedMessages int64
if gormDB != nil {
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
}
var moderationActions int64
if gormDB != nil {
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
}
redisEnabled := 0
redisUp := 0
redisClients := 0
redisMemoryUsedBytes := int64(0)
redisMemoryMaxBytes := int64(0)
redisMemoryUsageRatio := 0.0
if redis != nil {
redisEnabled = 1
if err := redis.Ping(context.Background()); err == nil {
redisUp = 1
if info, err := redis.Info(context.Background(), "clients"); err == nil {
for _, line := range strings.Split(info, "\n") {
if strings.HasPrefix(line, "connected_clients:") {
if value, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, "connected_clients:"))); err == nil {
redisClients = value
}
break
}
}
}
if info, err := redis.Info(context.Background(), "memory"); err == nil {
if value := parseRedisMemoryBytes(info, "used_memory_human"); value > 0 {
redisMemoryUsedBytes = value
}
if value := parseRedisMemoryBytes(info, "maxmemory_human"); value > 0 {
redisMemoryMaxBytes = value
} else if value := parseRedisMemoryBytes(info, "maxmemory"); value > 0 {
redisMemoryMaxBytes = value
}
if redisMemoryMaxBytes > 0 {
redisMemoryUsageRatio = math.Min(float64(redisMemoryUsedBytes)/float64(redisMemoryMaxBytes), 1.0)
}
}
}
}
connectedClients := 0
dmsSent := int64(0)
if hub != nil {
connectedClients = hub.ConnectedClients()
dmsSent = hub.DMsSent
}
activeSessions := 0
if sessionStore != nil {
activeSessions = sessionStore.CountActive()
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
writeLine := func(name string, value any) {
fmt.Fprintf(w, "%s %v\n", name, value)
}
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
writeLine("linearvoid_chat_connected_clients", connectedClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
writeLine("linearvoid_chat_messages_total", storedMessages)
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
writeLine("linearvoid_chat_dms_total", dmsSent)
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
writeLine("linearvoid_chat_moderation_actions_total", moderationActions)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_enabled Redis configured for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_enabled gauge")
writeLine("linearvoid_chat_redis_enabled", redisEnabled)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_up Redis reachable from chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_up gauge")
writeLine("linearvoid_chat_redis_up", redisUp)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_connected_clients Redis connected client count seen by chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_connected_clients gauge")
writeLine("linearvoid_chat_redis_connected_clients", redisClients)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_used_bytes Redis used memory reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_used_bytes gauge")
writeLine("linearvoid_chat_redis_memory_used_bytes", redisMemoryUsedBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_max_bytes Redis maxmemory limit reported by INFO memory.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_max_bytes gauge")
writeLine("linearvoid_chat_redis_memory_max_bytes", redisMemoryMaxBytes)
fmt.Fprintln(w, "# HELP linearvoid_chat_redis_memory_usage_ratio Redis memory usage ratio (used / max).")
fmt.Fprintln(w, "# TYPE linearvoid_chat_redis_memory_usage_ratio gauge")
writeLine("linearvoid_chat_redis_memory_usage_ratio", redisMemoryUsageRatio)
fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge")
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
counts, sum, total := chatRequestDurationHistogram.snapshot()
fmt.Fprintln(w, "# HELP linearvoid_chat_request_duration_seconds HTTP request duration for chat API handlers.")
fmt.Fprintln(w, "# TYPE linearvoid_chat_request_duration_seconds histogram")
cumulative := uint64(0)
for index, bucket := range requestDurationBuckets {
cumulative += counts[index]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"%g\"} %d\n", bucket, cumulative)
}
cumulative += counts[len(counts)-1]
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_bucket{le=\"+Inf\"} %d\n", cumulative)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_sum %g\n", sum)
fmt.Fprintf(w, "linearvoid_chat_request_duration_seconds_count %d\n", total)
}
}
// HandleHealth returns a simple health check endpoint.
func HandleHealth() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"status":"healthy","service":"chat","timestamp":"`+time.Now().UTC().Format(time.RFC3339)+`"}`)
}
}
// HandleNotFound returns a 404 JSON response for all unhandled paths.
func HandleNotFound() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`)
}
}
func parseRedisMemoryBytes(info, key string) int64 {
for _, line := range strings.Split(info, "\n") {
if !strings.HasPrefix(line, key+":") {
continue
}
value := strings.TrimSpace(strings.TrimPrefix(line, key+":"))
if value == "" {
return 0
}
if raw, err := strconv.ParseInt(value, 10, 64); err == nil {
return raw
}
return parseHumanBytes(value)
}
return 0
}
func parseHumanBytes(value string) int64 {
value = strings.TrimSpace(strings.ToUpper(value))
if value == "" {
return 0
}
multiplier := float64(1)
suffixes := map[string]float64{
"B": 1,
"K": 1024,
"KB": 1024,
"M": 1024 * 1024,
"MB": 1024 * 1024,
"G": 1024 * 1024 * 1024,
"GB": 1024 * 1024 * 1024,
"T": 1024 * 1024 * 1024 * 1024,
"TB": 1024 * 1024 * 1024 * 1024,
}
for suffix, factor := range suffixes {
if strings.HasSuffix(value, suffix) {
multiplier = factor
value = strings.TrimSpace(strings.TrimSuffix(value, suffix))
break
}
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return int64(parsed * multiplier)
}
+215 -215
View File
@@ -1,216 +1,216 @@
package metrics
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/db"
"chat/src/ws"
)
func TestHandleHealth(t *testing.T) {
handler := HandleHealth()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body)
}
if !strings.Contains(body, `"chat"`) {
t.Errorf("body missing service name: %s", body)
}
}
func TestHandleNotFound(t *testing.T) {
handler := HandleNotFound()
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"not found"`) {
t.Errorf("body missing not found: %s", body)
}
}
func TestHandleMetrics(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(100 * time.Millisecond)
ObserveRequestDuration(750 * time.Millisecond)
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// Add some test data
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
hub.DMsSent = 5
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// Check required metric names
requiredMetrics := []string{
"linearvoid_chat_connected_clients",
"linearvoid_chat_messages_total",
"linearvoid_chat_dms_total",
"linearvoid_chat_moderation_actions_total",
"linearvoid_chat_redis_enabled",
"linearvoid_chat_redis_up",
"linearvoid_chat_redis_connected_clients",
"linearvoid_chat_redis_memory_used_bytes",
"linearvoid_chat_redis_memory_max_bytes",
"linearvoid_chat_redis_memory_usage_ratio",
"linearvoid_chat_admin_sessions_active",
"linearvoid_chat_build_info",
"linearvoid_chat_request_duration_seconds",
}
for _, metric := range requiredMetrics {
if !strings.Contains(body, metric) {
t.Errorf("body missing metric %s", metric)
}
}
// Check build_info has instance_id label
if !strings.Contains(body, `instance_id="test-instance"`) {
t.Error("build_info missing instance_id label")
}
// Check values
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
t.Error("messages_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
t.Error("moderation_actions_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
t.Error("dms_total should be 5")
}
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
t.Error("redis_enabled should be 0 (nil redis)")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
t.Error("request duration histogram should include both observed samples by the 1s bucket")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
t.Error("request duration count should be 2")
}
}
func TestHandleMetrics_WrongMethod(t *testing.T) {
handler := HandleMetrics(nil, nil, nil, nil, "", "")
req := httptest.NewRequest("POST", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", w.Code)
}
}
func TestHandleMetrics_WithRedis(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(250 * time.Millisecond)
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// mockRedis for metrics test
mr := &metricsMockRedis{}
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
t.Error("redis_enabled should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
t.Error("redis_up should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
t.Error("redis_connected_clients should be 5")
}
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
t.Error("redis_memory_used_bytes should be 1048576")
}
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
t.Error("request duration count should be 1")
}
}
// metricsMockRedis is a minimal mock for metrics tests.
type metricsMockRedis struct{}
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
return nil
}
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return nil, 0, nil
}
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
}
package metrics
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"chat/src/auth"
"chat/src/db"
"chat/src/ws"
)
func TestHandleHealth(t *testing.T) {
handler := HandleHealth()
req := httptest.NewRequest("GET", "/health", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"healthy"`) {
t.Errorf("body missing healthy: %s", body)
}
if !strings.Contains(body, `"chat"`) {
t.Errorf("body missing service name: %s", body)
}
}
func TestHandleNotFound(t *testing.T) {
handler := HandleNotFound()
req := httptest.NewRequest("GET", "/nonexistent", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, `"not found"`) {
t.Errorf("body missing not found: %s", body)
}
}
func TestHandleMetrics(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(100 * time.Millisecond)
ObserveRequestDuration(750 * time.Millisecond)
gormDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open test DB: %v", err)
}
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// Add some test data
gormDB.Create(&db.Message{Room: "main", Username: "u1", Text: "hi", Timestamp: time.Now(), Type: "public"})
gormDB.Create(&db.ModerationLog{Action: "mute", Target: "u2", Moderator: "admin", Reason: "spam", Timestamp: time.Now()})
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, nil, "test-instance", 10, 10*time.Second, "admin")
hub.DMsSent = 5
handler := HandleMetrics(gormDB, nil, hub, sessionStore, "sqlite", "test-instance")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// Check required metric names
requiredMetrics := []string{
"linearvoid_chat_connected_clients",
"linearvoid_chat_messages_total",
"linearvoid_chat_dms_total",
"linearvoid_chat_moderation_actions_total",
"linearvoid_chat_redis_enabled",
"linearvoid_chat_redis_up",
"linearvoid_chat_redis_connected_clients",
"linearvoid_chat_redis_memory_used_bytes",
"linearvoid_chat_redis_memory_max_bytes",
"linearvoid_chat_redis_memory_usage_ratio",
"linearvoid_chat_admin_sessions_active",
"linearvoid_chat_build_info",
"linearvoid_chat_request_duration_seconds",
}
for _, metric := range requiredMetrics {
if !strings.Contains(body, metric) {
t.Errorf("body missing metric %s", metric)
}
}
// Check build_info has instance_id label
if !strings.Contains(body, `instance_id="test-instance"`) {
t.Error("build_info missing instance_id label")
}
// Check values
if !strings.Contains(body, "linearvoid_chat_messages_total 1") {
t.Error("messages_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_moderation_actions_total 1") {
t.Error("moderation_actions_total should be 1")
}
if !strings.Contains(body, "linearvoid_chat_dms_total 5") {
t.Error("dms_total should be 5")
}
if !strings.Contains(body, "linearvoid_chat_redis_enabled 0") {
t.Error("redis_enabled should be 0 (nil redis)")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_bucket{le=\"1\"} 2") {
t.Error("request duration histogram should include both observed samples by the 1s bucket")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 2") {
t.Error("request duration count should be 2")
}
}
func TestHandleMetrics_WrongMethod(t *testing.T) {
handler := HandleMetrics(nil, nil, nil, nil, "", "")
req := httptest.NewRequest("POST", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("status = %d, want 405", w.Code)
}
}
func TestHandleMetrics_WithRedis(t *testing.T) {
chatRequestDurationHistogram = newRequestDurationHistogram(requestDurationBuckets)
ObserveRequestDuration(250 * time.Millisecond)
gormDB, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
gormDB.AutoMigrate(&db.Message{}, &db.ModerationLog{})
// mockRedis for metrics test
mr := &metricsMockRedis{}
sessionStore := auth.NewSessionStore()
hub := ws.NewHub(gormDB, mr, "test-instance", 10, 10*time.Second, "admin")
handler := HandleMetrics(gormDB, mr, hub, sessionStore, "sqlite", "inst-123")
req := httptest.NewRequest("GET", "/metrics", nil)
w := httptest.NewRecorder()
handler(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "linearvoid_chat_redis_enabled 1") {
t.Error("redis_enabled should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_up 1") {
t.Error("redis_up should be 1")
}
if !strings.Contains(body, "linearvoid_chat_redis_connected_clients 5") {
t.Error("redis_connected_clients should be 5")
}
if !strings.Contains(body, "linearvoid_chat_redis_memory_used_bytes 1048576") {
t.Error("redis_memory_used_bytes should be 1048576")
}
if !strings.Contains(body, "linearvoid_chat_redis_memory_usage_ratio 1") {
t.Error("redis_memory_usage_ratio should be 1 when maxmemory is unavailable or equal to used memory in tests")
}
if !strings.Contains(body, "linearvoid_chat_request_duration_seconds_count 1") {
t.Error("request duration count should be 1")
}
}
// metricsMockRedis is a minimal mock for metrics tests.
type metricsMockRedis struct{}
func (m *metricsMockRedis) Ping(ctx context.Context) error { return nil }
func (m *metricsMockRedis) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Get(ctx context.Context, key string) (string, error) { return "", nil }
func (m *metricsMockRedis) Del(ctx context.Context, keys ...string) error { return nil }
func (m *metricsMockRedis) Incr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Decr(ctx context.Context, key string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *metricsMockRedis) Exists(ctx context.Context, keys ...string) (int64, error) { return 0, nil }
func (m *metricsMockRedis) Publish(ctx context.Context, channel string, message interface{}) error {
return nil
}
func (m *metricsMockRedis) PSubscribe(ctx context.Context, channels ...string) db.PubSub { return nil }
func (m *metricsMockRedis) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
return nil, 0, nil
}
func (m *metricsMockRedis) Info(ctx context.Context, sections ...string) (string, error) {
return "connected_clients:5\nused_memory_human:1M\nmaxmemory_human:1M\n", nil
}
func (m *metricsMockRedis) Close() error { return nil }
+74 -74
View File
@@ -1,75 +1,75 @@
package ratelimit
import (
"context"
"fmt"
"time"
)
// RedisClient is the subset of Redis operations needed for rate limiting.
type RedisClient interface {
Incr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error
Decr(ctx context.Context, key string) (int64, error)
}
// CheckMessageRate checks if a user has exceeded the message rate limit.
// Returns true if allowed, false if rate limited.
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
if redis == nil {
return true, nil // No Redis = no rate limiting
}
key := fmt.Sprintf("rate:%s", username)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err // Fail open on Redis error
}
if count == 1 {
_ = redis.Expire(ctx, key, window)
}
return count <= int64(max), nil
}
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
// Returns true if allowed, false if rate limited.
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil {
return true, nil
}
key := fmt.Sprintf("login_attempts:%s", clientIP)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err
}
if count == 1 {
_ = redis.Expire(ctx, key, 5*time.Minute)
}
return count <= 5, nil
}
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
// Returns true if allowed, false if rate limited.
// Caller must call Decr on disconnect.
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil {
return true, nil
}
key := fmt.Sprintf("ws_conn:%s", clientIP)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err
}
if count == 1 {
_ = redis.Expire(ctx, key, 60*time.Second)
}
return count <= 5, nil
}
// DecrWSConn decrements the WebSocket connection counter for an IP.
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
if redis == nil {
return
}
key := fmt.Sprintf("ws_conn:%s", clientIP)
_, _ = redis.Decr(ctx, key)
package ratelimit
import (
"context"
"fmt"
"time"
)
// RedisClient is the subset of Redis operations needed for rate limiting.
type RedisClient interface {
Incr(ctx context.Context, key string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error
Decr(ctx context.Context, key string) (int64, error)
}
// CheckMessageRate checks if a user has exceeded the message rate limit.
// Returns true if allowed, false if rate limited.
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
if redis == nil {
return true, nil // No Redis = no rate limiting
}
key := fmt.Sprintf("rate:%s", username)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err // Fail open on Redis error
}
if count == 1 {
_ = redis.Expire(ctx, key, window)
}
return count <= int64(max), nil
}
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
// Returns true if allowed, false if rate limited.
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil {
return true, nil
}
key := fmt.Sprintf("login_attempts:%s", clientIP)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err
}
if count == 1 {
_ = redis.Expire(ctx, key, 5*time.Minute)
}
return count <= 5, nil
}
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
// Returns true if allowed, false if rate limited.
// Caller must call Decr on disconnect.
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
if redis == nil {
return true, nil
}
key := fmt.Sprintf("ws_conn:%s", clientIP)
count, err := redis.Incr(ctx, key)
if err != nil {
return true, err
}
if count == 1 {
_ = redis.Expire(ctx, key, 60*time.Second)
}
return count <= 5, nil
}
// DecrWSConn decrements the WebSocket connection counter for an IP.
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
if redis == nil {
return
}
key := fmt.Sprintf("ws_conn:%s", clientIP)
_, _ = redis.Decr(ctx, key)
}
+189 -189
View File
@@ -1,190 +1,190 @@
package ratelimit
import (
"context"
"sync"
"testing"
"time"
)
// mockRedis implements RedisClient for testing.
type mockRedis struct {
mu sync.Mutex
store map[string]int64
}
func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]int64)}
}
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]++
return m.store[key], nil
}
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]--
return m.store[key], nil
}
func TestCheckMessageRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 10 messages allowed
for i := 0; i < 10; i++ {
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
}
}
// 11th message blocked
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #11 error = %v", err)
}
if allowed {
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
}
// Different user not affected
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate user2 error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate user2 = false, want true")
}
}
func TestCheckMessageRate_NilRedis(t *testing.T) {
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
}
}
func TestCheckLoginRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 attempts allowed
for i := 0; i < 5; i++ {
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
}
}
// 6th attempt blocked
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #6 error = %v", err)
}
if allowed {
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
}
// Different IP not affected
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
if err != nil {
t.Fatalf("CheckLoginRate other IP error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate other IP = false, want true")
}
}
func TestCheckLoginRate_NilRedis(t *testing.T) {
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
}
}
func TestCheckWSConnRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 connections allowed
for i := 0; i < 5; i++ {
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
}
}
// 6th connection blocked
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #6 error = %v", err)
}
if allowed {
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
}
}
func TestCheckWSConnRate_NilRedis(t *testing.T) {
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
}
}
func TestDecrWSConn(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// Increment to 5
for i := 0; i < 5; i++ {
CheckWSConnRate(ctx, redis, "192.168.1.1")
}
// 6th should be blocked
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
if allowed {
t.Error("6th connection should be blocked")
}
// Decrement twice (simulate 2 disconnects)
DecrWSConn(ctx, redis, "192.168.1.1")
DecrWSConn(ctx, redis, "192.168.1.1")
// Now should be allowed again (counter 6→4, then incr→5)
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
if !allowed {
t.Error("after 2x DecrWSConn, connection should be allowed")
}
}
func TestDecrWSConn_NilRedis(t *testing.T) {
// Should not panic
DecrWSConn(context.Background(), nil, "192.168.1.1")
package ratelimit
import (
"context"
"sync"
"testing"
"time"
)
// mockRedis implements RedisClient for testing.
type mockRedis struct {
mu sync.Mutex
store map[string]int64
}
func newMockRedis() *mockRedis {
return &mockRedis{store: make(map[string]int64)}
}
func (m *mockRedis) Incr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]++
return m.store[key], nil
}
func (m *mockRedis) Expire(ctx context.Context, key string, expiration time.Duration) error {
return nil
}
func (m *mockRedis) Decr(ctx context.Context, key string) (int64, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.store[key]--
return m.store[key], nil
}
func TestCheckMessageRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 10 messages allowed
for i := 0; i < 10; i++ {
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckMessageRate #%d = false, want true", i+1)
}
}
// 11th message blocked
allowed, err := CheckMessageRate(ctx, redis, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate #11 error = %v", err)
}
if allowed {
t.Error("CheckMessageRate #11 = true, want false (rate limited)")
}
// Different user not affected
allowed, err = CheckMessageRate(ctx, redis, "user2", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate user2 error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate user2 = false, want true")
}
}
func TestCheckMessageRate_NilRedis(t *testing.T) {
allowed, err := CheckMessageRate(context.Background(), nil, "user1", 10, 10*time.Second)
if err != nil {
t.Fatalf("CheckMessageRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckMessageRate nil redis = false, want true (fail open)")
}
}
func TestCheckLoginRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 attempts allowed
for i := 0; i < 5; i++ {
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckLoginRate #%d = false, want true", i+1)
}
}
// 6th attempt blocked
allowed, err := CheckLoginRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate #6 error = %v", err)
}
if allowed {
t.Error("CheckLoginRate #6 = true, want false (rate limited)")
}
// Different IP not affected
allowed, err = CheckLoginRate(ctx, redis, "10.0.0.1")
if err != nil {
t.Fatalf("CheckLoginRate other IP error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate other IP = false, want true")
}
}
func TestCheckLoginRate_NilRedis(t *testing.T) {
allowed, err := CheckLoginRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckLoginRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckLoginRate nil redis = false, want true (fail open)")
}
}
func TestCheckWSConnRate(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// First 5 connections allowed
for i := 0; i < 5; i++ {
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #%d error = %v", i+1, err)
}
if !allowed {
t.Errorf("CheckWSConnRate #%d = false, want true", i+1)
}
}
// 6th connection blocked
allowed, err := CheckWSConnRate(ctx, redis, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate #6 error = %v", err)
}
if allowed {
t.Error("CheckWSConnRate #6 = true, want false (rate limited)")
}
}
func TestCheckWSConnRate_NilRedis(t *testing.T) {
allowed, err := CheckWSConnRate(context.Background(), nil, "192.168.1.1")
if err != nil {
t.Fatalf("CheckWSConnRate nil redis error = %v", err)
}
if !allowed {
t.Error("CheckWSConnRate nil redis = false, want true (fail open)")
}
}
func TestDecrWSConn(t *testing.T) {
redis := newMockRedis()
ctx := context.Background()
// Increment to 5
for i := 0; i < 5; i++ {
CheckWSConnRate(ctx, redis, "192.168.1.1")
}
// 6th should be blocked
allowed, _ := CheckWSConnRate(ctx, redis, "192.168.1.1")
if allowed {
t.Error("6th connection should be blocked")
}
// Decrement twice (simulate 2 disconnects)
DecrWSConn(ctx, redis, "192.168.1.1")
DecrWSConn(ctx, redis, "192.168.1.1")
// Now should be allowed again (counter 6→4, then incr→5)
allowed, _ = CheckWSConnRate(ctx, redis, "192.168.1.1")
if !allowed {
t.Error("after 2x DecrWSConn, connection should be allowed")
}
}
func TestDecrWSConn_NilRedis(t *testing.T) {
// Should not panic
DecrWSConn(context.Background(), nil, "192.168.1.1")
}
+499 -499
View File
@@ -1,500 +1,500 @@
package ws
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"chat/src/db"
"chat/src/ratelimit"
)
// Upgrader is the WebSocket upgrader configuration.
var Upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ClientInfo stores metadata about a connected WebSocket client.
type ClientInfo struct {
Username string
Room string
}
// Hub manages all WebSocket connections and message routing.
type Hub struct {
mu sync.Mutex
clients map[*websocket.Conn]bool
clientMeta map[*websocket.Conn]ClientInfo
DB *gorm.DB
Redis db.RedisClient
InstanceID string
RateLimitMax int
RateLimitWindow time.Duration
AdminUsername string
DMsSent int64
presenceMu sync.Mutex
presenceTimers map[string]*time.Timer
PresenceGracePeriod time.Duration
}
// NewHub creates a new WebSocket hub.
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
clientMeta: make(map[*websocket.Conn]ClientInfo),
DB: gormDB,
Redis: redis,
InstanceID: instanceID,
RateLimitMax: rateMax,
RateLimitWindow: rateWindow,
AdminUsername: adminUser,
presenceTimers: make(map[string]*time.Timer),
PresenceGracePeriod: 5 * time.Second,
}
}
// ConnectedClients returns the number of currently connected clients.
func (h *Hub) ConnectedClients() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.clients)
}
// Broadcast sends a message to all connected clients and persists to DB.
// System messages (join/leave announcements, moderation notices) fan out but
// are not persisted, so the chat-log table only holds real chat messages.
func (h *Hub) Broadcast(msg db.Message) {
h.mu.Lock()
defer h.mu.Unlock()
// Persist to configured SQL backend (chat messages only).
if h.DB != nil && msg.Type != "system" {
h.DB.Create(&msg)
}
// Local fan-out
for client := range h.clients {
if err := client.WriteJSON(msg); err != nil {
client.Close()
delete(h.clients, client)
delete(h.clientMeta, client)
}
}
// Redis pub/sub for multi-instance fan-out
if h.Redis != nil && msg.Room != "" {
payload, _ := json.Marshal(map[string]any{
"instance": h.InstanceID,
"msg": msg,
})
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
}
}
// scheduleLeaveAnnouncement broadcasts a "left" message after a short grace
// period so quick reconnects don't produce join/leave spam.
func (h *Hub) scheduleLeaveAnnouncement(room, username string) {
key := room + "\x00" + username
h.presenceMu.Lock()
if h.presenceTimers == nil {
h.presenceTimers = make(map[string]*time.Timer)
}
grace := h.PresenceGracePeriod
if grace <= 0 {
grace = 5 * time.Second
}
h.presenceTimers[key] = time.AfterFunc(grace, func() {
h.presenceMu.Lock()
delete(h.presenceTimers, key)
h.presenceMu.Unlock()
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
})
h.presenceMu.Unlock()
}
// cancelLeaveAnnouncement cancels a pending "left" announcement for a
// room+user and reports whether one existed (i.e. a reconnect within grace).
func (h *Hub) cancelLeaveAnnouncement(room, username string) bool {
key := room + "\x00" + username
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
if timer, ok := h.presenceTimers[key]; ok {
timer.Stop()
delete(h.presenceTimers, key)
return true
}
return false
}
// 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 == "" {
return
}
var messages []db.Message
h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages)
// Reverse to chronological order
for i := len(messages)/2 - 1; i >= 0; i-- {
opp := len(messages) - 1 - i
messages[i], messages[opp] = messages[opp], messages[i]
}
h.mu.Lock()
for _, msg := range messages {
conn.WriteJSON(msg)
}
h.mu.Unlock()
}
// HandleConnection handles a new WebSocket connection.
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
// IP-based connection rate limiting
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
if err != nil {
log.Printf("WS rate limit check error: %v", err)
}
if !allowed {
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
return
}
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
conn, err := Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room")
if username == "" {
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
}
h.mu.Lock()
h.clients[conn] = true
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
h.mu.Unlock()
// Announce presence (ADR-007). A reconnect within the grace window cancels
// the pending "left" announcement and skips a redundant "joined".
rejoined := h.cancelLeaveAnnouncement(room, username)
if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
if !rejoined {
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
}
}
// Push chat history on join (last 50 messages)
h.PushChatHistory(conn, room, 50)
// Heartbeat goroutine: refresh presence TTL every 30s
heartbeatStop := make(chan struct{})
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
}
// Ping to detect dead connections
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-heartbeatStop:
return
}
}
}()
defer func() {
close(heartbeatStop)
h.mu.Lock()
delete(h.clients, conn)
delete(h.clientMeta, conn)
h.mu.Unlock()
if h.Redis != nil && room != "" {
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
h.scheduleLeaveAnnouncement(room, username)
}
conn.Close()
}()
for {
var msg db.Message
if err := conn.ReadJSON(&msg); err != nil {
break
}
msg.Timestamp = time.Now()
msg.Username = username // enforce from query param for security
// Check if banned before processing
if h.Redis != nil {
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
continue
}
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
continue
}
}
// Rate limiting check
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
if !allowed {
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
continue
}
// Handle moderation commands
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
h.handleModerationCommand(msg, conn)
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)
continue
}
msg.Type = "public"
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
h.Broadcast(msg)
}
}
// handlePrivateDM routes private messages to the specific user pair.
func (h *Hub) handlePrivateDM(msg db.Message) {
msg.Type = "private"
h.DMsSent++
// Persist for history
if h.DB != nil {
h.DB.Create(&msg)
}
// Deliver to target if online
h.mu.Lock()
targetOnline := false
for conn, info := range h.clientMeta {
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
if err := conn.WriteJSON(msg); err == nil {
targetOnline = true
}
}
}
h.mu.Unlock()
if !targetOnline {
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
}
// Also deliver to sender for confirmation
h.mu.Lock()
for conn, info := range h.clientMeta {
if info.Username == msg.Username {
conn.WriteJSON(msg)
break
}
}
h.mu.Unlock()
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
}
// logModerationAction logs an action to the immutable ModerationLog table.
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
logEntry := db.ModerationLog{
Action: action,
Target: target,
Moderator: moderator,
Reason: reason,
Timestamp: time.Now(),
}
if h.DB != nil {
h.DB.Create(&logEntry)
}
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
}
// handleModerationCommand processes /mod, /mute, /ban commands.
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
return
}
if msg.Username != h.AdminUsername {
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
return
}
action := strings.TrimPrefix(parts[0], "/")
target := parts[1]
reason := strings.Join(parts[2:], " ")
if h.Redis != nil {
switch action {
case "mute", "mod":
durationMinutes := 10 // default
if len(parts) >= 3 {
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
durationMinutes = d
if len(parts) > 3 {
reason = strings.Join(parts[3:], " ")
} else {
reason = "no reason given"
}
}
}
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
case "ban":
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
}
}
h.logModerationAction(action, target, msg.Username, reason)
h.Broadcast(db.Message{
Room: msg.Room,
Username: "system",
Type: "system",
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
Timestamp: time.Now(),
})
}
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
func (h *Hub) SubscribeRoomMessages() {
if h.Redis == nil {
return
}
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
defer pubsub.Close()
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
for {
msg, err := pubsub.ReceiveMessage(context.Background())
if err != nil {
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
time.Sleep(5 * time.Second)
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
continue
}
var envelope struct {
Instance string `json:"instance"`
Msg db.Message `json:"msg"`
}
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
continue
}
// Skip messages originating from this instance
if envelope.Instance == h.InstanceID {
continue
}
// Fan out to local clients
h.mu.Lock()
for client := range h.clients {
client.WriteJSON(envelope.Msg)
}
h.mu.Unlock()
}
}
// ScanPresence returns all active presence entries from Redis.
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
if h.Redis == nil {
return nil, nil
}
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
if err != nil {
return nil, err
}
var presences []db.Presence
for _, key := range keys {
parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 {
continue
}
presences = append(presences, db.Presence{
Room: parts[1],
Username: parts[2],
Online: true,
})
}
return presences, nil
}
// MuteUser mutes a user for a duration.
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
}
// UnmuteUser removes a mute.
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
}
// BanUser bans a user permanently.
func (h *Hub) BanUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
}
// UnbanUser removes a ban.
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
package ws
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"gorm.io/gorm"
"chat/src/db"
"chat/src/ratelimit"
)
// Upgrader is the WebSocket upgrader configuration.
var Upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ClientInfo stores metadata about a connected WebSocket client.
type ClientInfo struct {
Username string
Room string
}
// Hub manages all WebSocket connections and message routing.
type Hub struct {
mu sync.Mutex
clients map[*websocket.Conn]bool
clientMeta map[*websocket.Conn]ClientInfo
DB *gorm.DB
Redis db.RedisClient
InstanceID string
RateLimitMax int
RateLimitWindow time.Duration
AdminUsername string
DMsSent int64
presenceMu sync.Mutex
presenceTimers map[string]*time.Timer
PresenceGracePeriod time.Duration
}
// NewHub creates a new WebSocket hub.
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
clientMeta: make(map[*websocket.Conn]ClientInfo),
DB: gormDB,
Redis: redis,
InstanceID: instanceID,
RateLimitMax: rateMax,
RateLimitWindow: rateWindow,
AdminUsername: adminUser,
presenceTimers: make(map[string]*time.Timer),
PresenceGracePeriod: 5 * time.Second,
}
}
// ConnectedClients returns the number of currently connected clients.
func (h *Hub) ConnectedClients() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.clients)
}
// Broadcast sends a message to all connected clients and persists to DB.
// System messages (join/leave announcements, moderation notices) fan out but
// are not persisted, so the chat-log table only holds real chat messages.
func (h *Hub) Broadcast(msg db.Message) {
h.mu.Lock()
defer h.mu.Unlock()
// Persist to configured SQL backend (chat messages only).
if h.DB != nil && msg.Type != "system" {
h.DB.Create(&msg)
}
// Local fan-out
for client := range h.clients {
if err := client.WriteJSON(msg); err != nil {
client.Close()
delete(h.clients, client)
delete(h.clientMeta, client)
}
}
// Redis pub/sub for multi-instance fan-out
if h.Redis != nil && msg.Room != "" {
payload, _ := json.Marshal(map[string]any{
"instance": h.InstanceID,
"msg": msg,
})
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
}
}
// scheduleLeaveAnnouncement broadcasts a "left" message after a short grace
// period so quick reconnects don't produce join/leave spam.
func (h *Hub) scheduleLeaveAnnouncement(room, username string) {
key := room + "\x00" + username
h.presenceMu.Lock()
if h.presenceTimers == nil {
h.presenceTimers = make(map[string]*time.Timer)
}
grace := h.PresenceGracePeriod
if grace <= 0 {
grace = 5 * time.Second
}
h.presenceTimers[key] = time.AfterFunc(grace, func() {
h.presenceMu.Lock()
delete(h.presenceTimers, key)
h.presenceMu.Unlock()
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
})
h.presenceMu.Unlock()
}
// cancelLeaveAnnouncement cancels a pending "left" announcement for a
// room+user and reports whether one existed (i.e. a reconnect within grace).
func (h *Hub) cancelLeaveAnnouncement(room, username string) bool {
key := room + "\x00" + username
h.presenceMu.Lock()
defer h.presenceMu.Unlock()
if timer, ok := h.presenceTimers[key]; ok {
timer.Stop()
delete(h.presenceTimers, key)
return true
}
return false
}
// 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 == "" {
return
}
var messages []db.Message
h.DB.Where("room = ? AND type <> ?", room, "system").Order("timestamp desc").Limit(limit).Find(&messages)
// Reverse to chronological order
for i := len(messages)/2 - 1; i >= 0; i-- {
opp := len(messages) - 1 - i
messages[i], messages[opp] = messages[opp], messages[i]
}
h.mu.Lock()
for _, msg := range messages {
conn.WriteJSON(msg)
}
h.mu.Unlock()
}
// HandleConnection handles a new WebSocket connection.
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
clientIP := r.Header.Get("X-Forwarded-For")
if clientIP == "" {
clientIP = r.RemoteAddr
}
// IP-based connection rate limiting
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
if err != nil {
log.Printf("WS rate limit check error: %v", err)
}
if !allowed {
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
return
}
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
conn, err := Upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("Upgrade error:", err)
return
}
username := r.URL.Query().Get("username")
room := r.URL.Query().Get("room")
if username == "" {
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
}
h.mu.Lock()
h.clients[conn] = true
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
h.mu.Unlock()
// Announce presence (ADR-007). A reconnect within the grace window cancels
// the pending "left" announcement and skips a redundant "joined".
rejoined := h.cancelLeaveAnnouncement(room, username)
if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
if !rejoined {
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
}
}
// Push chat history on join (last 50 messages)
h.PushChatHistory(conn, room, 50)
// Heartbeat goroutine: refresh presence TTL every 30s
heartbeatStop := make(chan struct{})
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if h.Redis != nil && room != "" {
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
}
// Ping to detect dead connections
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
case <-heartbeatStop:
return
}
}
}()
defer func() {
close(heartbeatStop)
h.mu.Lock()
delete(h.clients, conn)
delete(h.clientMeta, conn)
h.mu.Unlock()
if h.Redis != nil && room != "" {
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
h.scheduleLeaveAnnouncement(room, username)
}
conn.Close()
}()
for {
var msg db.Message
if err := conn.ReadJSON(&msg); err != nil {
break
}
msg.Timestamp = time.Now()
msg.Username = username // enforce from query param for security
// Check if banned before processing
if h.Redis != nil {
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
continue
}
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
continue
}
}
// Rate limiting check
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
if !allowed {
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
continue
}
// Handle moderation commands
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
h.handleModerationCommand(msg, conn)
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)
continue
}
msg.Type = "public"
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
h.Broadcast(msg)
}
}
// handlePrivateDM routes private messages to the specific user pair.
func (h *Hub) handlePrivateDM(msg db.Message) {
msg.Type = "private"
h.DMsSent++
// Persist for history
if h.DB != nil {
h.DB.Create(&msg)
}
// Deliver to target if online
h.mu.Lock()
targetOnline := false
for conn, info := range h.clientMeta {
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
if err := conn.WriteJSON(msg); err == nil {
targetOnline = true
}
}
}
h.mu.Unlock()
if !targetOnline {
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
}
// Also deliver to sender for confirmation
h.mu.Lock()
for conn, info := range h.clientMeta {
if info.Username == msg.Username {
conn.WriteJSON(msg)
break
}
}
h.mu.Unlock()
log.Printf("Private DM from %s to room %s: %s", msg.Username, msg.Room, msg.Text)
}
// logModerationAction logs an action to the immutable ModerationLog table.
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
logEntry := db.ModerationLog{
Action: action,
Target: target,
Moderator: moderator,
Reason: reason,
Timestamp: time.Now(),
}
if h.DB != nil {
h.DB.Create(&logEntry)
}
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
}
// handleModerationCommand processes /mod, /mute, /ban commands.
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
parts := strings.Fields(msg.Text)
if len(parts) < 3 {
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
return
}
if msg.Username != h.AdminUsername {
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
return
}
action := strings.TrimPrefix(parts[0], "/")
target := parts[1]
reason := strings.Join(parts[2:], " ")
if h.Redis != nil {
switch action {
case "mute", "mod":
durationMinutes := 10 // default
if len(parts) >= 3 {
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
durationMinutes = d
if len(parts) > 3 {
reason = strings.Join(parts[3:], " ")
} else {
reason = "no reason given"
}
}
}
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
case "ban":
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
}
}
h.logModerationAction(action, target, msg.Username, reason)
h.Broadcast(db.Message{
Room: msg.Room,
Username: "system",
Type: "system",
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
Timestamp: time.Now(),
})
}
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
func (h *Hub) SubscribeRoomMessages() {
if h.Redis == nil {
return
}
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
defer pubsub.Close()
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
for {
msg, err := pubsub.ReceiveMessage(context.Background())
if err != nil {
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
time.Sleep(5 * time.Second)
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
continue
}
var envelope struct {
Instance string `json:"instance"`
Msg db.Message `json:"msg"`
}
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
continue
}
// Skip messages originating from this instance
if envelope.Instance == h.InstanceID {
continue
}
// Fan out to local clients
h.mu.Lock()
for client := range h.clients {
client.WriteJSON(envelope.Msg)
}
h.mu.Unlock()
}
}
// ScanPresence returns all active presence entries from Redis.
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
if h.Redis == nil {
return nil, nil
}
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
if err != nil {
return nil, err
}
var presences []db.Presence
for _, key := range keys {
parts := strings.SplitN(key, ":", 3)
if len(parts) != 3 {
continue
}
presences = append(presences, db.Presence{
Room: parts[1],
Username: parts[2],
Online: true,
})
}
return presences, nil
}
// MuteUser mutes a user for a duration.
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
}
// UnmuteUser removes a mute.
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
}
// BanUser bans a user permanently.
func (h *Hub) BanUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
}
// UnbanUser removes a ban.
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
if h.Redis == nil {
return fmt.Errorf("redis unavailable")
}
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
}
File diff suppressed because it is too large. Load diff