Refactor load testing scripts to support Bash and PowerShell
- 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:
1 parent
9f60087a4c
commit
33b6296c5e
82 files changed
+19682
-21133
No files matched your search
+16
-16
@@ -1,17 +1,17 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod ./
|
||||
COPY src/ ./src/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||
|
||||
EXPOSE 3000
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod ./
|
||||
COPY src/ ./src/
|
||||
RUN go mod tidy
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk --no-cache add ca-certificates curl ffmpeg
|
||||
WORKDIR /app
|
||||
COPY --from=builder /chat-server /usr/local/bin/chat-server
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/usr/local/bin/chat-server"]
|
||||
+206
-206
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
+2048
-2048
File diff suppressed because it is too large.
Load diff
+1422
-1422
File diff suppressed because it is too large.
Load diff
+110
-110
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
+610
-610
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user