package main import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "io" "log" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "sync" "time" "github.com/gorilla/websocket" "github.com/redis/go-redis/v9" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" "gorm.io/gorm" ) // Config from environment (ADR-003 and ADR-007) var ( redisClient *redis.Client db *gorm.DB upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } clients = make(map[*websocket.Conn]bool) mutex = sync.Mutex{} authSessions = map[string]time.Time{} authSessionsMu = sync.Mutex{} ) // Message represents a chat message (persisted in SQLite 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"` } // 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"` } type AdminOverview struct { Viewers int `json:"viewers"` StoredMessages int64 `json:"storedMessages"` ModerationActions int64 `json:"moderationActions"` RedisEnabled bool `json:"redisEnabled"` RedisClients int `json:"redisClients"` RedisMemoryUsed string `json:"redisMemoryUsed"` DatabaseDriver string `json:"databaseDriver"` GeneratedAt string `json:"generatedAt"` } type PlaylistPayload struct { Content string `json:"content"` } type ModerationActionPayload struct { Action string `json:"action"` Target string `json:"target"` Moderator string `json:"moderator"` Reason string `json:"reason"` Room string `json:"room"` } type ModerationLogsResponse struct { Logs []ModerationLog `json:"logs"` } type RoomAnalytics struct { Room string `json:"room"` MessageCount int64 `json:"messageCount"` } type AdminAnalytics struct { TotalMessages int64 `json:"totalMessages"` MessagesLast24Hours int64 `json:"messagesLast24Hours"` DistinctRooms int64 `json:"distinctRooms"` ModerationActions int64 `json:"moderationActions"` TopRooms []RoomAnalytics `json:"topRooms"` GeneratedAt string `json:"generatedAt"` } type ServiceHealth struct { Name string `json:"name"` Status string `json:"status"` Detail string `json:"detail"` Checked string `json:"checked"` } type AdminSystemSummary struct { DatabaseDriver string `json:"databaseDriver"` PlaylistPath string `json:"playlistPath"` PlaylistExists bool `json:"playlistExists"` RedisEnabled bool `json:"redisEnabled"` RedisMemoryUsed string `json:"redisMemoryUsed"` Services []ServiceHealth `json:"services"` GeneratedAt string `json:"generatedAt"` } type ActiveUser struct { Username string `json:"username"` Room string `json:"room"` Online bool `json:"online"` } type AdminUsersResponse struct { Users []ActiveUser `json:"users"` RedisEnabled bool `json:"redisEnabled"` GeneratedAt string `json:"generatedAt"` } type ScheduleEntry struct { Index int `json:"index"` Path string `json:"path"` Label string `json:"label"` } type AdminScheduleResponse struct { Entries []ScheduleEntry `json:"entries"` GeneratedAt string `json:"generatedAt"` } type MediaFileEntry struct { Name string `json:"name"` Size int64 `json:"size"` ModifiedAt string `json:"modifiedAt"` } type AdminMediaResponse struct { Files []MediaFileEntry `json:"files"` GeneratedAt string `json:"generatedAt"` } type AdminLoginPayload struct { Username string `json:"username"` Password string `json:"password"` } type AdminSessionResponse struct { Authenticated bool `json:"authenticated"` Username string `json:"username,omitempty"` } var databaseDriverName = "sqlite" const adminSessionCookieName = "linearvoid_admin_session" // initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007) func initRedis() { redisAddress := os.Getenv("REDIS_URL") if redisAddress == "" { redisAddress = "redis:6379" } 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) redisClient = nil return } } else { options = &redis.Options{Addr: redisAddress} } redisClient = redis.NewClient(options) if err := redisClient.Ping(context.Background()).Err(); err != nil { log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err) redisClient = nil } else { log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)") } } // initDB initializes SQLite for message history and moderation audit log (ADR-006) func initDB() { var err error databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) driverName := "sqlite" switch { case databaseURL == "": db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{}) case strings.HasPrefix(databaseURL, "sqlite://"): sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://") if sqlitePath == "" { sqlitePath = "chat.db" } db, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{}) case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"): driverName = "postgres" db, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{}) default: log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL) } if err != nil { log.Fatalf("Failed to connect to %s database: %v", driverName, err) } databaseDriverName = driverName db.AutoMigrate(&Message{}, &ModerationLog{}) log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName)) } func parseRedisInfoValue(info, key string) string { prefix := key + ":" for _, line := range strings.Split(info, "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, prefix) { return strings.TrimSpace(strings.TrimPrefix(line, prefix)) } } return "" } func getAdminOverview() AdminOverview { overview := AdminOverview{ RedisEnabled: redisClient != nil, RedisMemoryUsed: "unavailable", DatabaseDriver: databaseDriverName, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } mutex.Lock() overview.Viewers = len(clients) mutex.Unlock() if db != nil { db.Model(&Message{}).Count(&overview.StoredMessages) db.Model(&ModerationLog{}).Count(&overview.ModerationActions) } if redisClient != nil { ctx := context.Background() if info, err := redisClient.Info(ctx, "clients", "memory").Result(); err == nil { if value := parseRedisInfoValue(info, "connected_clients"); value != "" { if count, convErr := strconv.Atoi(value); convErr == nil { overview.RedisClients = count } } if value := parseRedisInfoValue(info, "used_memory_human"); value != "" { overview.RedisMemoryUsed = value } } } return overview } func handleAdminLogin(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload AdminLoginPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } if payload.Username != getAdminUsername() || payload.Password != getAdminPassword() { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } token, err := generateSessionToken() if err != nil { http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError) return } expiresAt := time.Now().Add(getAdminSessionTTL()) authSessionsMu.Lock() authSessions[token] = expiresAt authSessionsMu.Unlock() setAdminSessionCookie(w, r, token, expiresAt) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()}) } func handleAdminLogout(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if cookie, err := r.Cookie(adminSessionCookieName); err == nil { authSessionsMu.Lock() delete(authSessions, cookie.Value) authSessionsMu.Unlock() } clearAdminSessionCookie(w, r) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false}) } func handleAdminSession(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if !validateAdminSession(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: getAdminUsername()}) } func handleAdminOverview(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(getAdminOverview()) } func getPlaylistPath() string { playlistPath := strings.TrimSpace(os.Getenv("PLAYLIST_PATH")) if playlistPath == "" { playlistPath = "/app/playlist/playlist.txt" } return playlistPath } func getMediaDir() string { mediaDir := strings.TrimSpace(os.Getenv("MEDIA_DIR")) if mediaDir == "" { mediaDir = "/app/media" } return mediaDir } func getAdminUsername() string { username := strings.TrimSpace(os.Getenv("ADMIN_USERNAME")) if username == "" { username = "admin" } return username } func getAdminPassword() string { password := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD")) if password == "" { password = "admin" } return password } func getAdminSessionTTL() time.Duration { if rawHours := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS")); rawHours != "" { if hours, err := strconv.Atoi(rawHours); err == nil && hours > 0 { return time.Duration(hours) * time.Hour } } return 12 * time.Hour } func generateSessionToken() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err } return hex.EncodeToString(buf), nil } func sessionCookieSecure(r *http.Request) bool { if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" { return strings.EqualFold(forwardedProto, "https") } return r.TLS != nil } func setAdminSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) { http.SetCookie(w, &http.Cookie{ Name: adminSessionCookieName, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: sessionCookieSecure(r), Expires: expiresAt, MaxAge: int(time.Until(expiresAt).Seconds()), }) } func clearAdminSessionCookie(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: adminSessionCookieName, Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: sessionCookieSecure(r), Expires: time.Unix(0, 0), MaxAge: -1, }) } func validateAdminSession(r *http.Request) bool { cookie, err := r.Cookie(adminSessionCookieName) if err != nil || cookie.Value == "" { return false } authSessionsMu.Lock() defer authSessionsMu.Unlock() expiresAt, ok := authSessions[cookie.Value] if !ok { return false } if time.Now().After(expiresAt) { delete(authSessions, cookie.Value) return false } return true } func requireAdminAuth(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if !validateAdminSession(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } next(w, r) } } func parsePlaylistEntries(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 } func handleAdminPlaylist(w http.ResponseWriter, r *http.Request) { playlistPath := getPlaylistPath() switch r.Method { case http.MethodGet: content, err := os.ReadFile(playlistPath) if err != nil { http.Error(w, fmt.Sprintf("failed to read playlist: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PlaylistPayload{Content: string(content)}) case http.MethodPut: var payload PlaylistPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } if err := os.MkdirAll(filepath.Dir(playlistPath), 0o755); err != nil { http.Error(w, fmt.Sprintf("failed to ensure playlist directory: %v", err), http.StatusInternalServerError) return } content := payload.Content if content != "" && !strings.HasSuffix(content, "\n") { content += "\n" } if err := os.WriteFile(playlistPath, []byte(content), 0o644); err != nil { http.Error(w, fmt.Sprintf("failed to save playlist: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(PlaylistPayload{Content: content}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func handleAdminSchedule(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } content, err := os.ReadFile(getPlaylistPath()) if err != nil { http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminScheduleResponse{ Entries: parsePlaylistEntries(string(content)), GeneratedAt: time.Now().UTC().Format(time.RFC3339), }) } func handleAdminModerationLogs(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } limit := 50 if rawLimit := strings.TrimSpace(r.URL.Query().Get("limit")); rawLimit != "" { parsedLimit, err := strconv.Atoi(rawLimit) if err != nil || parsedLimit <= 0 { http.Error(w, "invalid limit", http.StatusBadRequest) return } if parsedLimit > 200 { parsedLimit = 200 } limit = parsedLimit } var logs []ModerationLog if err := db.Order("timestamp desc").Limit(limit).Find(&logs).Error; err != nil { http.Error(w, fmt.Sprintf("failed to load moderation logs: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ModerationLogsResponse{Logs: logs}) } func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload ModerationActionPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } payload.Action = strings.TrimSpace(payload.Action) payload.Target = strings.TrimSpace(payload.Target) payload.Moderator = strings.TrimSpace(payload.Moderator) payload.Reason = strings.TrimSpace(payload.Reason) payload.Room = strings.TrimSpace(payload.Room) if payload.Action == "" || payload.Target == "" || payload.Reason == "" { http.Error(w, "action, target, and reason are required", http.StatusBadRequest) return } if payload.Moderator == "" { payload.Moderator = "admin" } if payload.Room == "" { payload.Room = "main" } logModerationAction(payload.Action, payload.Target, payload.Moderator, payload.Reason) broadcast(Message{ Room: payload.Room, Username: "system", Type: "system", Text: fmt.Sprintf("Moderator %s %s %s for: %s", payload.Moderator, payload.Action, payload.Target, payload.Reason), Timestamp: time.Now(), }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(payload) } func getAdminAnalytics() AdminAnalytics { analytics := AdminAnalytics{ TopRooms: []RoomAnalytics{}, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if db == nil { return analytics } db.Model(&Message{}).Count(&analytics.TotalMessages) db.Model(&Message{}). Where("timestamp >= ?", time.Now().Add(-24*time.Hour)). Count(&analytics.MessagesLast24Hours) db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms) db.Model(&ModerationLog{}).Count(&analytics.ModerationActions) var topRooms []RoomAnalytics db.Model(&Message{}). Select("room, COUNT(*) as message_count"). Group("room"). Order("message_count DESC"). Limit(5). Scan(&topRooms) analytics.TopRooms = topRooms return analytics } func handleAdminAnalytics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(getAdminAnalytics()) } func checkServiceHealth(name, url string) ServiceHealth { checked := time.Now().UTC().Format(time.RFC3339) resp, err := http.Get(url) if err != nil { return ServiceHealth{Name: name, Status: "down", Detail: err.Error(), Checked: checked} } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) status := "healthy" if resp.StatusCode >= 400 { status = "degraded" } detail := strings.TrimSpace(string(body)) if detail == "" { detail = resp.Status } return ServiceHealth{Name: name, Status: status, Detail: detail, Checked: checked} } func getAdminSystemSummary() AdminSystemSummary { summary := AdminSystemSummary{ DatabaseDriver: databaseDriverName, PlaylistPath: getPlaylistPath(), RedisEnabled: redisClient != nil, RedisMemoryUsed: "unavailable", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if _, err := os.Stat(summary.PlaylistPath); err == nil { summary.PlaylistExists = true } if redisClient != nil { if info, err := redisClient.Info(context.Background(), "memory").Result(); err == nil { if value := parseRedisInfoValue(info, "used_memory_human"); value != "" { summary.RedisMemoryUsed = value } } } summary.Services = []ServiceHealth{ {Name: "chat", Status: "healthy", Detail: "local process", Checked: summary.GeneratedAt}, checkServiceHealth("playout", "http://playout:8081/health"), } if redisClient != nil { if err := redisClient.Ping(context.Background()).Err(); err != nil { summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt}) } else { summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt}) } } else { summary.Services = append(summary.Services, ServiceHealth{Name: "redis", Status: "disabled", Detail: "client unavailable", Checked: summary.GeneratedAt}) } if sqlDB, err := db.DB(); err == nil { if err := sqlDB.Ping(); err != nil { summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "down", Detail: err.Error(), Checked: summary.GeneratedAt}) } else { summary.Services = append(summary.Services, ServiceHealth{Name: "database", Status: "healthy", Detail: "ping ok", Checked: summary.GeneratedAt}) } } return summary } func handleAdminSystemSummary(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(getAdminSystemSummary()) } func handleAdminUsers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } response := AdminUsersResponse{ Users: []ActiveUser{}, RedisEnabled: redisClient != nil, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if redisClient != nil { ctx := context.Background() keys, _, err := redisClient.Scan(ctx, 0, "presence:*", 200).Result() if err != nil { http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError) return } for _, key := range keys { parts := strings.SplitN(key, ":", 3) if len(parts) != 3 { continue } response.Users = append(response.Users, ActiveUser{ Room: parts[1], Username: parts[2], Online: true, }) } } sort.Slice(response.Users, func(i, j int) bool { if response.Users[i].Room == response.Users[j].Room { return response.Users[i].Username < response.Users[j].Username } return response.Users[i].Room < response.Users[j].Room }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func listMediaFiles() ([]MediaFileEntry, error) { mediaDir := getMediaDir() entries, err := os.ReadDir(mediaDir) if err != nil { return nil, err } files := make([]MediaFileEntry, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } info, err := entry.Info() if err != nil { continue } files = append(files, MediaFileEntry{ Name: entry.Name(), Size: info.Size(), ModifiedAt: info.ModTime().UTC().Format(time.RFC3339), }) } sort.Slice(files, func(i, j int) bool { return files[i].Name < files[j].Name }) return files, nil } func handleAdminMedia(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: files, err := listMediaFiles() if err != nil { http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}) case http.MethodPost: if err := r.ParseMultipartForm(256 << 20); err != nil { http.Error(w, fmt.Sprintf("failed to parse upload: %v", err), http.StatusBadRequest) return } file, header, err := r.FormFile("file") if err != nil { http.Error(w, fmt.Sprintf("missing file upload: %v", err), http.StatusBadRequest) return } defer file.Close() mediaDir := getMediaDir() if err := os.MkdirAll(mediaDir, 0o755); err != nil { http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError) return } targetPath := filepath.Join(mediaDir, filepath.Base(header.Filename)) targetFile, err := os.Create(targetPath) if err != nil { http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError) return } defer targetFile.Close() if _, err := io.Copy(targetFile, file); err != nil { http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError) return } files, err := listMediaFiles() if err != nil { http.Error(w, fmt.Sprintf("failed to list media files: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminMediaResponse{Files: files, GeneratedAt: time.Now().UTC().Format(time.RFC3339)}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func countConnectedClients() int { mutex.Lock() defer mutex.Unlock() return len(clients) } func countActiveAdminSessions() int { now := time.Now() authSessionsMu.Lock() defer authSessionsMu.Unlock() active := 0 for token, expiresAt := range authSessions { if now.After(expiresAt) { delete(authSessions, token) continue } active++ } return active } func writePrometheusMetricLine(w io.Writer, name string, value any) { fmt.Fprintf(w, "%s %v\n", name, value) } func handleMetrics(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var storedMessages int64 if err := db.Model(&Message{}).Count(&storedMessages).Error; err != nil { http.Error(w, fmt.Sprintf("failed to count messages: %v", err), http.StatusInternalServerError) return } var moderationActions int64 if err := db.Model(&ModerationLog{}).Count(&moderationActions).Error; err != nil { http.Error(w, fmt.Sprintf("failed to count moderation actions: %v", err), http.StatusInternalServerError) return } redisEnabled := 0 redisUp := 0 redisClients := 0 if redisClient != nil { redisEnabled = 1 if err := redisClient.Ping(context.Background()).Err(); err == nil { redisUp = 1 if info, err := redisClient.Info(context.Background(), "clients").Result(); 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 } } } } } w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.") fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge") writePrometheusMetricLine(w, "linearvoid_chat_connected_clients", countConnectedClients()) fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.") fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge") writePrometheusMetricLine(w, "linearvoid_chat_messages_total", storedMessages) fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.") fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge") writePrometheusMetricLine(w, "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") writePrometheusMetricLine(w, "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") writePrometheusMetricLine(w, "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") writePrometheusMetricLine(w, "linearvoid_chat_redis_connected_clients", redisClients) fmt.Fprintln(w, "# HELP linearvoid_chat_admin_sessions_active Active unexpired admin sessions.") fmt.Fprintln(w, "# TYPE linearvoid_chat_admin_sessions_active gauge") writePrometheusMetricLine(w, "linearvoid_chat_admin_sessions_active", countActiveAdminSessions()) fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.") fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge") fmt.Fprintln(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\""+databaseDriverName+"\"} 1") } // broadcast sends a message to all connected clients in a room and saves to DB func broadcast(msg Message) { mutex.Lock() defer mutex.Unlock() // Save to SQLite db.Create(&msg) for client := range clients { if err := client.WriteJSON(msg); err != nil { client.Close() delete(clients, client) } } } // handleConnection handles WebSocket connections for chat (public rooms, private DMs, moderation) func handleConnection(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println("Upgrade error:", err) return } mutex.Lock() clients[conn] = true mutex.Unlock() // Announce presence (ADR-007) username := r.URL.Query().Get("username") room := r.URL.Query().Get("room") if redisClient != nil && username != "" && room != "" { redisClient.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second) broadcast(Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system"}) } defer func() { mutex.Lock() delete(clients, conn) mutex.Unlock() if redisClient != nil && username != "" && room != "" { redisClient.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username)) broadcast(Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system"}) } conn.Close() }() for { var msg Message if err := conn.ReadJSON(&msg); err != nil { break } msg.Timestamp = time.Now() msg.Username = username // enforce from query param for security // Rate limiting check using Redis (ADR-007) if redisClient != nil { key := fmt.Sprintf("rate:%s", msg.Username) count, _ := redisClient.Incr(context.Background(), key).Result() if count == 1 { redisClient.Expire(context.Background(), key, 10*time.Second) } if count > 10 { conn.WriteJSON(Message{Type: "system", Text: "Rate limit exceeded. Please slow down."}) continue } } // Handle moderation commands if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") { handleModerationCommand(msg, conn) continue } // Handle private DMs (room format "dm:user1-user2") if strings.HasPrefix(msg.Room, "dm:") { handlePrivateDM(msg) return } log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text) broadcast(msg) // Save to SQLite (already done in broadcast, but ensure for private DMs) if msg.Type == "private" { db.Create(&msg) } } } // handlePrivateDM routes private messages to the specific user pair func handlePrivateDM(msg Message) { // Save to SQLite for history db.Create(&msg) // Broadcast only to participants (simplified - in production use a map of user connections) broadcast(msg) 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 (ADR-005/ADR-007) func logModerationAction(action, target, moderator, reason string) { logEntry := ModerationLog{ Action: action, Target: target, Moderator: moderator, Reason: reason, Timestamp: time.Now(), } db.Create(&logEntry) log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason) } // handleModerationCommand processes /mod, /mute, /ban commands and logs to ModerationLog func handleModerationCommand(msg Message, conn *websocket.Conn) { parts := strings.Fields(msg.Text) if len(parts) < 3 { conn.WriteJSON(Message{Type: "system", Text: "Usage: /mod or /mute "}) return } action := strings.TrimPrefix(parts[0], "/") target := parts[1] reason := strings.Join(parts[2:], " ") logModerationAction(action, target, msg.Username, reason) broadcast(Message{ Room: msg.Room, Type: "system", Text: fmt.Sprintf("Moderator %s %s %s for: %s", msg.Username, action, target, reason), }) } func main() { initRedis() initDB() http.HandleFunc("/ws", handleConnection) http.HandleFunc("/api/admin/login", handleAdminLogin) http.HandleFunc("/api/admin/logout", handleAdminLogout) http.HandleFunc("/api/admin/session", handleAdminSession) http.HandleFunc("/api/admin/overview", requireAdminAuth(handleAdminOverview)) http.HandleFunc("/api/admin/playlist", requireAdminAuth(handleAdminPlaylist)) http.HandleFunc("/api/admin/moderation/logs", requireAdminAuth(handleAdminModerationLogs)) http.HandleFunc("/api/admin/moderation/actions", requireAdminAuth(handleAdminModerationAction)) http.HandleFunc("/api/admin/analytics", requireAdminAuth(handleAdminAnalytics)) http.HandleFunc("/api/admin/system", requireAdminAuth(handleAdminSystemSummary)) http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers)) http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule)) http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia)) http.HandleFunc("/metrics", handleMetrics) http.HandleFunc("/health", 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)+`"}`) }) port := os.Getenv("CHAT_PORT") if port == "" { port = "3000" } log.Printf("Chat Backend starting on :%s (WebSocket at /ws, health at /health, database persistence enabled)", port) log.Fatal(http.ListenAndServe(":"+port, nil)) }