package handlers import ( "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "time" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "chat/src/auth" "chat/src/config" "chat/src/db" "chat/src/media" "chat/src/ratelimit" "chat/src/ws" ) // AdminOverview response payload. type AdminOverview struct { Viewers int `json:"viewers"` StoredMessages int64 `json:"storedMessages"` ModerationActions int64 `json:"moderationActions"` MediaCount int64 `json:"mediaCount"` MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"` MediaTotalRuntime string `json:"mediaTotalRuntime"` RedisEnabled bool `json:"redisEnabled"` RedisClients int `json:"redisClients"` RedisMemoryUsed string `json:"redisMemoryUsed"` DatabaseDriver string `json:"databaseDriver"` GeneratedAt string `json:"generatedAt"` } // PlaylistPayload for playlist read/write. type PlaylistPayload struct { Content string `json:"content"` } // ModerationActionPayload for moderation actions. type ModerationActionPayload struct { Action string `json:"action"` Target string `json:"target"` Moderator string `json:"moderator"` Reason string `json:"reason"` Room string `json:"room"` } // ModerationLogsResponse wraps moderation log entries. type ModerationLogsResponse struct { Logs []db.ModerationLog `json:"logs"` } // RoomAnalytics for per-room message counts. type RoomAnalytics struct { Room string `json:"room"` MessageCount int64 `json:"messageCount"` } // AdminAnalytics response payload. type AdminAnalytics struct { TotalMessages int64 `json:"totalMessages"` MessagesLast24Hours int64 `json:"messagesLast24Hours"` DistinctRooms int64 `json:"distinctRooms"` ModerationActions int64 `json:"moderationActions"` MediaCount int64 `json:"mediaCount"` MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"` MediaTotalRuntime string `json:"mediaTotalRuntime"` TopRooms []RoomAnalytics `json:"topRooms"` GeneratedAt string `json:"generatedAt"` } // ServiceHealth for system summary. type ServiceHealth struct { Name string `json:"name"` Status string `json:"status"` Detail string `json:"detail"` Checked string `json:"checked"` } // AdminSystemSummary response payload. 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"` } // ActiveUser for admin users list. type ActiveUser struct { Username string `json:"username"` Room string `json:"room"` Online bool `json:"online"` } // AdminUsersResponse wraps active users. type AdminUsersResponse struct { Users []ActiveUser `json:"users"` RedisEnabled bool `json:"redisEnabled"` GeneratedAt string `json:"generatedAt"` } // ChatLogEntry for chat log API. type ChatLogEntry struct { ID uint `json:"id"` Room string `json:"room"` Username string `json:"username"` Text string `json:"text"` Timestamp time.Time `json:"timestamp"` Type string `json:"type"` } // ChatLogListResponse wraps chat log entries. type ChatLogListResponse struct { Entries []ChatLogEntry `json:"entries"` Total int64 `json:"total"` } // ChatLogBulkDeleteRequest for bulk deletion. type ChatLogBulkDeleteRequest struct { IDs []uint `json:"ids"` } // AdminScheduleResponse for schedule endpoint. type AdminScheduleResponse struct { Entries []media.ScheduleEntry `json:"entries"` TotalEntries int `json:"totalEntries"` MatchedEntries int `json:"matchedEntries"` TotalDurationSeconds int `json:"totalDurationSeconds"` TotalRuntime string `json:"totalRuntime"` GeneratedAt string `json:"generatedAt"` } // AdminMediaResponse wraps media file entries. type AdminMediaResponse struct { Files []media.FileEntry `json:"files"` GeneratedAt string `json:"generatedAt"` } // AdminMediaUploadInitRequest for chunked upload init. type AdminMediaUploadInitRequest struct { UploadID string `json:"uploadId"` FileName string `json:"fileName"` FileSize int64 `json:"fileSize"` MimeType string `json:"mimeType"` ChunkSize int64 `json:"chunkSize"` TotalChunks int `json:"totalChunks"` } // AdminMediaUploadSession tracks a chunked upload. type AdminMediaUploadSession struct { UploadID string `json:"uploadId"` FileName string `json:"fileName"` FileSize int64 `json:"fileSize"` MimeType string `json:"mimeType"` ChunkSize int64 `json:"chunkSize"` TotalChunks int `json:"totalChunks"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } // AdminMediaUploadStatusResponse for upload progress. type AdminMediaUploadStatusResponse struct { UploadID string `json:"uploadId"` FileName string `json:"fileName"` FileSize int64 `json:"fileSize"` ChunkSize int64 `json:"chunkSize"` TotalChunks int `json:"totalChunks"` UploadedChunks int `json:"uploadedChunks"` Progress float64 `json:"progress"` } // AdminLoginPayload for login requests. type AdminLoginPayload struct { Username string `json:"username"` Password string `json:"password"` } // AdminSessionResponse for session status. type AdminSessionResponse struct { Authenticated bool `json:"authenticated"` Username string `json:"username,omitempty"` } // Dependencies holds all shared dependencies for handlers. type Dependencies struct { DB *gorm.DB Redis db.RedisClient Hub *ws.Hub SessionStore *auth.SessionStore UserSessions *auth.UserSessionStore DBDriver string } // parseRedisInfoValue extracts a value from Redis INFO output. 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 "" } // getAdminOverview builds the admin overview response. func getAdminOverview(deps *Dependencies) AdminOverview { overview := AdminOverview{ RedisEnabled: deps.Redis != nil, RedisMemoryUsed: "unavailable", DatabaseDriver: deps.DBDriver, MediaTotalRuntime: "00:00:00", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if deps.Hub != nil { overview.Viewers = deps.Hub.ConnectedClients() } if deps.DB != nil { deps.DB.Model(&db.Message{}).Count(&overview.StoredMessages) deps.DB.Model(&db.ModerationLog{}).Count(&overview.ModerationActions) deps.DB.Model(&db.Media{}).Count(&overview.MediaCount) deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds) overview.MediaTotalRuntime = media.FormatDuration(int(overview.MediaTotalDurationSeconds)) } if deps.Redis != nil { ctx := context.Background() if info, err := deps.Redis.Info(ctx, "clients", "memory"); 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 } // HandleAdminLogin processes admin login requests. func HandleAdminLogin(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } clientIP := r.Header.Get("X-Forwarded-For") if clientIP == "" { clientIP = r.RemoteAddr } allowed, _ := ratelimit.CheckLoginRate(context.Background(), deps.Redis, clientIP) if !allowed { http.Error(w, "too many login attempts, try again later", http.StatusTooManyRequests) 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 != config.AdminUsername() || payload.Password != config.AdminPassword() { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } token, err := auth.GenerateToken() if err != nil { http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError) return } expiresAt := time.Now().Add(config.AdminSessionTTL()) deps.SessionStore.Add(token, expiresAt) auth.SetCookie(w, r, config.AdminSessionCookieName, token, expiresAt) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()}) } } // HandleAdminLogout processes admin logout. func HandleAdminLogout(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName); ok { deps.SessionStore.Remove(token) } auth.ClearCookie(w, r, config.AdminSessionCookieName) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: false}) } } // HandleAdminSession returns current session status. func HandleAdminSession(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } token, ok := auth.GetTokenFromCookie(r, config.AdminSessionCookieName) if !ok || !deps.SessionStore.Validate(token) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminSessionResponse{Authenticated: true, Username: config.AdminUsername()}) } } // userSessionCookieName is the cookie for registered chat users. const userSessionCookieName = "linearvoid_user_session" // UserCredentialsPayload is used by registration and login. type UserCredentialsPayload struct { Username string `json:"username"` Password string `json:"password"` } // validateUsername checks the username length constraints. func validateUsername(username string) error { if len(username) < 3 || len(username) > 32 { return fmt.Errorf("username must be 3-32 characters") } return nil } // validatePassword checks the password length constraints. func validatePassword(password string) error { if len(password) < 6 { return fmt.Errorf("password must be at least 6 characters") } return nil } // startUserSession issues a user session cookie for the given username. func startUserSession(deps *Dependencies, w http.ResponseWriter, r *http.Request, username string) { token, err := auth.GenerateToken() if err != nil { return } expiresAt := time.Now().Add(config.UserSessionTTL()) deps.UserSessions.Add(token, username, expiresAt) auth.SetCookie(w, r, userSessionCookieName, token, expiresAt) } // HandleUserRegister creates a new chat user and signs them in. func HandleUserRegister(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload UserCredentialsPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } username := strings.TrimSpace(payload.Username) if err := validateUsername(username); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := validatePassword(payload.Password); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var count int64 deps.DB.Model(&db.User{}).Where("username = ?", username).Count(&count) if count > 0 { http.Error(w, "username already taken", http.StatusConflict) return } hash, err := bcrypt.GenerateFromPassword([]byte(payload.Password), bcrypt.DefaultCost) if err != nil { http.Error(w, "failed to hash password", http.StatusInternalServerError) return } user := db.User{Username: username, PasswordHash: string(hash)} if err := deps.DB.Create(&user).Error; err != nil { http.Error(w, "failed to create user", http.StatusInternalServerError) return } startUserSession(deps, w, r, user.Username) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"username": user.Username}) } } // HandleUserLogin verifies credentials and signs the user in. func HandleUserLogin(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload UserCredentialsPayload if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } username := strings.TrimSpace(payload.Username) var user db.User if err := deps.DB.Where("username = ?", username).First(&user).Error; err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(payload.Password)); err != nil { http.Error(w, "invalid credentials", http.StatusUnauthorized) return } startUserSession(deps, w, r, user.Username) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"username": user.Username}) } } // HandleUserSession returns the current user session state. func HandleUserSession(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } token, _ := auth.GetTokenFromCookie(r, userSessionCookieName) username, ok := deps.UserSessions.Username(token) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "authenticated": ok, "username": username, }) } } // HandleUserLogout clears the user session. func HandleUserLogout(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if token, ok := auth.GetTokenFromCookie(r, userSessionCookieName); ok { deps.UserSessions.Remove(token) } auth.ClearCookie(w, r, userSessionCookieName) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"ok": true}) } } // currentUsername returns the authenticated username from the user session cookie. func currentUsername(deps *Dependencies, r *http.Request) (string, bool) { token, ok := auth.GetTokenFromCookie(r, userSessionCookieName) if !ok { return "", false } return deps.UserSessions.Username(token) } // dmRoomFor returns the canonical DM room for two users (sorted, prefixed). func dmRoomFor(a, b string) string { if a > b { a, b = b, a } return "dm:" + a + "-" + b } // HandleUserDMHistory returns paginated private message history with a target user. func HandleUserDMHistory(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username, ok := currentUsername(deps, r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } target := strings.TrimSpace(r.URL.Query().Get("with")) if target == "" { http.Error(w, "missing target user", http.StatusBadRequest) return } limit := 50 if raw := r.URL.Query().Get("limit"); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 { limit = n } } room := dmRoomFor(username, target) query := deps.DB.Where("room = ?", room) if beforeRaw := r.URL.Query().Get("before"); beforeRaw != "" { if t, err := time.Parse(time.RFC3339, beforeRaw); err == nil { query = query.Where("timestamp < ?", t) } } var messages []db.Message if err := query.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { http.Error(w, "failed to load history", http.StatusInternalServerError) return } for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { messages[i], messages[j] = messages[j], messages[i] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } } // HandleUserDMSearch searches private messages with a target user. func HandleUserDMSearch(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } username, ok := currentUsername(deps, r) if !ok { http.Error(w, "unauthorized", http.StatusUnauthorized) return } target := strings.TrimSpace(r.URL.Query().Get("with")) q := strings.TrimSpace(r.URL.Query().Get("q")) if target == "" || q == "" { http.Error(w, "missing target or query", http.StatusBadRequest) return } limit := 50 if raw := r.URL.Query().Get("limit"); raw != "" { if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 200 { limit = n } } room := dmRoomFor(username, target) var messages []db.Message if err := deps.DB.Where("room = ? AND LOWER(text) LIKE ?", room, "%"+strings.ToLower(q)+"%"). Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { http.Error(w, "failed to search", http.StatusInternalServerError) return } for i, j := 0, len(messages)-1; i < j; i, j = i+1, j-1 { messages[i], messages[j] = messages[j], messages[i] } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(messages) } } // HandleAdminOverview returns the admin dashboard overview. func HandleAdminOverview(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { 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(deps)) } } // HandleAdminPlaylist reads/writes the playlist file. func HandleAdminPlaylist(deps *Dependencies) http.HandlerFunc { playlistPath := config.PlaylistPath() return func(w http.ResponseWriter, r *http.Request) { 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) } } } // HandlePublicSchedule returns playlist schedule without auth. func HandlePublicSchedule(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } content, err := os.ReadFile(config.PlaylistPath()) if err != nil { http.Error(w, "schedule unavailable", http.StatusServiceUnavailable) return } entries := media.ParsePlaylist(string(content)) var mediaRecords []db.Media if deps.DB != nil { deps.DB.Find(&mediaRecords) } mediaByName := make(map[string]db.Media, len(mediaRecords)) for _, m := range mediaRecords { mediaByName[strings.ToLower(m.Filename)] = m } type PublicEntry struct { Index int `json:"index"` Label string `json:"label"` Title string `json:"title"` Status string `json:"status"` } output := make([]PublicEntry, 0, len(entries)) for _, e := range entries { pe := PublicEntry{Index: e.Index, Label: e.Label, Status: "pending"} if m, ok := mediaByName[strings.ToLower(filepath.Base(e.Path))]; ok { pe.Title = m.Title pe.Status = m.Status } else { pe.Title = filepath.Base(e.Path) } output = append(output, pe) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "entries": output, "total": len(output), "generatedAt": time.Now().UTC().Format(time.RFC3339), }) } } // HandleAdminSchedule returns the full schedule with media metadata. func HandleAdminSchedule(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } content, err := os.ReadFile(config.PlaylistPath()) if err != nil { http.Error(w, fmt.Sprintf("failed to read schedule playlist: %v", err), http.StatusInternalServerError) return } entries := media.ParsePlaylist(string(content)) var mediaRecords []db.Media if err := deps.DB.Find(&mediaRecords).Error; err != nil { http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError) return } mediaByFilename := make(map[string]db.Media, len(mediaRecords)) mediaByPath := make(map[string]db.Media, len(mediaRecords)) for _, record := range mediaRecords { mediaByFilename[strings.ToLower(record.Filename)] = record mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record } totalDurationSeconds := 0 matchedEntries := 0 for i := range entries { entry := &entries[i] entry.Status = "missing" entry.Runtime = "00:00:00" cleanPath := strings.ToLower(filepath.Clean(entry.Path)) baseName := strings.ToLower(filepath.Base(entry.Path)) record, ok := mediaByPath[cleanPath] if !ok { record, ok = mediaByFilename[baseName] } if !ok { continue } entry.DurationSeconds = record.DurationSeconds entry.Runtime = media.FormatDuration(record.DurationSeconds) entry.Status = record.Status entry.Title = record.Title totalDurationSeconds += record.DurationSeconds matchedEntries++ } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminScheduleResponse{ Entries: entries, TotalEntries: len(entries), MatchedEntries: matchedEntries, TotalDurationSeconds: totalDurationSeconds, TotalRuntime: media.FormatDuration(totalDurationSeconds), GeneratedAt: time.Now().UTC().Format(time.RFC3339), }) } } // HandleAdminScheduleOverrides manages CRUD for schedule overrides. func HandleAdminScheduleOverrides(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: var overrides []db.ScheduleOverride deps.DB.Order("start_time asc").Find(&overrides) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(overrides) case http.MethodPost: var ov db.ScheduleOverride if err := json.NewDecoder(r.Body).Decode(&ov); err != nil { http.Error(w, "invalid request body", http.StatusBadRequest) return } if ov.MediaID == 0 || ov.StartTime.IsZero() { http.Error(w, "mediaId and startTime are required", http.StatusBadRequest) return } var conflict int64 deps.DB.Model(&db.ScheduleOverride{}). Where("start_time < ? AND end_time > ?", ov.EndTime, ov.StartTime). Count(&conflict) if conflict > 0 { http.Error(w, fmt.Sprintf("conflict: %d overlapping override(s)", conflict), http.StatusConflict) return } deps.DB.Create(&ov) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(ov) case http.MethodDelete: idStr := r.URL.Query().Get("id") if idStr == "" { http.Error(w, "id query param required", http.StatusBadRequest) return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { http.Error(w, "invalid id", http.StatusBadRequest) return } if err := deps.DB.Delete(&db.ScheduleOverride{}, id).Error; err != nil { http.Error(w, "failed to delete override", http.StatusInternalServerError) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // HandleAdminModerationLogs returns moderation log entries. func HandleAdminModerationLogs(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } 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 []db.ModerationLog if err := deps.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}) } } // HandleAdminModerationAction processes a moderation action. func HandleAdminModerationAction(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload 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" } // Log to DB logEntry := db.ModerationLog{ Action: payload.Action, Target: payload.Target, Moderator: payload.Moderator, Reason: payload.Reason, Timestamp: time.Now(), } deps.DB.Create(&logEntry) // Broadcast system message if deps.Hub != nil { deps.Hub.Broadcast(db.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) } } // HandleAdminAnalytics returns analytics data. func HandleAdminAnalytics(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } analytics := AdminAnalytics{ TopRooms: []RoomAnalytics{}, MediaTotalRuntime: "00:00:00", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if deps.DB == nil { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(analytics) return } deps.DB.Model(&db.Message{}).Count(&analytics.TotalMessages) deps.DB.Model(&db.Message{}). Where("timestamp >= ?", time.Now().Add(-24*time.Hour)). Count(&analytics.MessagesLast24Hours) deps.DB.Model(&db.Message{}).Distinct("room").Count(&analytics.DistinctRooms) deps.DB.Model(&db.ModerationLog{}).Count(&analytics.ModerationActions) deps.DB.Model(&db.Media{}).Count(&analytics.MediaCount) deps.DB.Model(&db.Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds) analytics.MediaTotalRuntime = media.FormatDuration(int(analytics.MediaTotalDurationSeconds)) var topRooms []RoomAnalytics deps.DB.Model(&db.Message{}). Select("room, COUNT(*) as message_count"). Group("room"). Order("message_count DESC"). Limit(5). Scan(&topRooms) analytics.TopRooms = topRooms w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(analytics) } } // checkServiceHealth probes a service health endpoint. 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} } // HandleAdminSystemSummary returns system health information. func HandleAdminSystemSummary(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } summary := AdminSystemSummary{ DatabaseDriver: deps.DBDriver, PlaylistPath: config.PlaylistPath(), RedisEnabled: deps.Redis != nil, RedisMemoryUsed: "unavailable", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if _, err := os.Stat(summary.PlaylistPath); err == nil { summary.PlaylistExists = true } if deps.Redis != nil { if info, err := deps.Redis.Info(context.Background(), "memory"); 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 deps.Redis != nil { if err := deps.Redis.Ping(context.Background()); 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 := deps.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}) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(summary) } } // HandlePublicMatomo exposes the Matomo analytics settings for the viewer frontend. func HandlePublicMatomo() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "matomoUrl": config.MatomoURL(), "matomoSiteId": config.MatomoSiteID(), }) } } // adminConfigResponse builds the admin configuration payload from runtime config // and read-only runtime facts. func adminConfigResponse(deps *Dependencies) map[string]any { resp := config.RuntimeConfigSnapshot() resp["rateLimitMax"] = config.RateLimitMax() resp["rateLimitWindowSec"] = int(config.RateLimitWindow().Seconds()) resp["databaseDriver"] = deps.DBDriver resp["redisEnabled"] = deps.Redis != nil resp["sessionTTLHours"] = int(config.AdminSessionTTL().Hours()) return resp } // HandleAdminConfig returns/updates runtime configuration. func HandleAdminConfig(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(adminConfigResponse(deps)) case http.MethodPut: // Editable runtime fields (Matomo + HLS). Rate limit fields remain // env-driven and are intentionally read-only here. var payload struct { MatomoURL *string `json:"matomoUrl"` MatomoSiteID *string `json:"matomoSiteId"` HLSSegmentDuration *int `json:"hlsSegmentDuration"` HLSWindowSize *int `json:"hlsWindowSize"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if payload.MatomoURL != nil { if err := config.SetMatomoURL(*payload.MatomoURL); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } if payload.MatomoSiteID != nil { if err := config.SetMatomoSiteID(*payload.MatomoSiteID); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } if payload.HLSSegmentDuration != nil { if err := config.SetHLSSegmentDuration(*payload.HLSSegmentDuration); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } if payload.HLSWindowSize != nil { if err := config.SetHLSWindowSize(*payload.HLSWindowSize); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } } if err := config.SaveRuntimeConfig(); err != nil { http.Error(w, fmt.Sprintf("failed to save config: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(adminConfigResponse(deps)) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // HandleAdminRestart returns manual restart instructions. func HandleAdminRestart(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } service := r.URL.Query().Get("service") w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "status": "restart_not_automated", "service": service, "message": fmt.Sprintf("Restart %s manually: docker compose -f docker-compose.prod.yml restart %s", service, service), }) } } // HandleAdminUsers lists and manages users. func HandleAdminUsers(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: response := AdminUsersResponse{ Users: []ActiveUser{}, RedisEnabled: deps.Redis != nil, GeneratedAt: time.Now().UTC().Format(time.RFC3339), } if deps.Hub != nil && deps.Redis != nil { presences, err := deps.Hub.ScanPresence(context.Background()) if err != nil { http.Error(w, fmt.Sprintf("failed to scan presence keys: %v", err), http.StatusInternalServerError) return } for _, p := range presences { response.Users = append(response.Users, ActiveUser{ Room: p.Room, Username: p.Username, Online: p.Online, }) } } 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) case http.MethodPost: var payload struct { Action string `json:"action"` Username string `json:"username"` Duration int `json:"duration"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, "invalid body", http.StatusBadRequest) return } if payload.Action == "" || payload.Username == "" { http.Error(w, "action and username required", http.StatusBadRequest) return } if deps.Hub == nil { http.Error(w, "Redis unavailable", http.StatusServiceUnavailable) return } ctx := context.Background() switch payload.Action { case "mute": dur := time.Duration(payload.Duration) * time.Minute if dur <= 0 { dur = 10 * time.Minute } deps.Hub.MuteUser(ctx, payload.Username, dur) case "unmute": deps.Hub.UnmuteUser(ctx, payload.Username) case "ban": deps.Hub.BanUser(ctx, payload.Username) case "unban": deps.Hub.UnbanUser(ctx, payload.Username) default: http.Error(w, "unknown action", http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "ok", "action": payload.Action, "username": payload.Username}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // HandleAdminMedia lists and uploads media files. func HandleAdminMedia(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: files, err := media.ListFiles(deps.DB) 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() fileName := filepath.Base(strings.TrimSpace(header.Filename)) if fileName == "" { http.Error(w, "missing file name", http.StatusBadRequest) return } if !media.IsAllowedExtension(fileName) { http.Error(w, "unsupported file type", http.StatusBadRequest) return } if header.Size > config.MaxAdminUploadBytes { http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge) return } mediaDir := config.MediaDir() if err := os.MkdirAll(mediaDir, 0o755); err != nil { http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError) return } targetPath := media.UniqueTargetPath(mediaDir, fileName) targetFile, err := os.Create(targetPath) if err != nil { http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError) return } if _, err := io.Copy(targetFile, file); err != nil { targetFile.Close() _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError) return } if err := targetFile.Close(); err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError) return } fileInfo, err := os.Stat(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError) return } if fileInfo.Size() > config.MaxAdminUploadBytes { _ = os.Remove(targetPath) http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge) return } detectedMime, err := media.DetectMime(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest) return } if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest) return } durationSeconds, err := media.ProbeDurationSeconds(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest) return } normResult := media.ValidateNormalization(targetPath) status := media.StatusReady if !normResult.Valid { status = media.StatusNeedsNormalization } filename := filepath.Base(targetPath) mediaRecord := db.Media{ Filename: filename, OriginalName: fileName, Path: targetPath, SizeBytes: fileInfo.Size(), DurationSeconds: durationSeconds, Status: status, Title: strings.TrimSuffix(filename, filepath.Ext(filename)), Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid), } if err := deps.DB.Create(&mediaRecord).Error; err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError) return } if !normResult.Valid { go normalizeMediaRecord(deps, mediaRecord) } files, err := media.ListFiles(deps.DB) 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) } } } // HandleAdminMediaUploads manages chunked upload sessions. func HandleAdminMediaUploads(deps *Dependencies) http.HandlerFunc { basePath := "/api/admin/media/uploads" mediaDir := config.MediaDir() return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == basePath { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload AdminMediaUploadInitRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } fileName := filepath.Base(strings.TrimSpace(payload.FileName)) if fileName == "" { http.Error(w, "file name is required", http.StatusBadRequest) return } if !media.IsAllowedExtension(fileName) { http.Error(w, "unsupported file type", http.StatusBadRequest) return } if payload.FileSize <= 0 { http.Error(w, "file size must be positive", http.StatusBadRequest) return } if payload.FileSize > config.MaxAdminUploadBytes { http.Error(w, "file exceeds 2GB limit", http.StatusRequestEntityTooLarge) return } if !media.IsAllowedMime(payload.MimeType) { http.Error(w, "unsupported mime type", http.StatusBadRequest) return } chunkSize := payload.ChunkSize if chunkSize <= 0 { chunkSize = config.DefaultAdminChunkSize } if chunkSize > config.MaxAdminChunkSize { http.Error(w, "chunk size exceeds server limit", http.StatusBadRequest) return } expectedChunks := int((payload.FileSize + chunkSize - 1) / chunkSize) if payload.TotalChunks <= 0 { payload.TotalChunks = expectedChunks } if payload.TotalChunks != expectedChunks { http.Error(w, "total chunk count mismatch", http.StatusBadRequest) return } uploadID := strings.TrimSpace(payload.UploadID) if uploadID != "" && !media.ValidateUploadID(uploadID) { http.Error(w, "invalid upload id", http.StatusBadRequest) return } now := time.Now().UTC().Format(time.RFC3339) session := AdminMediaUploadSession{} if uploadID != "" { loaded, err := loadUploadSession(mediaDir, uploadID) if err == nil { if loaded.FileName != fileName || loaded.FileSize != payload.FileSize { http.Error(w, "upload id does not match selected file", http.StatusConflict) return } session = loaded } else if !os.IsNotExist(err) { http.Error(w, fmt.Sprintf("failed to resume upload session: %v", err), http.StatusInternalServerError) return } } if session.UploadID == "" { randomID, err := auth.GenerateToken() if err != nil { http.Error(w, fmt.Sprintf("failed to create upload session: %v", err), http.StatusInternalServerError) return } session = AdminMediaUploadSession{ UploadID: randomID, FileName: fileName, FileSize: payload.FileSize, MimeType: payload.MimeType, ChunkSize: chunkSize, TotalChunks: payload.TotalChunks, CreatedAt: now, UpdatedAt: now, } } else { session.UpdatedAt = now } if err := saveUploadSession(mediaDir, session); err != nil { http.Error(w, fmt.Sprintf("failed to persist upload session: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session)) return } if !strings.HasPrefix(r.URL.Path, basePath+"/") { http.NotFound(w, r) return } remainder := strings.TrimPrefix(r.URL.Path, basePath+"/") parts := strings.Split(strings.Trim(remainder, "/"), "/") if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" { http.Error(w, "missing upload id", http.StatusBadRequest) return } uploadID := strings.TrimSpace(parts[0]) if !media.ValidateUploadID(uploadID) { http.Error(w, "invalid upload id", http.StatusBadRequest) return } session, err := loadUploadSession(mediaDir, uploadID) if err != nil { if os.IsNotExist(err) { http.Error(w, "upload session not found", http.StatusNotFound) return } http.Error(w, fmt.Sprintf("failed to read upload session: %v", err), http.StatusInternalServerError) return } if len(parts) == 1 { 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(buildUploadStatus(mediaDir, session)) return } if len(parts) != 2 { http.NotFound(w, r) return } subresource := parts[1] switch subresource { case "chunk": if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } chunkIndexRaw := strings.TrimSpace(r.URL.Query().Get("index")) chunkIndex, err := strconv.Atoi(chunkIndexRaw) if err != nil || chunkIndex < 0 || chunkIndex >= session.TotalChunks { http.Error(w, "invalid chunk index", http.StatusBadRequest) return } chunkPath := media.UploadChunkPath(mediaDir, uploadID, chunkIndex) tmpPath := chunkPath + ".tmp" chunkFile, err := os.Create(tmpPath) if err != nil { http.Error(w, fmt.Sprintf("failed to open chunk target: %v", err), http.StatusInternalServerError) return } maxRead := session.ChunkSize + 1 written, copyErr := io.Copy(chunkFile, io.LimitReader(r.Body, maxRead)) closeErr := chunkFile.Close() if copyErr != nil || closeErr != nil { _ = os.Remove(tmpPath) http.Error(w, "failed to persist upload chunk", http.StatusInternalServerError) return } expectedMax := session.ChunkSize if chunkIndex == session.TotalChunks-1 { remaining := session.FileSize - (int64(chunkIndex) * session.ChunkSize) if remaining > 0 { expectedMax = remaining } } if written <= 0 || written > expectedMax { _ = os.Remove(tmpPath) http.Error(w, "invalid chunk size", http.StatusBadRequest) return } if err := os.Rename(tmpPath, chunkPath); err != nil { _ = os.Remove(tmpPath) http.Error(w, fmt.Sprintf("failed to finalize chunk: %v", err), http.StatusInternalServerError) return } session.UpdatedAt = time.Now().UTC().Format(time.RFC3339) if err := saveUploadSession(mediaDir, session); err != nil { http.Error(w, fmt.Sprintf("failed to update upload session: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(buildUploadStatus(mediaDir, session)) case "complete": if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } if media.CountUploadedChunks(mediaDir, uploadID, session.TotalChunks) != session.TotalChunks { http.Error(w, "upload is incomplete", http.StatusBadRequest) return } if err := os.MkdirAll(mediaDir, 0o755); err != nil { http.Error(w, fmt.Sprintf("failed to ensure media directory: %v", err), http.StatusInternalServerError) return } targetPath := media.UniqueTargetPath(mediaDir, session.FileName) targetFile, err := os.Create(targetPath) if err != nil { http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError) return } var totalWritten int64 for i := 0; i < session.TotalChunks; i++ { chunkPath := media.UploadChunkPath(mediaDir, uploadID, i) chunkReader, err := os.Open(chunkPath) if err != nil { targetFile.Close() _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("missing chunk %d", i), http.StatusBadRequest) return } written, err := io.Copy(targetFile, chunkReader) _ = chunkReader.Close() if err != nil { targetFile.Close() _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to assemble media file: %v", err), http.StatusInternalServerError) return } totalWritten += written } if err := targetFile.Close(); err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError) return } if totalWritten != session.FileSize { _ = os.Remove(targetPath) http.Error(w, "assembled file size mismatch", http.StatusBadRequest) return } detectedMime, err := media.DetectMime(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to inspect media file: %v", err), http.StatusBadRequest) return } if !strings.HasPrefix(strings.ToLower(detectedMime), "video/") { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("unsupported media content type: %s", detectedMime), http.StatusBadRequest) return } durationSeconds, err := media.ProbeDurationSeconds(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest) return } fileInfo, err := os.Stat(targetPath) if err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError) return } normResult := media.ValidateNormalization(targetPath) status := media.StatusReady if !normResult.Valid { status = media.StatusNeedsNormalization } filename := filepath.Base(targetPath) mediaRecord := db.Media{ Filename: filename, OriginalName: session.FileName, Path: targetPath, SizeBytes: fileInfo.Size(), DurationSeconds: durationSeconds, Status: status, Title: strings.TrimSuffix(filename, filepath.Ext(filename)), Tags: fmt.Sprintf(`"normalization":%v`, normResult.Valid), } if err := deps.DB.Create(&mediaRecord).Error; err != nil { _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError) return } if !normResult.Valid { go normalizeMediaRecord(deps, mediaRecord) } _ = os.RemoveAll(media.UploadSessionDir(mediaDir, uploadID)) files, err := media.ListFiles(deps.DB) 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.NotFound(w, r) } } } // HandleAdminMediaByID handles single media item CRUD. func HandleAdminMediaByID(deps *Dependencies) http.HandlerFunc { mediaDir := config.MediaDir() return func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/normalize") { handleNormalizeMedia(deps, w, r) return } mediaID, err := media.GetMediaIDFromPath(r.URL.Path, "/api/admin/media/") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } var record db.Media if err := deps.DB.First(&record, mediaID).Error; err != nil { http.Error(w, "media item not found", http.StatusNotFound) return } switch r.Method { case http.MethodGet: w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(mediaEntryFromRecord(record)) case http.MethodPut: var payload struct { Status string `json:"status"` Title string `json:"title"` Tags string `json:"tags"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } updates := map[string]any{} if payload.Status != "" { updates["status"] = strings.TrimSpace(payload.Status) } if payload.Title != "" { updates["title"] = strings.TrimSpace(payload.Title) } updates["tags"] = strings.TrimSpace(payload.Tags) if err := deps.DB.Model(&record).Updates(updates).Error; err != nil { http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError) return } if err := deps.DB.First(&record, mediaID).Error; err != nil { http.Error(w, "media item not found after update", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(mediaEntryFromRecord(record)) case http.MethodDelete: absMediaDir, err := filepath.Abs(mediaDir) if err != nil { http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError) return } absTargetPath, err := filepath.Abs(record.Path) if err != nil { http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError) return } prefix := absMediaDir + string(os.PathSeparator) if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) { http.Error(w, "media path consistency check failed", http.StatusConflict) return } if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) { http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError) return } if err := deps.DB.Delete(&record).Error; err != nil { http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID}) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // normalizeMediaRecord runs ffmpeg normalization for a media record and updates // its status, path, duration, and size. Safe to run asynchronously. func normalizeMediaRecord(deps *Dependencies, record db.Media) { src := record.Path dst := strings.TrimSuffix(src, filepath.Ext(src)) + ".normalized.mp4" if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil { return } duration, size, err := media.NormalizeVideo(src, dst) if err != nil { _ = deps.DB.Model(&record).Updates(map[string]any{ "status": media.StatusNormalizationFailed, "tags": `"normalization":false`, }).Error return } _ = deps.DB.Model(&record).Updates(map[string]any{ "path": dst, "filename": filepath.Base(dst), "size_bytes": size, "duration_seconds": duration, "status": media.StatusReady, "tags": `"normalization":true`, }).Error } // handleNormalizeMedia handles POST /api/admin/media/{id}/normalize. func handleNormalizeMedia(deps *Dependencies, w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } idPart := strings.TrimSuffix(r.URL.Path, "/normalize") idPart = strings.TrimPrefix(idPart, "/api/admin/media/") idPart = strings.Trim(idPart, "/") mediaID, err := strconv.ParseUint(idPart, 10, 64) if err != nil || mediaID == 0 { http.Error(w, "invalid media id", http.StatusBadRequest) return } var record db.Media if err := deps.DB.First(&record, mediaID).Error; err != nil { http.Error(w, "media item not found", http.StatusNotFound) return } if record.Status == media.StatusNormalizing { http.Error(w, "normalization already in progress", http.StatusConflict) return } record.Status = media.StatusNormalizing if err := deps.DB.Model(&record).Update("status", media.StatusNormalizing).Error; err != nil { http.Error(w, fmt.Sprintf("failed to update media status: %v", err), http.StatusInternalServerError) return } go normalizeMediaRecord(deps, record) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(mediaEntryFromRecord(record)) } // HandleAdminChatLog lists chat log entries. func HandleAdminChatLog(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } query := r.URL.Query() room := strings.TrimSpace(query.Get("room")) limit := 50 if rawLimit := strings.TrimSpace(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 } dbQuery := deps.DB.Model(&db.Message{}).Where("type <> ?", "system") if room != "" { dbQuery = dbQuery.Where("room = ?", room) } if beforeRaw := strings.TrimSpace(query.Get("before")); beforeRaw != "" { beforeTime, err := time.Parse(time.RFC3339, beforeRaw) if err != nil { http.Error(w, "invalid before timestamp, use RFC3339 format", http.StatusBadRequest) return } dbQuery = dbQuery.Where("timestamp < ?", beforeTime) } if username := strings.TrimSpace(query.Get("username")); username != "" { dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%") } if text := strings.TrimSpace(query.Get("text")); text != "" { dbQuery = dbQuery.Where("text LIKE ?", "%"+text+"%") } var total int64 if err := dbQuery.Count(&total).Error; err != nil { http.Error(w, fmt.Sprintf("failed to count chat log entries: %v", err), http.StatusInternalServerError) return } var messages []db.Message if err := dbQuery.Order("timestamp desc").Limit(limit).Find(&messages).Error; err != nil { http.Error(w, fmt.Sprintf("failed to load chat log entries: %v", err), http.StatusInternalServerError) return } entries := make([]ChatLogEntry, len(messages)) for i, msg := range messages { entries[i] = ChatLogEntry{ ID: msg.ID, Room: msg.Room, Username: msg.Username, Text: msg.Text, Timestamp: msg.Timestamp, Type: msg.Type, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ChatLogListResponse{Entries: entries, Total: total}) } } // HandleAdminChatLogByID returns or deletes a single chat log entry. func HandleAdminChatLogByID(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { idPart := strings.TrimPrefix(r.URL.Path, "/api/admin/chat-log/") idPart = strings.TrimSpace(idPart) if idPart == "" || strings.Contains(idPart, "/") { http.Error(w, "invalid chat log id", http.StatusBadRequest) return } id, err := strconv.ParseUint(idPart, 10, 64) if err != nil || id == 0 { http.Error(w, "invalid chat log id", http.StatusBadRequest) return } switch r.Method { case http.MethodGet: var msg db.Message if err := deps.DB.First(&msg, id).Error; err != nil { http.Error(w, "chat log entry not found", http.StatusNotFound) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(ChatLogEntry{ ID: msg.ID, Room: msg.Room, Username: msg.Username, Text: msg.Text, Timestamp: msg.Timestamp, Type: msg.Type, }) case http.MethodDelete: result := deps.DB.Delete(&db.Message{}, id) if result.Error != nil { http.Error(w, fmt.Sprintf("failed to delete chat log entry: %v", result.Error), http.StatusInternalServerError) return } if result.RowsAffected == 0 { http.Error(w, "chat log entry not found", http.StatusNotFound) return } w.WriteHeader(http.StatusNoContent) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } } // HandleAdminChatLogExport exports chat log as CSV. func HandleAdminChatLogExport(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } query := r.URL.Query() room := strings.TrimSpace(query.Get("room")) dbQuery := deps.DB.Model(&db.Message{}) if room != "" { dbQuery = dbQuery.Where("room = ?", room) } if username := strings.TrimSpace(query.Get("username")); username != "" { dbQuery = dbQuery.Where("username LIKE ?", "%"+username+"%") } var messages []db.Message if err := dbQuery.Order("timestamp desc").Limit(10000).Find(&messages).Error; err != nil { http.Error(w, "failed to load messages", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/csv; charset=utf-8") w.Header().Set("Content-Disposition", "attachment; filename=chat-log.csv") fmt.Fprintln(w, "id,room,username,type,text,timestamp") for _, msg := range messages { text := strings.ReplaceAll(msg.Text, `"`, `""`) fmt.Fprintf(w, "%d,%s,%s,%s,\"%s\",%s\n", msg.ID, msg.Room, msg.Username, msg.Type, text, msg.Timestamp.Format(time.RFC3339)) } } } // HandleAdminChatLogBulk deletes multiple chat log entries. func HandleAdminChatLogBulk(deps *Dependencies) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var payload ChatLogBulkDeleteRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) return } if len(payload.IDs) == 0 { http.Error(w, "ids array is required", http.StatusBadRequest) return } if len(payload.IDs) > 500 { http.Error(w, "maximum 500 ids per bulk request", http.StatusBadRequest) return } result := deps.DB.Delete(&db.Message{}, payload.IDs) if result.Error != nil { http.Error(w, fmt.Sprintf("failed to bulk delete chat log entries: %v", result.Error), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"deleted": result.RowsAffected}) } } // --- Upload session helpers --- func loadUploadSession(mediaDir, uploadID string) (AdminMediaUploadSession, error) { var session AdminMediaUploadSession data, err := os.ReadFile(media.UploadSessionMetaPath(mediaDir, uploadID)) if err != nil { return session, err } if err := json.Unmarshal(data, &session); err != nil { return session, err } return session, nil } func saveUploadSession(mediaDir string, session AdminMediaUploadSession) error { if err := os.MkdirAll(media.UploadSessionDir(mediaDir, session.UploadID), 0o755); err != nil { return err } data, err := json.MarshalIndent(session, "", " ") if err != nil { return err } return os.WriteFile(media.UploadSessionMetaPath(mediaDir, session.UploadID), data, 0o644) } func buildUploadStatus(mediaDir string, session AdminMediaUploadSession) AdminMediaUploadStatusResponse { uploadedChunks := media.CountUploadedChunks(mediaDir, session.UploadID, session.TotalChunks) progress := 0.0 if session.TotalChunks > 0 { progress = (float64(uploadedChunks) / float64(session.TotalChunks)) * 100 } return AdminMediaUploadStatusResponse{ UploadID: session.UploadID, FileName: session.FileName, FileSize: session.FileSize, ChunkSize: session.ChunkSize, TotalChunks: session.TotalChunks, UploadedChunks: uploadedChunks, Progress: progress, } } func mediaEntryFromRecord(record db.Media) media.FileEntry { return media.FileEntry{ ID: record.ID, Name: record.Filename, OriginalName: record.OriginalName, Path: record.Path, Size: record.SizeBytes, DurationSeconds: record.DurationSeconds, Runtime: media.FormatDuration(record.DurationSeconds), Status: record.Status, Title: record.Title, Tags: record.Tags, UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339), ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339), } }