Files
linearvoid/backend/chat/src/main.go
T
zwitschi 6b494a34fe
CD / deploy (push) Failing after 27s
CI / test (push) Successful in 1m54s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s
feat: add admin login page and authentication API
- 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.
2026-08-03 17:19:05 +02:00

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