1666 lines
54 KiB
Go
1666 lines
54 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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
|
|
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()})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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(map[string]any{
|
|
"rateLimitMax": config.RateLimitMax(),
|
|
"rateLimitWindowSec": int(config.RateLimitWindow().Seconds()),
|
|
"databaseDriver": deps.DBDriver,
|
|
"redisEnabled": deps.Redis != nil,
|
|
"sessionTTLHours": int(config.AdminSessionTTL().Hours()),
|
|
})
|
|
case http.MethodPut:
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
http.Error(w, "invalid body", http.StatusBadRequest)
|
|
return
|
|
}
|
|
// Note: rate limit config is read-only at runtime in this refactored version.
|
|
// To change, restart with env vars.
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
|
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 := "ready"
|
|
if !normResult.Valid {
|
|
status = "needs_normalization"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 := "ready"
|
|
if !normResult.Valid {
|
|
status = "needs_normalization"
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
_ = 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) {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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{})
|
|
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),
|
|
}
|
|
} |