Add complete playout service, chat backend, frontend with HLS player and live chat, Docker integration with health checks, updated CHANGELOG and architecture docs

This commit is contained in:
zwitschi committed 2026-08-02 16:43:12 +02:00
1 parent 0785260689
commit 15a2aaa1a1
23 files changed
+2372 -37

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
module chat
go 1.23
require (
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
)
+235
View File
@@ -0,0 +1,235 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
"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{}
)
// 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"`
}
// initRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007)
func initRedis() {
redisClient = redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
Password: "",
DB: 0,
})
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
db, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
if err != nil {
log.Fatal("Failed to connect to SQLite:", err)
}
db.AutoMigrate(&Message{}, &ModerationLog{})
log.Println("SQLite database initialized (chat history and moderation log)")
}
// 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 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 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("/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, SQLite persistence enabled)", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}