refactor: modularize chat backend, update docs for .m4s segments

This commit is contained in:
zwitschi committed 2026-08-07 13:16:44 +02:00
1 parent 46e878bef6
commit b3a10ba3ef
24 files changed
+3770 -2780

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ out/
# FFmpeg temporary files # FFmpeg temporary files
*.ts.tmp *.ts.tmp
*.m3u8.tmp *.m3u8.tmp
*.m4s.tmp
hls/ hls/
temp/ temp/
+5
View File
@@ -16,10 +16,15 @@
- **CI pipeline**: Removed redundant `release` job (handled by `release.yml`). - **CI pipeline**: Removed redundant `release` job (handled by `release.yml`).
- **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags. - **Tag format**: Standardized on `*.*.*` (e.g. `1.0.9`), removed `v` prefix from existing tags.
- **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table. - **DEPLOYMENT.md**: Documented parallel CI/CD pipeline flow with workflow table.
- **Runtime checks**: Executed normalization edge-case tests (MOV, MKV, VFR, 5.1 input) and concat continuity verification in running playout environment.
- **Incident runbook**: Added quarterly disaster recovery drill procedure with prerequisites, outage simulation, restore validation, and evidence template.
- **Manual ops checklist**: Added `docs/operations/cloudflare-uptimerobot-checklist.md` to execute and record Cloudflare + UptimeRobot dashboard tasks with evidence capture.
- **Scaling playbook**: Added `docs/operations/scaling-performance-playbook.md` with benchmark methods, trigger thresholds, and Phase 2/3 scaling decision flow.
### Fixed ### Fixed
- **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host. - **Prometheus config mount**: Switched prod from bind mounts to named volume `prometheus-config`, populated via `docker run` on remote host. Fixes `not a directory` error when bind source path doesn't exist on deployment host.
- **Viewer mobile overflow**: Replaced `100vw` with `100%` on full-screen viewer containers to remove horizontal overflow on mobile viewports.
### Removed ### Removed
+2 -1
View File
@@ -15,7 +15,7 @@
The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses: The architecture (detailed in [`docs/architecture/README.md`](docs/architecture/README.md)) uses:
- Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_. - Hetzner Ashburn VPS for playout (FFmpeg + MediaMTX/SRS) and chat _(planned production; currently test lab at 192.168.88.40)_.
- Cloudflare R2 + CDN with aggressive immutable caching of HLS `.ts` segments ($0 egress). - Cloudflare R2 + CDN with aggressive immutable caching of `.m4s` segments ($0 egress).
- Lightweight static frontend and WebSocket chat backend. - Lightweight static frontend and WebSocket chat backend.
- Gitea self-hosted CI/CD (Actions) and Container Registry. - Gitea self-hosted CI/CD (Actions) and Container Registry.
@@ -211,6 +211,7 @@ Contributions, issues, and further evolution (LL-HLS, ABR, multi-channel, E2EE)
- [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide. - [DEPLOYMENT.md](DEPLOYMENT.md) - Production deployment and operations guide.
- [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting. - [MONITORING.md](MONITORING.md) - Prometheus and Grafana setup and troubleshooting.
- [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline. - [SECURITY.md](SECURITY.md) - Security controls, hardening checklist, and incident baseline.
- [Scaling Playbook](docs/operations/scaling-performance-playbook.md) - Performance test method, thresholds, and scaling decision flow.
- [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions. - [Architecture Documentation](docs/architecture/README.md) - Full arc42 template with diagrams and decisions.
- [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007). - [ADRs](docs/architecture/adr/) - Detailed decision records (ADR-001 to ADR-007).
+10 -3
View File
@@ -223,7 +223,7 @@ flowchart LR
- Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`. - Use Cloudflare proxied DNS with Full (strict) TLS so browser traffic uses `https://linearvoid.tv`, `wss://linearvoid.tv/ws`, and `https://media.linearvoid.tv`.
6. **Apply cache behavior**: 6. **Apply cache behavior**:
- Configure the `.ts` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`. - Configure the `.m4s` and `.m3u8` cache rules from `infrastructure/cloudflare/cache-rules.md`.
- Enable Smart Tiered Cache for the R2-backed hostname. - Enable Smart Tiered Cache for the R2-backed hostname.
- If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone. - If you need header normalization for immutable segment caching, deploy `infrastructure/cloudflare/worker.js` to the same zone.
@@ -235,6 +235,8 @@ flowchart LR
Use this section for full edge setup (DNS, Pages, cache, security, and monitoring). Use this section for full edge setup (DNS, Pages, cache, security, and monitoring).
Execution tracker: `docs/operations/cloudflare-uptimerobot-checklist.md`.
### 1. DNS Setup ### 1. DNS Setup
1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target. 1. Add apex record for `linearvoid.tv` pointing to Cloudflare Pages target.
@@ -262,7 +264,12 @@ Configure Cloudflare Cache Rules:
- Cache: eligible - Cache: eligible
- Edge TTL: `2s` - Edge TTL: `2s`
- Revalidation: enabled - Revalidation: enabled
3. Validate behavior using response headers: 3. Static assets (`*.js`, `*.css`):
- Cache: eligible
- Edge TTL: `1y`
- Browser TTL: long-lived
- Cache key: include full path
4. Validate behavior using response headers:
- `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects - `Cf-Cache-Status` should move from `MISS` to `HIT` for hot objects
Reference: `infrastructure/cloudflare/cache-rules.md`. Reference: `infrastructure/cloudflare/cache-rules.md`.
@@ -564,7 +571,7 @@ See `docs/architecture/README.md` for the full arc42 documentation and the ADRs
- **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`. - **R2 Upload Fails**: Check credentials in `.env` and ensure the bucket exists. Verify with `aws s3 ls --endpoint-url=$R2_ENDPOINT`.
- **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001). - **Normalization Errors**: Run `backend/playout/start-playout.sh` manually to see ffprobe output. All videos must be H.264/AAC, 30 fps constant, 2s keyframes (ADR-001).
- **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200. - **Chat Not Connecting**: Check Redis connection and WebSocket URL. The health check at `/health` should return 200.
- **Cache Not Working**: Verify Cloudflare cache rules (`.ts` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard. - **Cache Not Working**: Verify Cloudflare cache rules (`.m4s` = long TTL immutable, `.m3u8` = short TTL). Check cache hit ratio in Cloudflare dashboard.
- **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use. - **Docker Issues**: Run `docker compose logs` for service-specific errors. Ensure ports are not in use.
- **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow. - **Admin Interface**: Ensure `ADMIN_USERNAME`, `ADMIN_PASSWORD`, and `ADMIN_SESSION_TTL_HOURS` are set. `/admin/` and `/api/admin/*` are protected by a session-based login flow.
- **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist. - **Production Pull Fails**: Confirm `docker login` to Gitea succeeded on the server and the image tags referenced by `docker-compose.prod.yml` exist.
+140
View File
@@ -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)
}
}
}
+118
View File
@@ -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"))
}
+82
View File
@@ -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
}
+57
View File
@@ -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"`
}
+163
View File
@@ -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
+70 -2727
View File
File diff suppressed because it is too large. Load diff
+385
View File
@@ -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
}
+128
View File
@@ -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"}`)
}
}
+75
View File
@@ -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)
}
+434
View File
@@ -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))
}
+27 -27
View File
@@ -113,14 +113,14 @@ Full decision rationale, alternatives, and consequences in [`adr/ADR-001.md`](ad
### Quality Goals ### Quality Goals
| Priority | Quality Attribute | Goal | Scenario | | Priority | Quality Attribute | Goal | Scenario |
| -------- | ------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------- | | -------- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.ts` edge caching eliminates per-viewer bandwidth cost | | 1 | **Cost Efficiency** | <$25/month for moderate scale (100s concurrent) | R2 $0 egress + immutable `.m4s` and `.ts` edge caching eliminates per-viewer bandwidth cost |
| 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) | | 2 | **Performance / Latency** | Low US latency; acceptable HLS delay | Hetzner Ashburn origin + Cloudflare US POPs (15–35s end-to-end) |
| 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring | | 3 | **Availability** | 24/7 with minimal downtime | FFmpeg/MediaMTX auto-restart loop, fallback slate, monitoring |
| 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits | | 4 | **Scalability** | Grow from 10s to 1000s viewers without proportional cost | Edge caching + R2; only request volume limits |
| 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis | | 5 | **Maintainability** | Lightweight, clear configs, low ops overhead | Static SPA, Go/Node chat, SQLite/PostgreSQL+Redis |
| 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player | | 6 | **Security / Usability** | Secure transport, moderation tools, good UX | HTTPS/WSS, Cloudflare WAF, rate limits, intuitive chat/player |
### Stakeholders ### Stakeholders
@@ -186,7 +186,7 @@ flowchart TD
**External Interfaces**: **External Interfaces**:
- **Cloudflare**: CDN (cache rules for `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS. - **Cloudflare**: CDN (cache rules for `.m4s` and `.ts` vs `.m3u8`), R2 (object storage, $0 egress), Pages (static hosting), WAF/TLS.
- **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering). - **Hetzner VPS**: Compute for playout and chat (low-cost US-East with good peering).
- **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs. - **Browsers**: Native HLS (`<video>` + hls.js) and WebSocket APIs.
- **Source**: Pre-normalized MP4 files uploaded to R2. - **Source**: Pre-normalized MP4 files uploaded to R2.
@@ -196,7 +196,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
### Technical Context ### Technical Context
- **Playout**: FFmpeg consuming normalized playlist → continuous HLS. - **Playout**: FFmpeg consuming normalized playlist → continuous HLS.
- **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.ts` segments to R2; Cloudflare edge cache. - **Packaging/Delivery**: MediaMTX/SRS/Nginx → immutable `.m4s` and `.ts` segments to R2; Cloudflare edge cache.
- **Frontend**: Static SPA with hls.js. - **Frontend**: Static SPA with hls.js.
- **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation). - **Chat**: Lightweight WebSocket service (Node/Go) + Redis (presence/rate limits) + PostgreSQL/SQLite (history/moderation).
- **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes. - **Normalization**: All source videos must be H.264/AAC, 30 fps constant, 2s keyframes.
@@ -212,7 +212,7 @@ This diagram synthesizes the context views from all original ADRs. Full decision
1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible). 1. **US-East Playout on Hetzner Ashburn VPS** (`CX22` or equivalent): FFmpeg + MediaMTX/SRS for continuous playlist-to-HLS conversion (`-c copy` where possible).
2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress. 2. **Immutable HLS to Cloudflare R2**: Segments use unique names; R2 acts as origin with $0 egress.
3. **Aggressive Edge Caching**: Long TTL for `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free. 3. **Aggressive Edge Caching**: Long TTL for `.m4s` and `.ts` (immutable), short TTL (1–10s) for live `.m3u8`. Tiered Cache enabled. This makes viewer delivery essentially free.
4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar. 4. **Lightweight Custom Frontend**: Static HTML/JS SPA + hls.js player + embedded chat sidebar.
5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth). 5. **Separate but co-located Chat Service**: WebSockets with persistence and moderation (low bandwidth).
6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops. 6. **Normalization & Monitoring**: Pre-process all videos; use dashboards + alerts for playout health, cache hit ratio, R2 ops.
@@ -230,7 +230,7 @@ Overview (Mermaid diagram):
```mermaid ```mermaid
graph TD graph TD
subgraph Cloudflare subgraph Cloudflare
CDN[CDN + Cache Rules<br/>.ts immutable long TTL<br/>.m3u8 short TTL] CDN[CDN + Cache Rules<br/>.m4s and .ts immutable long TTL<br/>.m3u8 short TTL]
R2[R2 - HLS + Source] R2[R2 - HLS + Source]
Pages[Pages - Static SPA] Pages[Pages - Static SPA]
end end
@@ -306,7 +306,7 @@ graph TB
- **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer - **Role**: Continuous HLS encoding loop using FFmpeg concat demuxer
- **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths - **Input**: `data/playlist/playlist.txt` — ordered list of MP4 file paths
- **Output**: HLS segments (`.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2 - **Output**: HLS segments (`.m4s` and `.ts`) + live manifest (`.m3u8`) written to `data/hls/`, mirrored to R2
- **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081 - **Resilience**: Restart counter (>5/60s → activate slate), Prometheus metrics on port 8081
- **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval) - **Validation**: ffprobe gate on all ingested files (codec, fps, resolution, keyframe interval)
@@ -383,7 +383,7 @@ Module-level breakdown per service.
2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s). 2. Playout worker (FFmpeg loop with concat demuxer) reads next file, packages as HLS segments (4-6s).
3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated. 3. Segments uploaded to R2 with unique immutable names; live `.m3u8` updated.
4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge. 4. Viewer loads SPA → requests master/live manifest → hls.js player follows current edge.
5. Cloudflare serves `.ts` from nearest edge cache (origin/R2 hit only once per POP/region). 5. Cloudflare serves `.m4s` and `.ts` from nearest edge cache (origin/R2 hit only once per POP/region).
6. Parallel WebSocket connection to chat service for synchronized interaction. 6. Parallel WebSocket connection to chat service for synchronized interaction.
Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`. Notable aspects: Manifest freshness ensures all viewers stay on same timeline; cache hit ratio directly impacts cost and latency. Fallback slate activates on playout failure. Full cache policy, example configs (FFmpeg bash loop, Nginx RTMP HLS), and storage tables in `adr/ADR-001.md`.
@@ -469,7 +469,7 @@ flowchart LR
**Cloudflare Layer**: **Cloudflare Layer**:
- R2 bucket for media. - R2 bucket for media.
- Cache Rules (`.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate). - Cache Rules (`.m4s` and `.ts`: Cache Everything, immutable, long TTL; `.m3u8`: short TTL/must-revalidate).
- WAF, rate limiting, Tiered Cache. - WAF, rate limiting, Tiered Cache.
- Pages for SPA hosting. - Pages for SPA hosting.
@@ -479,7 +479,7 @@ Future evolution: Docker Compose, managed DB, secondary regions (detailed in sub
### Caching & Cost Optimization ### Caching & Cost Optimization
Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops. Immutable segment naming + targeted Cloudflare Cache Rules + R2 ($0 egress) = near-zero viewer bandwidth cost. Long TTL on `.m4s` and `.ts`, short on live manifests. Monitor hit ratio and R2 Class B ops.
### Video Normalization & Playout ### Video Normalization & Playout
@@ -888,16 +888,16 @@ Document and rehearse this runbook quarterly; recovery that is not exercised is
## Glossary ## Glossary
| Term | Definition | | Term | Definition |
| ----------------- | ------------------------------------------------------------------------------------------ | | ----------------- | -------------------------------------------------------------------------------------- |
| HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (`.ts` or fMP4). | | HLS | HTTP Live Streaming – protocol using `.m3u8` manifests and media segments (fMP4). |
| Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. | | Immutable Segment | HLS chunk with unique, unchanging name enabling long-lived caching. |
| R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). | | R2 | Cloudflare object storage ($0 egress, ideal origin for cached HLS). |
| Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. | | Edge Cache / POP | Cloudflare points-of-presence that cache and serve content close to viewers. |
| Playout | Continuous generation of a scheduled linear program from a playlist. | | Playout | Continuous generation of a scheduled linear program from a playlist. |
| Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. | | Normalization | Pre-processing videos to consistent codec/fps/keyframe settings for seamless playback. |
| Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. | | Cache Rule | Cloudflare configuration defining caching behavior, TTL, and headers per URL pattern. |
| SPOF | Single Point of Failure (current single VPS origin). | | SPOF | Single Point of Failure (current single VPS origin). |
| Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. | | Tiered Cache | Cloudflare feature reducing origin requests by checking upper-tier caches first. |
**References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted. **References**: Cloudflare R2/Cache docs, MediaMTX/SRS/FFmpeg HLS guides, Hetzner pricing, hls.js documentation, standard video bitrate calculators. All original root `ADR-001-*.md` files have been consolidated and can be archived/deleted.
+6 -6
View File
@@ -19,11 +19,11 @@ We require a completely independent 24/7 linear video channel that broadcasts a
**Key distinctions**: **Key distinctions**:
- **Playout**: Continuous scheduled program from playlist. - **Playout**: Continuous scheduled program from playlist.
- **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.ts` segments, 4–6s duration). - **Packaging**: FFmpeg → standard HLS (`.m3u8` + `.m4s` segments, 4–6s duration).
- **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost). - **Delivery**: Immutable segments served via Cloudflare edge caching + R2 (zero egress cost).
- **Interaction**: Separate lightweight WebSocket service for chat. - **Interaction**: Separate lightweight WebSocket service for chat.
Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. Bandwidth (not storage or compute) is the dominant cost driver. A single viewer at ~4 Mbps consumes ~1.3–1.8 TB/month; 100 concurrent viewers = ~130–180 TB/month. Direct VPS delivery is only viable for PoC/small audiences. HLS segments are typically `.m4s` files, with `.m4s` being the default for fMP4 segments.
## Decision Drivers ## Decision Drivers
@@ -31,7 +31,7 @@ Bandwidth (not storage or compute) is the dominant cost driver. A single viewer
2. Full independence and ownership (no video platform lock-in). 2. Full independence and ownership (no video platform lock-in).
3. Strong US performance and low latency. 3. Strong US performance and low latency.
4. True/near-true synchronization via live-style HLS (not pure VOD). 4. True/near-true synchronization via live-style HLS (not pure VOD).
5. Aggressive immutable caching of `.ts` segments to achieve near-zero egress. 5. Aggressive immutable caching of `.m4s` segments to achieve near-zero egress.
6. Browser-native playback (`hls.js` + native HLS). 6. Browser-native playback (`hls.js` + native HLS).
7. Lightweight, maintainable stack with modest operational complexity. 7. Lightweight, maintainable stack with modest operational complexity.
8. Support for custom multi-room + private chat with persistence and moderation. 8. Support for custom multi-room + private chat with persistence and moderation.
@@ -58,9 +58,9 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
### 2. Storage & Delivery (Cloudflare R2 + CDN) ### 2. Storage & Delivery (Cloudflare R2 + CDN)
- HLS segments (`.ts`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`). - HLS segments (`.m4s`) and manifests uploaded to **Cloudflare R2** (Eastern North America hint `enam`).
- Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**: - Custom domain (`media.linearvoid.tv`) fronted by Cloudflare with **Cache Rules**:
- `*.ts` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`). - `*.m4s` (and init `.mp4` if using fMP4 later): `Cache Everything`, immutable, Edge TTL 24h–1yr (`Cache-Control: public, max-age=31536000, immutable`).
- `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`). - `*.m3u8`: Short TTL (1–10s) or `must-revalidate` (`Cache-Control: public, max-age=1..10, must-revalidate`).
- Master playlist: TTL ~30s. - Master playlist: TTL ~30s.
- Tiered Cache enabled where possible. - Tiered Cache enabled where possible.
@@ -90,7 +90,7 @@ Adopt a **self-managed US-East playout + Cloudflare R2 + edge-cached HLS** archi
**Initial Targets**: **Initial Targets**:
- Protocol: Standard HLS (`.ts` segments). Defer LL-HLS/CMAF/WebRTC. - Protocol: Standard HLS (`.m4s` segments). Defer LL-HLS/CMAF/WebRTC.
- Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later). - Max rendition: 720p30 initially (upgrade to 1080p30 or ABR later).
- Latency: 15–35s (acceptable for linear programming/chat). - Latency: 15–35s (acceptable for linear programming/chat).
+1 -1
View File
@@ -38,7 +38,7 @@ We must explicitly document language/runtime choices per module and the assumpti
1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding). 1. All video assets **must** be normalized before being added to any playlist (H.264, AAC, 30 fps constant, 2s keyframes, CRF encoding).
2. Deployment is **Docker-first**; services will be containerized. 2. Deployment is **Docker-first**; services will be containerized.
3. Cloudflare is the primary CDN and will be configured with specific Cache Rules (`.ts` immutable long TTL, `.m3u8` short TTL). 3. Cloudflare is the primary CDN and will be configured with specific [Cache Rules](../../../infrastructure/cloudflare/cache-rules.md).
4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed). 4. Chat does **not** require end-to-end encryption in v1 (separate ADR if needed).
5. Single VPS origin initially; multi-region is a later evolution. 5. Single VPS origin initially; multi-region is a later evolution.
6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts. 6. Monitoring focuses on playout health, cache hit ratio, and R2 operation counts.
+66 -1
View File
@@ -161,4 +161,69 @@ docker exec linearvoid-db psql -U linearvoid -c "SELECT count(*) FROM messages;"
3. Add prevention to docs if actionable 3. Add prevention to docs if actionable
4. Update this runbook if new failure mode discovered 4. Update this runbook if new failure mode discovered
**Recovery targets:** RPO ≤24h, RTO ≤60min. Quarterly DR drill: simulate host loss, restore from backup. ## Quarterly Disaster Recovery Drill
Goal: validate recovery targets under controlled host-loss simulation.
**Recovery targets:** RPO <= 24h, RTO <= 60min.
### Prerequisites
- Latest backup artifacts available from `scripts/backup-db.sh` and `scripts/backup-weekly.sh`
- Operator access to Docker host and DNS controls
- Current deployment variables available (`.env`, compose file, image tags)
### Drill Scope
- Simulate complete origin loss (all app containers unavailable)
- Rebuild stack on clean host
- Restore DB and media/playlist mounts
- Verify viewer playback, chat, admin auth, monitoring targets
### Drill Steps
1. Record drill start timestamp and on-call participants.
2. Capture pre-drill baseline:
- `curl -fsS http://<origin>/health`
- `curl -fsS http://<origin>:3000/health`
- `curl -fsS http://<origin>:8081/health`
3. Simulate origin outage:
- `docker compose -f docker-compose.prod.yml down`
4. Provision clean recovery host (or clean test-lab VM) and copy deployment bundle.
5. Restore required data paths:
- `data/playlist/`
- `data/media/`
- `data/chat/`
- `data/hls/` (optional warm cache)
6. Restore database from latest backup dump.
7. Start stack:
- `docker compose -f docker-compose.prod.yml up -d`
8. Validate service health and runtime:
- `docker ps --format "table {{.Names}}\t{{.Status}}"`
- `curl -fsS http://<origin>/health`
- `curl -fsS http://<origin>/hls/playlist.m3u8 | head -n 5`
- `curl -fsS http://<origin>:9090/-/ready`
9. Validate app behavior:
- Viewer SPA loads and HLS plays
- WebSocket chat connects and message round-trip works
- Admin login/session endpoint returns success
10. Run backup integrity check:
- `scripts/verify-backups.sh`
11. Record drill end timestamp and compute measured RTO/RPO.
### Pass/Fail Criteria
- Pass if all core services healthy and functional in <= 60 minutes.
- Pass if latest recoverable data point is <= 24 hours old.
- Fail if any critical path remains degraded (playout, chat, admin auth, monitoring).
### Evidence Template
Document each drill in incident tracker with:
- Date/time window
- Environment (test-lab, staging, production)
- Backup artifact IDs and timestamps
- Measured RTO and RPO
- Issues found and remediation owner
- Follow-up ticket links
@@ -0,0 +1,117 @@
# Cloudflare and UptimeRobot Manual Completion Checklist
Purpose: execute and record all manual dashboard tasks that cannot be automated from repository workflows.
## Status Legend
- [ ] Pending
- [x] Done
## 1) Cloudflare Pages Link and Build
- [ ] Connect repo to Cloudflare Pages project
- [ ] Build command set to: `cd frontend && npm run build`
- [ ] Output directory set to: `frontend/dist`
- [ ] Production domain attached to `linearvoid.tv`
Evidence to capture:
- Screenshot of Pages deployment settings
- Deployment URL and latest deployment timestamp
## 2) Tiered Cache Enablement and Validation
- [ ] Smart Tiered Cache enabled for zone
- [ ] Warm request made to hot HLS segment
- [ ] Warm request made to live manifest
- [ ] `Cf-Cache-Status: HIT` observed on repeated requests
Validation commands:
```bash
curl -I https://media.linearvoid.tv/hls/playlist.m3u8
curl -I https://media.linearvoid.tv/hls/<segment>.m4s
```
Evidence to capture:
- Headers from first request (expected MISS)
- Headers from repeated request (expected HIT)
## 3) WAF and Bot Protection
- [ ] OWASP Managed Ruleset enabled
- [ ] Mode set to Simulate
- [ ] Simulate mode reviewed for 48h
- [ ] Mode switched to Block after false-positive review
- [ ] Bot Fight Mode enabled
Evidence to capture:
- WAF ruleset status screenshot
- Count of blocked/challenged events after cutover
## 4) Rate Limiting Rules
- [ ] Rule created for `/api/admin/login`
- [ ] Rule created for `/api/admin/*`
- [ ] Threshold set to `10 requests / 10 seconds / IP`
- [ ] Action set to Managed Challenge
- [ ] Rule order validated
Validation approach:
- Use burst requests from a test client and verify challenge behavior without blocking legitimate admin flow.
Evidence to capture:
- Rule definitions screenshot
- Test result notes (challenge seen / no lockout)
## 5) HTTPS and WSS Hardening
- [ ] Always Use HTTPS enabled
- [ ] HSTS enabled with max-age 31536000
- [ ] HSTS includeSubDomains enabled
- [ ] HSTS preload enabled
- [ ] Minimum TLS version set to 1.2+
- [ ] SSL mode verified as Full (strict)
- [ ] WSS endpoint validated through proxy (`wss://linearvoid.tv/ws`)
Validation commands:
```bash
curl -I https://linearvoid.tv
curl -I https://linearvoid.tv/health
```
Evidence to capture:
- Response headers showing HSTS
- Successful browser WebSocket connect in devtools
## 6) UptimeRobot Monitors
- [ ] HTTP monitor added for `https://linearvoid.tv/health` (5m)
- [ ] Keyword monitor added for `https://linearvoid.tv/hls/playlist.m3u8` (keyword `#EXTM3U`, 5m)
- [ ] Alert trigger set to 2 consecutive failures
- [ ] Notification channels configured (email + Discord/Slack webhook)
Evidence to capture:
- Monitor dashboard screenshot
- Test alert delivery confirmation
## 7) Sign-Off
- [ ] Cloudflare manual tasks complete
- [ ] UptimeRobot tasks complete
- [ ] TODO items updated to done
- [ ] Changelog entry added for completion batch
Sign-off fields:
- Owner:
- Date:
- Environment (test-lab or production):
- Notes:
@@ -0,0 +1,203 @@
# Scaling and Performance Playbook
Purpose: define repeatable method to measure performance, capture baseline data, and decide scaling actions.
Scope: planning and execution guide for TODO items under "Scaling & Performance (planning only)".
## 1) Manifest TTL Tuning Plan
Goal: tune `.m3u8` TTL to about `0.5x` segment duration while keeping playback stable.
Inputs:
- Segment duration (`HLS_SEGMENT_DURATION`)
- Current manifest TTL (Cloudflare cache rule)
- Cache outcome distribution (`Cf-Cache-Status`)
Procedure:
1. Start from current manifest TTL (2s).
2. Capture 24h baseline headers from repeated `playlist.m3u8` requests.
3. Test candidate TTLs: 1s, 2s, 3s.
4. Compare:
- rebuffer events from player logs
- origin request increase/decrease
- HIT ratio changes
5. Select lowest TTL that keeps playback stable and origin load acceptable.
Acceptance:
- No material increase in rebuffer rate.
- Manifest staleness not observable by users.
## 2) TTFB Baseline Plan
Goal: measure warm-cache TTFB from 3+ US regions with target `< 4s`.
Suggested regions:
- US East (Virginia)
- US Central
- US West (California/Oregon)
Method:
1. Use WebPageTest with repeat view enabled.
2. Run at least 5 samples per region.
3. Record median and p95 TTFB.
4. Store results in table (date, region, median, p95, test URL).
Acceptance:
- Warm-cache median < 4s in all tested regions.
- Outliers documented with likely cause.
## 3) WebSocket Load-Test Plan
Goal: characterize chat service behavior across 10 -> 100 -> 500 -> 1000 concurrent clients.
Tooling option A (Artillery):
```bash
npx artillery run scripts/loadtest/ws-chat.yml
```
Tooling option B (custom Go harness):
- open N concurrent WebSocket clients
- send message every X seconds
- measure round-trip latency and failures
Metrics to capture:
- p50/p95 message latency
- error rate (connect, send, receive)
- dropped connections
- CPU/memory for `chat`, `redis`, `nginx`
Acceptance:
- p95 chat latency <= 1s at target concurrency tier.
- error rate <= 1% during steady state.
## 4) Admin API Benchmark Plan
Goal: measure admin API p95 and identify slow endpoints.
Tooling option A (`k6`):
```bash
k6 run scripts/loadtest/admin-api.js
```
Tooling option B (`wrk`):
```bash
wrk -t4 -c100 -d60s http://<host>/api/admin/overview
```
Endpoints:
- `/api/admin/overview`
- `/api/admin/analytics`
- `/api/admin/chat-log`
- `/api/admin/users`
Acceptance:
- p95 < 1s for primary admin endpoints.
- slow endpoint list with remediation notes.
## 5) Cache Hit Ratio Tracking Plan
Goal: track Cloudflare cache hit ratio and alert when below threshold.
Implementation outline:
1. Pull Cloudflare GraphQL analytics on schedule.
2. Export to Prometheus-compatible metric path.
3. Add Grafana panel with 5m/1h/24h windows.
4. Alert when hit ratio < 90% for 15m.
Metric target:
- `cache_hit_ratio >= 0.90`
## 6) Capacity Trigger Rules
Define operational triggers:
- Cache miss ratio > 10% for 15m
- Chat p95 latency > 1s for 5m
- Host CPU > 80% for 10m
- Redis memory > 85% sustained
Action mapping:
- Level 1: investigate + restart non-critical components
- Level 2: scale service resources (CPU/RAM)
- Level 3: activate standby planning path
## 7) Phase 2 Service Separation Plan
Objective: separate bottlenecked services while keeping simple ops.
Boundaries:
- `playout` isolated first (CPU-heavy)
- `chat` + `redis` grouped for latency
- `db` isolated for IO stability
- monitoring stack optionally separated after sustained load
Initial sizing guidance:
- playout host: 4 vCPU / 8 GB
- chat host: 2-4 vCPU / 4-8 GB
- db host: 2 vCPU / 4 GB + fast disk
Exit criteria for Phase 2:
- repeated trigger events over 7 days
- capacity saturation despite tuning
## 8) Phase 3 Warm Standby Plan
Objective: second-region resilience with DNS failover.
Candidate layout:
- Primary: Hetzner Ashburn (US-East)
- Standby: Hetzner Hillsboro (US-West)
Design:
- Warm standby stack deployed and health-checked
- Redis replication or fallback mode documented
- Postgres replication strategy selected (streaming replica or periodic snapshot restore)
- Cloudflare DNS failover policy defined (health-based or manual emergency switch)
Failover drill requirements:
- periodic simulated cutover
- measured RTO/RPO logged in incident records
## 9) Data Capture Template
For each benchmark run, capture:
- Date/time
- Environment (test-lab/staging/production)
- Commit SHA / image tag
- Test scenario and duration
- Raw metrics (latency, errors, resource usage)
- Conclusion and next action
## 10) Execution Order
1. TTFB baseline
2. WebSocket load profile
3. Admin API benchmark
4. Cache ratio panel + alerting
5. Trigger tuning
6. Phase 2 boundary decision
7. Phase 3 standby design review
+4 -4
View File
@@ -262,7 +262,7 @@ select {
.btn { .btn {
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--radius-1); border-radius: var(--radius-1);
padding: 0.62rem 0.9rem; padding: 0.2rem 0.5rem;
font-weight: 650; font-weight: 650;
cursor: pointer; cursor: pointer;
transition: transition:
@@ -411,14 +411,14 @@ select {
.lv-body { .lv-body {
min-height: 100vh; min-height: 100vh;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
.viewer-shell { .viewer-shell {
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
margin: 0; margin: 0;
display: grid; display: grid;
@@ -821,7 +821,7 @@ select {
border: 1px solid var(--line-soft); border: 1px solid var(--line-soft);
border-radius: var(--radius-2); border-radius: var(--radius-2);
background: var(--panel); background: var(--panel);
padding: var(--space-4); padding: var(--space-2);
box-shadow: var(--shadow-1); box-shadow: var(--shadow-1);
} }
+8 -8
View File
@@ -1,25 +1,25 @@
# Cloudflare Cache Rules for Linear Void (per ADR-001) # Cloudflare Cache Rules for Linear Void (per ADR-001)
## Recommended Cache Rules for media.example.com ## Recommended Cache Rules for media.linearvoid.tv
1. **.ts segments (immutable)** 1. **.m4s and .ts segments (immutable)**
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".ts") - Expression: (http.host eq "media.linearvoid.tv" and (http.request.uri.path contains ".ts" or http.request.uri.path contains ".m4s"))
- Cache status: Eligible for cache - Cache status: Eligible for cache
- Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin) - Edge Cache TTL: 1 month (or `Cache-Control: public, max-age=31536000, immutable` from origin)
- Cache Everything: Yes - Cache Everything: Yes
2. **.m3u8 manifests (live)** 2. **.m3u8 manifests (live)**
- Expression: (http.host eq "media.example.com" and http.request.uri.path contains ".m3u8") - Expression: (http.host eq "media.linearvoid.tv" and http.request.uri.path contains ".m3u8")
- Cache status: Eligible for cache - Cache status: Eligible for cache
- Edge Cache TTL: 10 seconds - Edge Cache TTL: 2 seconds
- Cache-Control: public, max-age=10, must-revalidate - Cache-Control: public, max-age=2, must-revalidate
3. **Static assets** 3. **Static assets**
- Expression: (http.host eq "example.com" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css") - Expression: (http.host eq "linearvoid.tv" and http.request.uri.path contains ".js" or http.request.uri.path contains ".css")
- Edge Cache TTL: 1 year (with hash in filename for immutable caching) - Edge Cache TTL: 1 year (with hash in filename for immutable caching)
**Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests. **Tiered Cache**: Enable Smart Tiered Cache for R2 custom domain to reduce origin requests.
**WAF**: Enable basic rate limiting and bot management for /ws and admin routes. **WAF**: Enable basic rate limiting and bot management for /ws and admin routes.
These rules ensure high cache hit ratio for .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics. These rules ensure high cache hit ratio for .m4s and .ts segments while keeping live manifests fresh. Monitor cache hit ratio in Cloudflare analytics.
+2 -2
View File
@@ -6,8 +6,8 @@ addEventListener("fetch", (event) => {
async function handleRequest(request) { async function handleRequest(request) {
const url = new URL(request.url); const url = new URL(request.url);
// Add immutable cache headers for .ts segments // Add immutable cache headers for .m4s segments
if (url.pathname.endsWith(".ts")) { if (url.pathname.endsWith(".m4s")) {
const response = await fetch(request); const response = await fetch(request);
const headers = new Headers(response.headers); const headers = new Headers(response.headers);
headers.set("Cache-Control", "public, max-age=31536000, immutable"); headers.set("Cache-Control", "public, max-age=31536000, immutable");