feat: add admin login page and authentication API
CD / deploy (push) Failing after 27s
CI / test (push) Successful in 1m54s
CI / docker (push) Canceled after 0s
CI / release (push) Canceled after 0s

- 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.
This commit is contained in:
zwitschi committed 2026-08-03 17:19:05 +02:00
1 parent 1ae0ad121b
commit 6b494a34fe
15 files changed
+1896 -52

No files matched your search

+870
View File
@@ -2,10 +2,17 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
@@ -26,6 +33,8 @@ var (
}
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)
@@ -55,6 +64,112 @@ type Presence struct {
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")
@@ -110,10 +225,752 @@ func initDB() {
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()
@@ -253,6 +1110,19 @@ func main() {
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)