Files
linearvoid/backend/chat/src/handlers/handlers.go
T
zwitschi cef002862e
CI / test (push) Successful in 1m33s
CI / docker (push) Successful in 2m10s
feat(chat): add transient presence flash and 5s reconnect grace period
2026-08-13 20:45:52 +02:00

2049 lines
66 KiB
Go

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),
}
}