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