refactor: modularize chat backend, update docs for .m4s segments
This commit is contained in:
1 parent
46e878bef6
commit
b3a10ba3ef
24 files changed
+3771
-2781
No files matched your search
@@ -0,0 +1,140 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionStore manages admin authentication sessions.
|
||||
type SessionStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time
|
||||
}
|
||||
|
||||
// NewSessionStore creates a new session store.
|
||||
func NewSessionStore() *SessionStore {
|
||||
return &SessionStore{
|
||||
sessions: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateToken creates a cryptographically random 64-char hex session token.
|
||||
func GenerateToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
// Validate checks if a session token is valid and not expired.
|
||||
// Returns true if valid, false otherwise. Expired sessions are cleaned up.
|
||||
func (s *SessionStore) Validate(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
expiresAt, ok := s.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Add stores a session token with expiry.
|
||||
func (s *SessionStore) Add(token string, expiresAt time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.sessions[token] = expiresAt
|
||||
}
|
||||
|
||||
// Remove deletes a session token.
|
||||
func (s *SessionStore) Remove(token string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
|
||||
// CountActive returns the number of unexpired sessions.
|
||||
func (s *SessionStore) CountActive() int {
|
||||
now := time.Now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for token, expiresAt := range s.sessions {
|
||||
if now.After(expiresAt) {
|
||||
delete(s.sessions, token)
|
||||
}
|
||||
}
|
||||
return len(s.sessions)
|
||||
}
|
||||
|
||||
// CookieSecure determines if the session cookie should have Secure flag.
|
||||
func CookieSecure(r *http.Request) bool {
|
||||
if forwardedProto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||
return strings.EqualFold(forwardedProto, "https")
|
||||
}
|
||||
return r.TLS != nil
|
||||
}
|
||||
|
||||
// SetCookie writes the admin session cookie.
|
||||
func SetCookie(w http.ResponseWriter, r *http.Request, cookieName, token string, expiresAt time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
// ClearCookie removes the admin session cookie.
|
||||
func ClearCookie(w http.ResponseWriter, r *http.Request, cookieName string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
Secure: CookieSecure(r),
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTokenFromCookie extracts the session token from the request cookie.
|
||||
func GetTokenFromCookie(r *http.Request, cookieName string) (string, bool) {
|
||||
cookie, err := r.Cookie(cookieName)
|
||||
if err != nil || cookie.Value == "" {
|
||||
return "", false
|
||||
}
|
||||
return cookie.Value, true
|
||||
}
|
||||
|
||||
// RequireAdmin returns middleware that checks for a valid admin session.
|
||||
func RequireAdmin(store *SessionStore, cookieName string) func(http.HandlerFunc) http.HandlerFunc {
|
||||
return func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token, ok := GetTokenFromCookie(r, cookieName)
|
||||
if !ok || !store.Validate(token) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults and constants
|
||||
const (
|
||||
AdminSessionCookieName = "linearvoid_admin_session"
|
||||
MaxAdminUploadBytes int64 = 2 * 1024 * 1024 * 1024
|
||||
DefaultAdminChunkSize int64 = 8 * 1024 * 1024
|
||||
MaxAdminChunkSize int64 = 32 * 1024 * 1024
|
||||
DefaultRateLimitMax = 10
|
||||
DefaultRateLimitWindow = 10 * time.Second
|
||||
DefaultAdminSessionTTL = 12 * time.Hour
|
||||
DefaultChatPort = "3000"
|
||||
)
|
||||
|
||||
// PlaylistPath returns the playlist file path from env or default.
|
||||
func PlaylistPath() string {
|
||||
p := strings.TrimSpace(os.Getenv("PLAYLIST_PATH"))
|
||||
if p == "" {
|
||||
return "/app/playlist/playlist.txt"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// MediaDir returns the media directory from env or default.
|
||||
func MediaDir() string {
|
||||
d := strings.TrimSpace(os.Getenv("MEDIA_DIR"))
|
||||
if d == "" {
|
||||
return "/app/media"
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// AdminUsername returns the admin username from env or default.
|
||||
func AdminUsername() string {
|
||||
u := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
|
||||
if u == "" {
|
||||
return "admin"
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// AdminPassword returns the admin password from env or default.
|
||||
func AdminPassword() string {
|
||||
p := strings.TrimSpace(os.Getenv("ADMIN_PASSWORD"))
|
||||
if p == "" {
|
||||
return "admin"
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// AdminSessionTTL returns the admin session TTL from env or default.
|
||||
func AdminSessionTTL() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("ADMIN_SESSION_TTL_HOURS"))
|
||||
if raw == "" {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
hours, err := strconv.Atoi(raw)
|
||||
if err != nil || hours <= 0 {
|
||||
return DefaultAdminSessionTTL
|
||||
}
|
||||
return time.Duration(hours) * time.Hour
|
||||
}
|
||||
|
||||
// RateLimitMax returns the chat rate limit max messages per window.
|
||||
func RateLimitMax() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_MAX"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// RateLimitWindow returns the chat rate limit window duration.
|
||||
func RateLimitWindow() time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv("CHAT_RATE_LIMIT_WINDOW_S"))
|
||||
if raw == "" {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v <= 0 {
|
||||
return DefaultRateLimitWindow
|
||||
}
|
||||
return time.Duration(v) * time.Second
|
||||
}
|
||||
|
||||
// ChatPort returns the chat service port from env or default.
|
||||
func ChatPort() string {
|
||||
p := strings.TrimSpace(os.Getenv("CHAT_PORT"))
|
||||
if p == "" {
|
||||
return DefaultChatPort
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// RedisAddress returns the Redis address from env or default.
|
||||
func RedisAddress() string {
|
||||
a := strings.TrimSpace(os.Getenv("REDIS_URL"))
|
||||
if a == "" {
|
||||
return "redis:6379"
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// DatabaseURL returns the database URL from env (empty = SQLite default).
|
||||
func DatabaseURL() string {
|
||||
return strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBClient wraps gorm.DB for testability.
|
||||
type DBClient struct {
|
||||
*gorm.DB
|
||||
}
|
||||
|
||||
// InitDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006).
|
||||
// Returns the client and the driver name.
|
||||
func InitDB(databaseURL string) (*DBClient, string, error) {
|
||||
var (
|
||||
gormDB *gorm.DB
|
||||
err error
|
||||
driver = "sqlite"
|
||||
)
|
||||
|
||||
switch {
|
||||
case databaseURL == "":
|
||||
gormDB, err = gorm.Open(sqlite.Open("chat.db"), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "sqlite://"):
|
||||
sqlitePath := strings.TrimPrefix(databaseURL, "sqlite://")
|
||||
if sqlitePath == "" {
|
||||
sqlitePath = "chat.db"
|
||||
}
|
||||
gormDB, err = gorm.Open(sqlite.Open(sqlitePath), &gorm.Config{})
|
||||
case strings.HasPrefix(databaseURL, "postgres://") || strings.HasPrefix(databaseURL, "postgresql://"):
|
||||
driver = "postgres"
|
||||
gormDB, err = gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
|
||||
default:
|
||||
log.Fatalf("Unsupported DATABASE_URL scheme: %s", databaseURL)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, driver, err
|
||||
}
|
||||
|
||||
gormDB.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}, &ScheduleOverride{})
|
||||
log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driver))
|
||||
|
||||
return &DBClient{DB: gormDB}, driver, nil
|
||||
}
|
||||
|
||||
// RedisClient wraps the Redis client for testability.
|
||||
// Interface defined for mocking in tests.
|
||||
type RedisClient interface {
|
||||
Ping(ctx context.Context) error
|
||||
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error
|
||||
Get(ctx context.Context, key string) (string, error)
|
||||
Del(ctx context.Context, keys ...string) error
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Exists(ctx context.Context, keys ...string) (int64, error)
|
||||
Publish(ctx context.Context, channel string, message interface{}) error
|
||||
PSubscribe(ctx context.Context, channels ...string) PubSub
|
||||
Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error)
|
||||
Info(ctx context.Context, sections ...string) (string, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSub wraps Redis pub/sub subscription.
|
||||
type PubSub interface {
|
||||
ReceiveMessage(ctx context.Context) (*PubSubMessage, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// PubSubMessage represents a received pub/sub message.
|
||||
type PubSubMessage struct {
|
||||
Channel string
|
||||
Payload string
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Message represents a chat message (persisted in SQL DB per ADR-006)
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Room string `json:"room"`
|
||||
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"`
|
||||
}
|
||||
|
||||
// Media stores admin media library metadata (filesystem + DB model per ADR-006)
|
||||
type Media struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Filename string `gorm:"index" json:"filename"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Status string `gorm:"index" json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
|
||||
}
|
||||
|
||||
// ScheduleOverride represents a scheduled playlist override (ADR-004)
|
||||
type ScheduleOverride struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
MediaID uint `json:"mediaId"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime time.Time `json:"endTime"`
|
||||
Reason string `json:"reason"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// RedisWrapper wraps go-redis client to implement RedisClient interface.
|
||||
type RedisWrapper struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
// NewRedisWrapper creates a RedisWrapper from a go-redis client.
|
||||
func NewRedisWrapper(client *redis.Client) *RedisWrapper {
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// InitRedis initializes Redis for presence, rate limiting, and pub/sub (ADR-007).
|
||||
// Returns nil if Redis is unavailable (non-fatal).
|
||||
func InitRedis(redisAddress string) *RedisWrapper {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
options = &redis.Options{Addr: redisAddress}
|
||||
}
|
||||
|
||||
client := redis.NewClient(options)
|
||||
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis connection warning: %v (rate limiting and presence will be disabled)", err)
|
||||
client.Close()
|
||||
// Start auto-reconnect goroutine
|
||||
go redisReconnectLoop(redisAddress, options)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Redis connected successfully (presence, rate limiting, pub/sub enabled)")
|
||||
return &RedisWrapper{client: client}
|
||||
}
|
||||
|
||||
// redisReconnectLoop attempts to reconnect to Redis with exponential backoff.
|
||||
func redisReconnectLoop(addr string, baseOptions *redis.Options) {
|
||||
backoff := 2 * time.Second
|
||||
maxBackoff := 60 * time.Second
|
||||
for {
|
||||
time.Sleep(backoff)
|
||||
testClient := redis.NewClient(baseOptions)
|
||||
if err := testClient.Ping(context.Background()).Err(); err != nil {
|
||||
log.Printf("Redis reconnect failed: %v (retry in %v)", err, backoff)
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
testClient.Close()
|
||||
continue
|
||||
}
|
||||
// Reconnected — caller must replace their reference.
|
||||
// This is a best-effort background reconnect; the wrapper
|
||||
// returned by InitRedis remains nil until a new InitRedis call.
|
||||
log.Println("Redis reconnected successfully (background)")
|
||||
testClient.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ping checks Redis connectivity.
|
||||
func (r *RedisWrapper) Ping(ctx context.Context) error {
|
||||
if r == nil || r.client == nil {
|
||||
return redis.ErrClosed
|
||||
}
|
||||
return r.client.Ping(ctx).Err()
|
||||
}
|
||||
|
||||
// Set stores a key with TTL.
|
||||
func (r *RedisWrapper) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) error {
|
||||
return r.client.Set(ctx, key, value, expiration).Err()
|
||||
}
|
||||
|
||||
// Get retrieves a key.
|
||||
func (r *RedisWrapper) Get(ctx context.Context, key string) (string, error) {
|
||||
return r.client.Get(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Del removes keys.
|
||||
func (r *RedisWrapper) Del(ctx context.Context, keys ...string) error {
|
||||
return r.client.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// Incr increments a key.
|
||||
func (r *RedisWrapper) Incr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Incr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Decr decrements a key.
|
||||
func (r *RedisWrapper) Decr(ctx context.Context, key string) (int64, error) {
|
||||
return r.client.Decr(ctx, key).Result()
|
||||
}
|
||||
|
||||
// Expire sets TTL on a key.
|
||||
func (r *RedisWrapper) Expire(ctx context.Context, key string, expiration time.Duration) error {
|
||||
return r.client.Expire(ctx, key, expiration).Err()
|
||||
}
|
||||
|
||||
// Exists checks if keys exist.
|
||||
func (r *RedisWrapper) Exists(ctx context.Context, keys ...string) (int64, error) {
|
||||
return r.client.Exists(ctx, keys...).Result()
|
||||
}
|
||||
|
||||
// Publish publishes a message to a channel.
|
||||
func (r *RedisWrapper) Publish(ctx context.Context, channel string, message interface{}) error {
|
||||
return r.client.Publish(ctx, channel, message).Err()
|
||||
}
|
||||
|
||||
// PSubscribe subscribes to channels by pattern.
|
||||
func (r *RedisWrapper) PSubscribe(ctx context.Context, channels ...string) PubSub {
|
||||
ps := r.client.PSubscribe(ctx, channels...)
|
||||
return &redisPubSubWrapper{ps: ps}
|
||||
}
|
||||
|
||||
// Scan scans keys matching a pattern.
|
||||
func (r *RedisWrapper) Scan(ctx context.Context, cursor uint64, match string, count int64) ([]string, uint64, error) {
|
||||
return r.client.Scan(ctx, cursor, match, count).Result()
|
||||
}
|
||||
|
||||
// Info returns Redis server info.
|
||||
func (r *RedisWrapper) Info(ctx context.Context, sections ...string) (string, error) {
|
||||
return r.client.Info(ctx, sections...).Result()
|
||||
}
|
||||
|
||||
// Close closes the Redis connection.
|
||||
func (r *RedisWrapper) Close() error {
|
||||
if r == nil || r.client == nil {
|
||||
return nil
|
||||
}
|
||||
return r.client.Close()
|
||||
}
|
||||
|
||||
// redisPubSubWrapper wraps go-redis PubSub.
|
||||
type redisPubSubWrapper struct {
|
||||
ps *redis.PubSub
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) ReceiveMessage(ctx context.Context) (*PubSubMessage, error) {
|
||||
msg, err := w.ps.ReceiveMessage(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PubSubMessage{Channel: msg.Channel, Payload: msg.Payload}, nil
|
||||
}
|
||||
|
||||
func (w *redisPubSubWrapper) Close() error {
|
||||
return w.ps.Close()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+71
-2728
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,385 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// FormatDuration converts seconds to HH:MM:SS string.
|
||||
func FormatDuration(seconds int) string {
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
hours := seconds / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
remainingSeconds := seconds % 60
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds)
|
||||
}
|
||||
|
||||
// NormalizationResult holds ffprobe validation output for admin uploads.
|
||||
type NormalizationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// ValidateNormalization runs ffprobe against a media file and checks
|
||||
// conformance with the Linear Void normalization profile (ADR-001).
|
||||
func ValidateNormalization(mediaPath string) NormalizationResult {
|
||||
result := NormalizationResult{Valid: true, Errors: []string{}}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(ctx,
|
||||
"ffprobe", "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe timed out"}}
|
||||
}
|
||||
if err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{fmt.Sprintf("ffprobe failed: %v (%s)", err, strings.TrimSpace(string(out)))}}
|
||||
}
|
||||
|
||||
var probe struct {
|
||||
Streams []struct {
|
||||
CodecName string `json:"codec_name"`
|
||||
CodecType string `json:"codec_type"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
RFrameRate string `json:"r_frame_rate"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
SampleRate string `json:"sample_rate"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &probe); err != nil {
|
||||
return NormalizationResult{Valid: false, Errors: []string{"ffprobe JSON parse failed: " + err.Error()}}
|
||||
}
|
||||
|
||||
var hasVideo, hasAudio bool
|
||||
for _, s := range probe.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
hasVideo = true
|
||||
if s.CodecName != "h264" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("video codec must be h264, got %s", s.CodecName))
|
||||
}
|
||||
if s.RFrameRate != "30/1" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("framerate must be 30 fps constant, got %s", s.RFrameRate))
|
||||
}
|
||||
if s.Width != 1280 && s.Width != 1920 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("width must be 1280 or 1920, got %d", s.Width))
|
||||
}
|
||||
if s.Height != 720 && s.Height != 1080 {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("height must be 720 or 1080, got %d", s.Height))
|
||||
}
|
||||
if s.PixFmt != "yuv420p" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("pixel format must be yuv420p, got %s", s.PixFmt))
|
||||
}
|
||||
case "audio":
|
||||
hasAudio = true
|
||||
if s.CodecName != "aac" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("audio codec must be aac, got %s", s.CodecName))
|
||||
}
|
||||
if s.SampleRate != "48000" {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("sample rate must be 48000 Hz, got %s", s.SampleRate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasVideo {
|
||||
result.Errors = append(result.Errors, "no video stream found")
|
||||
}
|
||||
if !hasAudio {
|
||||
result.Errors = append(result.Errors, "no audio stream found")
|
||||
}
|
||||
|
||||
if len(result.Errors) > 0 {
|
||||
result.Valid = false
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ProbeDurationSeconds returns the duration of a media file in seconds.
|
||||
func ProbeDurationSeconds(mediaPath string) (int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
out, err := exec.CommandContext(
|
||||
ctx,
|
||||
"ffprobe",
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
mediaPath,
|
||||
).CombinedOutput()
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 0, fmt.Errorf("ffprobe timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
rawDuration := strings.TrimSpace(string(out))
|
||||
if rawDuration == "" {
|
||||
return 0, fmt.Errorf("ffprobe returned empty duration")
|
||||
}
|
||||
|
||||
secondsFloat, err := strconv.ParseFloat(rawDuration, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err)
|
||||
}
|
||||
if secondsFloat < 0 {
|
||||
secondsFloat = 0
|
||||
}
|
||||
|
||||
seconds := int(secondsFloat + 0.5)
|
||||
if secondsFloat > 0 && seconds == 0 {
|
||||
seconds = 1
|
||||
}
|
||||
|
||||
return seconds, nil
|
||||
}
|
||||
|
||||
// DetectMime reads the first 512 bytes of a file to detect its MIME type.
|
||||
func DetectMime(filePath string) (string, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := f.Read(head)
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.SplitN(http.DetectContentType(head[:n]), ";", 2)[0], nil
|
||||
}
|
||||
|
||||
// IsAllowedExtension checks if the file extension is a supported video format.
|
||||
func IsAllowedExtension(fileName string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(fileName))
|
||||
switch ext {
|
||||
case ".mp4", ".mov", ".mkv", ".webm", ".m4v", ".ts":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// IsAllowedMime checks if the MIME type is a supported video type.
|
||||
func IsAllowedMime(mime string) bool {
|
||||
mime = strings.TrimSpace(strings.ToLower(mime))
|
||||
if mime == "" {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(mime, "video/") {
|
||||
return true
|
||||
}
|
||||
return mime == "application/octet-stream"
|
||||
}
|
||||
|
||||
// UniqueTargetPath returns a unique file path in mediaDir, appending -N if needed.
|
||||
func UniqueTargetPath(mediaDir, fileName string) string {
|
||||
baseName := filepath.Base(fileName)
|
||||
ext := filepath.Ext(baseName)
|
||||
nameOnly := strings.TrimSuffix(baseName, ext)
|
||||
candidate := filepath.Join(mediaDir, baseName)
|
||||
|
||||
for i := 1; ; i++ {
|
||||
if _, err := os.Stat(candidate); os.IsNotExist(err) {
|
||||
return candidate
|
||||
}
|
||||
candidate = filepath.Join(mediaDir, fmt.Sprintf("%s-%d%s", nameOnly, i, ext))
|
||||
}
|
||||
}
|
||||
|
||||
// FileEntry represents a media file for API responses.
|
||||
type FileEntry struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"originalName"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
UploadedAt string `json:"uploadedAt"`
|
||||
ModifiedAt string `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
// MediaRecord is the interface for media DB records.
|
||||
type MediaRecord interface {
|
||||
GetID() uint
|
||||
GetFilename() string
|
||||
GetOriginalName() string
|
||||
GetPath() string
|
||||
GetSizeBytes() int64
|
||||
GetDurationSeconds() int
|
||||
GetStatus() string
|
||||
GetTitle() string
|
||||
GetTags() string
|
||||
GetUploadedAt() time.Time
|
||||
GetUpdatedAt() time.Time
|
||||
}
|
||||
|
||||
// ListFiles returns all media files sorted by upload date descending.
|
||||
func ListFiles(db *gorm.DB) ([]FileEntry, error) {
|
||||
type record struct {
|
||||
ID uint
|
||||
Filename string
|
||||
OriginalName string
|
||||
Path string
|
||||
SizeBytes int64
|
||||
DurationSeconds int
|
||||
Status string
|
||||
Title string
|
||||
Tags string
|
||||
UploadedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
var records []record
|
||||
if err := db.Table("media").Order("uploaded_at desc").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := make([]FileEntry, 0, len(records))
|
||||
for _, r := range records {
|
||||
files = append(files, FileEntry{
|
||||
ID: r.ID,
|
||||
Name: r.Filename,
|
||||
OriginalName: r.OriginalName,
|
||||
Path: r.Path,
|
||||
Size: r.SizeBytes,
|
||||
DurationSeconds: r.DurationSeconds,
|
||||
Runtime: FormatDuration(r.DurationSeconds),
|
||||
Status: r.Status,
|
||||
Title: r.Title,
|
||||
Tags: r.Tags,
|
||||
UploadedAt: r.UploadedAt.UTC().Format(time.RFC3339),
|
||||
ModifiedAt: r.UpdatedAt.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].UploadedAt == files[j].UploadedAt {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].UploadedAt > files[j].UploadedAt
|
||||
})
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// ParsePlaylistEntries parses playlist content into schedule entries.
|
||||
type ScheduleEntry struct {
|
||||
Index int `json:"index"`
|
||||
Path string `json:"path"`
|
||||
Label string `json:"label"`
|
||||
DurationSeconds int `json:"durationSeconds"`
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// ParsePlaylist parses playlist text content into entries.
|
||||
func ParsePlaylist(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
|
||||
}
|
||||
|
||||
// UploadSessionDir returns the directory for an upload session's chunks.
|
||||
func UploadSessionDir(mediaDir, uploadID string) string {
|
||||
return filepath.Join(mediaDir, ".uploads", uploadID)
|
||||
}
|
||||
|
||||
// UploadSessionMetaPath returns the path to the session metadata file.
|
||||
func UploadSessionMetaPath(mediaDir, uploadID string) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), "session.json")
|
||||
}
|
||||
|
||||
// UploadChunkPath returns the path for a specific chunk file.
|
||||
func UploadChunkPath(mediaDir, uploadID string, chunkIndex int) string {
|
||||
return filepath.Join(UploadSessionDir(mediaDir, uploadID), fmt.Sprintf("chunk-%06d.part", chunkIndex))
|
||||
}
|
||||
|
||||
// CountUploadedChunks counts how many chunks exist for a session.
|
||||
func CountUploadedChunks(mediaDir, uploadID string, totalChunks int) int {
|
||||
uploaded := 0
|
||||
for i := 0; i < totalChunks; i++ {
|
||||
if _, err := os.Stat(UploadChunkPath(mediaDir, uploadID, i)); err == nil {
|
||||
uploaded++
|
||||
}
|
||||
}
|
||||
return uploaded
|
||||
}
|
||||
|
||||
// ValidateUploadID checks that an upload ID is a valid hex string of appropriate length.
|
||||
func ValidateUploadID(uploadID string) bool {
|
||||
if len(uploadID) < 16 || len(uploadID) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range uploadID {
|
||||
if (r < 'a' || r > 'f') && (r < '0' || r > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// GetMediaIDFromPath extracts a media ID from a URL path like /api/admin/media/123.
|
||||
func GetMediaIDFromPath(path, prefix string) (uint, error) {
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
return 0, fmt.Errorf("invalid media path")
|
||||
}
|
||||
idPart := strings.TrimPrefix(path, prefix)
|
||||
idPart = strings.TrimSpace(idPart)
|
||||
if idPart == "" || strings.Contains(idPart, "/") {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
id, err := strconv.ParseUint(idPart, 10, 64)
|
||||
if err != nil || id == 0 {
|
||||
return 0, fmt.Errorf("invalid media id")
|
||||
}
|
||||
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/auth"
|
||||
"chat/src/db"
|
||||
"chat/src/ws"
|
||||
)
|
||||
|
||||
// HandleMetrics returns Prometheus-format metrics for the chat service.
|
||||
func HandleMetrics(gormDB *gorm.DB, redis db.RedisClient, hub *ws.Hub, sessionStore *auth.SessionStore, dbDriver, instanceID string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var storedMessages int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.Message{}).Count(&storedMessages).Error
|
||||
}
|
||||
|
||||
var moderationActions int64
|
||||
if gormDB != nil {
|
||||
_ = gormDB.Model(&db.ModerationLog{}).Count(&moderationActions).Error
|
||||
}
|
||||
|
||||
redisEnabled := 0
|
||||
redisUp := 0
|
||||
redisClients := 0
|
||||
if redis != nil {
|
||||
redisEnabled = 1
|
||||
if err := redis.Ping(context.Background()); err == nil {
|
||||
redisUp = 1
|
||||
if info, err := redis.Info(context.Background(), "clients"); 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedClients := 0
|
||||
dmsSent := int64(0)
|
||||
if hub != nil {
|
||||
connectedClients = hub.ConnectedClients()
|
||||
dmsSent = hub.DMsSent
|
||||
}
|
||||
|
||||
activeSessions := 0
|
||||
if sessionStore != nil {
|
||||
activeSessions = sessionStore.CountActive()
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
|
||||
writeLine := func(name string, value any) {
|
||||
fmt.Fprintf(w, "%s %v\n", name, value)
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_connected_clients Current connected WebSocket clients.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_connected_clients gauge")
|
||||
writeLine("linearvoid_chat_connected_clients", connectedClients)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_messages_total Total stored chat messages.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_messages_total gauge")
|
||||
writeLine("linearvoid_chat_messages_total", storedMessages)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_dms_total Total private messages sent.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_dms_total counter")
|
||||
writeLine("linearvoid_chat_dms_total", dmsSent)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_moderation_actions_total Total moderation log entries.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_moderation_actions_total gauge")
|
||||
writeLine("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")
|
||||
writeLine("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")
|
||||
writeLine("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")
|
||||
writeLine("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")
|
||||
writeLine("linearvoid_chat_admin_sessions_active", activeSessions)
|
||||
|
||||
fmt.Fprintln(w, "# HELP linearvoid_chat_build_info Static build info for chat service.")
|
||||
fmt.Fprintln(w, "# TYPE linearvoid_chat_build_info gauge")
|
||||
fmt.Fprintf(w, "linearvoid_chat_build_info{service=\"chat\",database_driver=\"%s\",instance_id=\"%s\"} 1\n", dbDriver, instanceID)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleHealth returns a simple health check endpoint.
|
||||
func HandleHealth() http.HandlerFunc {
|
||||
return 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)+`"}`)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleNotFound returns a 404 JSON response for all unhandled paths.
|
||||
func HandleNotFound() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprint(w, `{"error":"not found"}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package ratelimit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RedisClient is the subset of Redis operations needed for rate limiting.
|
||||
type RedisClient interface {
|
||||
Incr(ctx context.Context, key string) (int64, error)
|
||||
Expire(ctx context.Context, key string, expiration time.Duration) error
|
||||
Decr(ctx context.Context, key string) (int64, error)
|
||||
}
|
||||
|
||||
// CheckMessageRate checks if a user has exceeded the message rate limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckMessageRate(ctx context.Context, redis RedisClient, username string, max int, window time.Duration) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil // No Redis = no rate limiting
|
||||
}
|
||||
key := fmt.Sprintf("rate:%s", username)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err // Fail open on Redis error
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, window)
|
||||
}
|
||||
return count <= int64(max), nil
|
||||
}
|
||||
|
||||
// CheckLoginRate checks if an IP has exceeded the login attempt limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
func CheckLoginRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("login_attempts:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 5*time.Minute)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// CheckWSConnRate checks if an IP has exceeded the WebSocket connection limit.
|
||||
// Returns true if allowed, false if rate limited.
|
||||
// Caller must call Decr on disconnect.
|
||||
func CheckWSConnRate(ctx context.Context, redis RedisClient, clientIP string) (bool, error) {
|
||||
if redis == nil {
|
||||
return true, nil
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
count, err := redis.Incr(ctx, key)
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
if count == 1 {
|
||||
_ = redis.Expire(ctx, key, 60*time.Second)
|
||||
}
|
||||
return count <= 5, nil
|
||||
}
|
||||
|
||||
// DecrWSConn decrements the WebSocket connection counter for an IP.
|
||||
func DecrWSConn(ctx context.Context, redis RedisClient, clientIP string) {
|
||||
if redis == nil {
|
||||
return
|
||||
}
|
||||
key := fmt.Sprintf("ws_conn:%s", clientIP)
|
||||
_, _ = redis.Decr(ctx, key)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"chat/src/db"
|
||||
"chat/src/ratelimit"
|
||||
)
|
||||
|
||||
// Upgrader is the WebSocket upgrader configuration.
|
||||
var Upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// ClientInfo stores metadata about a connected WebSocket client.
|
||||
type ClientInfo struct {
|
||||
Username string
|
||||
Room string
|
||||
}
|
||||
|
||||
// Hub manages all WebSocket connections and message routing.
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
clients map[*websocket.Conn]bool
|
||||
clientMeta map[*websocket.Conn]ClientInfo
|
||||
DB *gorm.DB
|
||||
Redis db.RedisClient
|
||||
InstanceID string
|
||||
RateLimitMax int
|
||||
RateLimitWindow time.Duration
|
||||
AdminUsername string
|
||||
DMsSent int64
|
||||
}
|
||||
|
||||
// NewHub creates a new WebSocket hub.
|
||||
func NewHub(gormDB *gorm.DB, redis db.RedisClient, instanceID string, rateMax int, rateWindow time.Duration, adminUser string) *Hub {
|
||||
return &Hub{
|
||||
clients: make(map[*websocket.Conn]bool),
|
||||
clientMeta: make(map[*websocket.Conn]ClientInfo),
|
||||
DB: gormDB,
|
||||
Redis: redis,
|
||||
InstanceID: instanceID,
|
||||
RateLimitMax: rateMax,
|
||||
RateLimitWindow: rateWindow,
|
||||
AdminUsername: adminUser,
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectedClients returns the number of currently connected clients.
|
||||
func (h *Hub) ConnectedClients() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return len(h.clients)
|
||||
}
|
||||
|
||||
// Broadcast sends a message to all connected clients and persists to DB.
|
||||
func (h *Hub) Broadcast(msg db.Message) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
// Persist to configured SQL backend.
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Local fan-out
|
||||
for client := range h.clients {
|
||||
if err := client.WriteJSON(msg); err != nil {
|
||||
client.Close()
|
||||
delete(h.clients, client)
|
||||
delete(h.clientMeta, client)
|
||||
}
|
||||
}
|
||||
|
||||
// Redis pub/sub for multi-instance fan-out
|
||||
if h.Redis != nil && msg.Room != "" {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"instance": h.InstanceID,
|
||||
"msg": msg,
|
||||
})
|
||||
_ = h.Redis.Publish(context.Background(), fmt.Sprintf("room:%s:messages", msg.Room), payload)
|
||||
}
|
||||
}
|
||||
|
||||
// PushChatHistory sends the last N messages for a room via WebSocket.
|
||||
func (h *Hub) PushChatHistory(conn *websocket.Conn, room string, limit int) {
|
||||
if h.DB == nil || room == "" {
|
||||
return
|
||||
}
|
||||
var messages []db.Message
|
||||
h.DB.Where("room = ?", room).Order("timestamp desc").Limit(limit).Find(&messages)
|
||||
// Reverse to chronological order
|
||||
for i := len(messages)/2 - 1; i >= 0; i-- {
|
||||
opp := len(messages) - 1 - i
|
||||
messages[i], messages[opp] = messages[opp], messages[i]
|
||||
}
|
||||
for _, msg := range messages {
|
||||
conn.WriteJSON(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// HandleConnection handles a new WebSocket connection.
|
||||
func (h *Hub) HandleConnection(w http.ResponseWriter, r *http.Request) {
|
||||
clientIP := r.Header.Get("X-Forwarded-For")
|
||||
if clientIP == "" {
|
||||
clientIP = r.RemoteAddr
|
||||
}
|
||||
|
||||
// IP-based connection rate limiting
|
||||
allowed, err := ratelimit.CheckWSConnRate(context.Background(), h.Redis, clientIP)
|
||||
if err != nil {
|
||||
log.Printf("WS rate limit check error: %v", err)
|
||||
}
|
||||
if !allowed {
|
||||
http.Error(w, "too many connections from this IP", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
defer ratelimit.DecrWSConn(context.Background(), h.Redis, clientIP)
|
||||
|
||||
conn, err := Upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("Upgrade error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
room := r.URL.Query().Get("room")
|
||||
if username == "" {
|
||||
username = fmt.Sprintf("anon_%d", time.Now().UnixNano()%10000)
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
h.clients[conn] = true
|
||||
h.clientMeta[conn] = ClientInfo{Username: username, Room: room}
|
||||
h.mu.Unlock()
|
||||
|
||||
// Announce presence (ADR-007)
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " joined " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
|
||||
// Push chat history on join (last 50 messages)
|
||||
h.PushChatHistory(conn, room, 50)
|
||||
|
||||
// Heartbeat goroutine: refresh presence TTL every 30s
|
||||
heartbeatStop := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("presence:%s:%s", room, username), "online", 60*time.Second)
|
||||
}
|
||||
// Ping to detect dead connections
|
||||
if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
case <-heartbeatStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
defer func() {
|
||||
close(heartbeatStop)
|
||||
h.mu.Lock()
|
||||
delete(h.clients, conn)
|
||||
delete(h.clientMeta, conn)
|
||||
h.mu.Unlock()
|
||||
if h.Redis != nil && room != "" {
|
||||
_ = h.Redis.Del(context.Background(), fmt.Sprintf("presence:%s:%s", room, username))
|
||||
h.Broadcast(db.Message{Room: room, Username: "system", Text: username + " left " + room, Type: "system", Timestamp: time.Now()})
|
||||
}
|
||||
conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
var msg db.Message
|
||||
if err := conn.ReadJSON(&msg); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
msg.Timestamp = time.Now()
|
||||
msg.Username = username // enforce from query param for security
|
||||
|
||||
// Check if banned before processing
|
||||
if h.Redis != nil {
|
||||
if isBanned, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("banned:%s", username)); isBanned > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are banned from chat.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
if isMuted, _ := h.Redis.Exists(context.Background(), fmt.Sprintf("muted:%s", username)); isMuted > 0 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "You are muted.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting check
|
||||
allowed, _ := ratelimit.CheckMessageRate(context.Background(), h.Redis, msg.Username, h.RateLimitMax, h.RateLimitWindow)
|
||||
if !allowed {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Rate limit exceeded. Please slow down.", Timestamp: time.Now()})
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle moderation commands
|
||||
if strings.HasPrefix(msg.Text, "/mod ") || strings.HasPrefix(msg.Text, "/mute ") || strings.HasPrefix(msg.Text, "/ban ") {
|
||||
h.handleModerationCommand(msg, conn)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle private DMs
|
||||
if strings.HasPrefix(msg.Room, "dm:") {
|
||||
h.handlePrivateDM(msg)
|
||||
continue
|
||||
}
|
||||
|
||||
msg.Type = "public"
|
||||
log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text)
|
||||
h.Broadcast(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// handlePrivateDM routes private messages to the specific user pair.
|
||||
func (h *Hub) handlePrivateDM(msg db.Message) {
|
||||
msg.Type = "private"
|
||||
h.DMsSent++
|
||||
|
||||
// Persist for history
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&msg)
|
||||
}
|
||||
|
||||
// Deliver to target if online
|
||||
h.mu.Lock()
|
||||
targetOnline := false
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Room || strings.Contains(msg.Room, info.Username) {
|
||||
if err := conn.WriteJSON(msg); err == nil {
|
||||
targetOnline = true
|
||||
}
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
if !targetOnline {
|
||||
log.Printf("DM stored for offline user: %s -> room %s", msg.Username, msg.Room)
|
||||
}
|
||||
|
||||
// Also deliver to sender for confirmation
|
||||
h.mu.Lock()
|
||||
for conn, info := range h.clientMeta {
|
||||
if info.Username == msg.Username {
|
||||
conn.WriteJSON(msg)
|
||||
break
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
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.
|
||||
func (h *Hub) logModerationAction(action, target, moderator, reason string) {
|
||||
logEntry := db.ModerationLog{
|
||||
Action: action,
|
||||
Target: target,
|
||||
Moderator: moderator,
|
||||
Reason: reason,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if h.DB != nil {
|
||||
h.DB.Create(&logEntry)
|
||||
}
|
||||
log.Printf("Moderation action logged: %s %s by %s - %s", action, target, moderator, reason)
|
||||
}
|
||||
|
||||
// handleModerationCommand processes /mod, /mute, /ban commands.
|
||||
func (h *Hub) handleModerationCommand(msg db.Message, conn *websocket.Conn) {
|
||||
parts := strings.Fields(msg.Text)
|
||||
if len(parts) < 3 {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Usage: /mute <user> <duration_m> <reason> or /ban <user> <reason>", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Username != h.AdminUsername {
|
||||
conn.WriteJSON(db.Message{Type: "system", Text: "Only moderators can use moderation commands.", Timestamp: time.Now()})
|
||||
return
|
||||
}
|
||||
|
||||
action := strings.TrimPrefix(parts[0], "/")
|
||||
target := parts[1]
|
||||
reason := strings.Join(parts[2:], " ")
|
||||
|
||||
if h.Redis != nil {
|
||||
switch action {
|
||||
case "mute", "mod":
|
||||
durationMinutes := 10 // default
|
||||
if len(parts) >= 3 {
|
||||
if d, err := strconv.Atoi(parts[2]); err == nil && d > 0 {
|
||||
durationMinutes = d
|
||||
if len(parts) > 3 {
|
||||
reason = strings.Join(parts[3:], " ")
|
||||
} else {
|
||||
reason = "no reason given"
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("muted:%s", target), "1", time.Duration(durationMinutes)*time.Minute)
|
||||
case "ban":
|
||||
_ = h.Redis.Set(context.Background(), fmt.Sprintf("banned:%s", target), "1", 0) // no expiry
|
||||
}
|
||||
}
|
||||
|
||||
h.logModerationAction(action, target, msg.Username, reason)
|
||||
|
||||
h.Broadcast(db.Message{
|
||||
Room: msg.Room,
|
||||
Username: "system",
|
||||
Type: "system",
|
||||
Text: fmt.Sprintf("%s was %s by %s: %s", target, action, msg.Username, reason),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
// SubscribeRoomMessages listens for Redis pub/sub messages and fans out locally.
|
||||
func (h *Hub) SubscribeRoomMessages() {
|
||||
if h.Redis == nil {
|
||||
return
|
||||
}
|
||||
pubsub := h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
defer pubsub.Close()
|
||||
|
||||
log.Printf("Redis pub/sub subscriber started (instance %s)", h.InstanceID)
|
||||
|
||||
for {
|
||||
msg, err := pubsub.ReceiveMessage(context.Background())
|
||||
if err != nil {
|
||||
log.Printf("Redis pub/sub error: %v — retrying in 5s", err)
|
||||
time.Sleep(5 * time.Second)
|
||||
pubsub = h.Redis.PSubscribe(context.Background(), "room:*:messages")
|
||||
continue
|
||||
}
|
||||
|
||||
var envelope struct {
|
||||
Instance string `json:"instance"`
|
||||
Msg db.Message `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg.Payload), &envelope); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip messages originating from this instance
|
||||
if envelope.Instance == h.InstanceID {
|
||||
continue
|
||||
}
|
||||
|
||||
// Fan out to local clients
|
||||
h.mu.Lock()
|
||||
for client := range h.clients {
|
||||
client.WriteJSON(envelope.Msg)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ScanPresence returns all active presence entries from Redis.
|
||||
func (h *Hub) ScanPresence(ctx context.Context) ([]db.Presence, error) {
|
||||
if h.Redis == nil {
|
||||
return nil, nil
|
||||
}
|
||||
keys, _, err := h.Redis.Scan(ctx, 0, "presence:*", 200)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var presences []db.Presence
|
||||
for _, key := range keys {
|
||||
parts := strings.SplitN(key, ":", 3)
|
||||
if len(parts) != 3 {
|
||||
continue
|
||||
}
|
||||
presences = append(presences, db.Presence{
|
||||
Room: parts[1],
|
||||
Username: parts[2],
|
||||
Online: true,
|
||||
})
|
||||
}
|
||||
return presences, nil
|
||||
}
|
||||
|
||||
// MuteUser mutes a user for a duration.
|
||||
func (h *Hub) MuteUser(ctx context.Context, username string, duration time.Duration) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("muted:%s", username), "1", duration)
|
||||
}
|
||||
|
||||
// UnmuteUser removes a mute.
|
||||
func (h *Hub) UnmuteUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("muted:%s", username))
|
||||
}
|
||||
|
||||
// BanUser bans a user permanently.
|
||||
func (h *Hub) BanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Set(ctx, fmt.Sprintf("banned:%s", username), "1", 0)
|
||||
}
|
||||
|
||||
// UnbanUser removes a ban.
|
||||
func (h *Hub) UnbanUser(ctx context.Context, username string) error {
|
||||
if h.Redis == nil {
|
||||
return fmt.Errorf("redis unavailable")
|
||||
}
|
||||
return h.Redis.Del(ctx, fmt.Sprintf("banned:%s", username))
|
||||
}
|
||||
Reference in new issue
Block a user