feat: enhance media management with CRUD operations and runtime tracking in admin UI
This commit is contained in:
1 parent
47740e3f4a
commit
7cfddfc7ef
6 files changed
+535
-103
No files matched your search
+339
-36
@@ -10,6 +10,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -37,7 +38,7 @@ var (
|
||||
authSessionsMu = sync.Mutex{}
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQLite per ADR-006)
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Room string `json:"room"`
|
||||
@@ -57,6 +58,21 @@ type ModerationLog struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Filename string `gorm:"index" json:"filename"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Presence tracks online users per room (using Redis per ADR-007)
|
||||
type Presence struct {
|
||||
Username string `json:"username"`
|
||||
@@ -68,6 +84,9 @@ 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"`
|
||||
@@ -101,6 +120,9 @@ type AdminAnalytics struct {
|
||||
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"`
|
||||
}
|
||||
@@ -135,20 +157,37 @@ type AdminUsersResponse struct {
|
||||
}
|
||||
|
||||
type ScheduleEntry struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type AdminScheduleResponse struct {
|
||||
Entries []ScheduleEntry `json:"entries"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
Entries []ScheduleEntry `json:"entries"`
|
||||
TotalEntries int `json:"totalEntries"`
|
||||
MatchedEntries int `json:"matchedEntries"`
|
||||
TotalDurationSeconds int `json:"totalDurationSeconds"`
|
||||
TotalRuntime string `json:"totalRuntime"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type MediaFileEntry struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt string `json:"uploadedAt"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type AdminMediaResponse struct {
|
||||
@@ -199,7 +238,7 @@ func initRedis() {
|
||||
}
|
||||
}
|
||||
|
||||
// initDB initializes SQLite for message history and moderation audit log (ADR-006)
|
||||
// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006)
|
||||
func initDB() {
|
||||
var err error
|
||||
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
@@ -226,8 +265,8 @@ func initDB() {
|
||||
}
|
||||
|
||||
databaseDriverName = driverName
|
||||
db.AutoMigrate(&Message{}, &ModerationLog{})
|
||||
log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName))
|
||||
db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName))
|
||||
}
|
||||
|
||||
func parseRedisInfoValue(info, key string) string {
|
||||
@@ -246,6 +285,7 @@ func getAdminOverview() AdminOverview {
|
||||
RedisEnabled: redisClient != nil,
|
||||
RedisMemoryUsed: "unavailable",
|
||||
DatabaseDriver: databaseDriverName,
|
||||
MediaTotalRuntime: "00:00:00",
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@@ -256,6 +296,9 @@ func getAdminOverview() AdminOverview {
|
||||
if db != nil {
|
||||
db.Model(&Message{}).Count(&overview.StoredMessages)
|
||||
db.Model(&ModerationLog{}).Count(&overview.ModerationActions)
|
||||
db.Model(&Media{}).Count(&overview.MediaCount)
|
||||
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds)
|
||||
overview.MediaTotalRuntime = formatDuration(int(overview.MediaTotalDurationSeconds))
|
||||
}
|
||||
|
||||
if redisClient != nil {
|
||||
@@ -541,10 +584,55 @@ func handleAdminSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
entries := parsePlaylistEntries(string(content))
|
||||
|
||||
var mediaRecords []Media
|
||||
if err := 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]Media, len(mediaRecords))
|
||||
mediaByPath := make(map[string]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 = 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: parsePlaylistEntries(string(content)),
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Entries: entries,
|
||||
TotalEntries: len(entries),
|
||||
MatchedEntries: matchedEntries,
|
||||
TotalDurationSeconds: totalDurationSeconds,
|
||||
TotalRuntime: formatDuration(totalDurationSeconds),
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,6 +712,7 @@ func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) {
|
||||
func getAdminAnalytics() AdminAnalytics {
|
||||
analytics := AdminAnalytics{
|
||||
TopRooms: []RoomAnalytics{},
|
||||
MediaTotalRuntime: "00:00:00",
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
@@ -637,6 +726,9 @@ func getAdminAnalytics() AdminAnalytics {
|
||||
Count(&analytics.MessagesLast24Hours)
|
||||
db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms)
|
||||
db.Model(&ModerationLog{}).Count(&analytics.ModerationActions)
|
||||
db.Model(&Media{}).Count(&analytics.MediaCount)
|
||||
db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds)
|
||||
analytics.MediaTotalRuntime = formatDuration(int(analytics.MediaTotalDurationSeconds))
|
||||
|
||||
var topRooms []RoomAnalytics
|
||||
db.Model(&Message{}).
|
||||
@@ -784,37 +876,126 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func listMediaFiles() ([]MediaFileEntry, error) {
|
||||
mediaDir := getMediaDir()
|
||||
entries, err := os.ReadDir(mediaDir)
|
||||
if err != nil {
|
||||
var records []Media
|
||||
if err := db.Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]MediaFileEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
files := make([]MediaFileEntry, 0, len(records))
|
||||
for _, record := range records {
|
||||
files = append(files, MediaFileEntry{
|
||||
Name: entry.Name(),
|
||||
Size: info.Size(),
|
||||
ModifiedAt: info.ModTime().UTC().Format(time.RFC3339),
|
||||
ID: record.ID,
|
||||
Name: record.Filename,
|
||||
OriginalName: record.OriginalName,
|
||||
Path: record.Path,
|
||||
Size: record.SizeBytes,
|
||||
DurationSeconds: record.DurationSeconds,
|
||||
Runtime: 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),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Name < files[j].Name
|
||||
if files[i].UploadedAt == files[j].UploadedAt {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].UploadedAt > files[j].UploadedAt
|
||||
})
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func formatDuration(seconds int) string {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
remainingSeconds := seconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
func getMediaIDFromPath(path string) (uint, error) {
|
||||
prefix := "/api/admin/media/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return 0, fmt.Errorf("invalid media path")
|
||||
}
|
||||
idPart := strings.TrimPrefix(path, prefix)
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart == "" || strings.Contains(idPart, "/") {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
return uint(id), nil
|
||||
}
|
||||
|
||||
func mediaEntryFromRecord(record Media) MediaFileEntry {
|
||||
return MediaFileEntry{
|
||||
ID: record.ID,
|
||||
Name: record.Filename,
|
||||
OriginalName: record.OriginalName,
|
||||
Path: record.Path,
|
||||
Size: record.SizeBytes,
|
||||
DurationSeconds: record.DurationSeconds,
|
||||
Runtime: 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),
|
||||
}
|
||||
}
|
||||
|
||||
func probeMediaDurationSeconds(mediaPath string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(
|
||||
ctx,
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 0, fmt.Errorf("ffprobe timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rawDuration := strings.TrimSpace(string(out))
|
||||
if rawDuration == "" {
|
||||
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||
}
|
||||
|
||||
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||
}
|
||||
if secondsFloat < 0 {
|
||||
secondsFloat = 0
|
||||
}
|
||||
|
||||
seconds := int(secondsFloat + 0.5)
|
||||
if secondsFloat > 0 && seconds == 0 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@@ -851,12 +1032,49 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer targetFile.Close()
|
||||
|
||||
if _, err := io.Copy(targetFile, file); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
durationSeconds, err := probeMediaDurationSeconds(targetPath)
|
||||
if err != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
filename := filepath.Base(header.Filename)
|
||||
mediaRecord := Media{
|
||||
Filename: filename,
|
||||
OriginalName: header.Filename,
|
||||
Path: targetPath,
|
||||
SizeBytes: fileInfo.Size(),
|
||||
DurationSeconds: durationSeconds,
|
||||
Status: "ready",
|
||||
Title: strings.TrimSuffix(filename, filepath.Ext(filename)),
|
||||
Tags: "",
|
||||
}
|
||||
if err := 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 := listMediaFiles()
|
||||
if err != nil {
|
||||
@@ -871,6 +1089,90 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) {
|
||||
mediaID, err := getMediaIDFromPath(r.URL.Path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
var record Media
|
||||
if err := 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 := 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 := 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(getMediaDir())
|
||||
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 := 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)
|
||||
}
|
||||
}
|
||||
|
||||
func countConnectedClients() int {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
@@ -976,7 +1278,7 @@ func broadcast(msg Message) {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
|
||||
// Save to SQLite
|
||||
// Persist to configured SQL backend.
|
||||
db.Create(&msg)
|
||||
|
||||
for client := range clients {
|
||||
@@ -1054,7 +1356,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||
broadcast(msg)
|
||||
|
||||
// Save to SQLite (already done in broadcast, but ensure for private DMs)
|
||||
// Save private messages explicitly as well.
|
||||
if msg.Type == "private" {
|
||||
db.Create(&msg)
|
||||
}
|
||||
@@ -1063,7 +1365,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handlePrivateDM routes private messages to the specific user pair
|
||||
func handlePrivateDM(msg Message) {
|
||||
// Save to SQLite for history
|
||||
// Persist private messages for history.
|
||||
db.Create(&msg)
|
||||
|
||||
// Broadcast only to participants (simplified - in production use a map of user connections)
|
||||
@@ -1122,6 +1424,7 @@ func main() {
|
||||
http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers))
|
||||
http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule))
|
||||
http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia))
|
||||
http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID))
|
||||
http.HandleFunc("/metrics", handleMetrics)
|
||||
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
Reference in new issue
Block a user